generated from nhcarrigan/template
1ae440659c
## Summary - **Fix git window "Not a git repository" error** — The working directory received from Claude Code is a WSL Linux path (e.g. `/home/naomi/...`), but git commands were being run as native Windows processes with `.current_dir()`. Windows can't resolve WSL paths, causing `git rev-parse --git-dir` to fail. Fixed by routing git commands through `wsl -- git -C <path>` when the working directory starts with `/`. - **Add syntax highlighting and line numbers to diff view** — Replaced the raw `<pre>` block with a proper `DiffViewer` component featuring: - Old/new line number columns with correct tracking across hunks - Colour-coded gutter (`+`/`-`) with green/red row backgrounds - Syntax highlighting via `highlight.js` using the detected file language, respecting all app themes via `--hljs-*` CSS variables - Styled hunk headers and file headers ## New files - `src/lib/utils/diffParser.ts` — pure diff parsing logic - `src/lib/utils/diffParser.test.ts` — 30 tests covering all line types, line number tracking, and language detection - `src/lib/components/DiffViewer.svelte` — the pretty diff viewer component ✨ This pull request was created with help from Hikari~ 🌸 Reviewed-on: #178 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
932 lines
30 KiB
Rust
932 lines
30 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::process::Command;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
use crate::process_ext::HideWindow;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct GitStatus {
|
|
pub is_repo: bool,
|
|
pub branch: Option<String>,
|
|
pub upstream: Option<String>,
|
|
pub ahead: u32,
|
|
pub behind: u32,
|
|
pub staged: Vec<GitFileChange>,
|
|
pub unstaged: Vec<GitFileChange>,
|
|
pub untracked: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct GitFileChange {
|
|
pub path: String,
|
|
pub status: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct GitBranch {
|
|
pub name: String,
|
|
pub is_current: bool,
|
|
pub is_remote: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct GitLogEntry {
|
|
pub hash: String,
|
|
pub short_hash: String,
|
|
pub author: String,
|
|
pub date: String,
|
|
pub message: String,
|
|
}
|
|
|
|
/// Builds the WSL argument list for running a git command at a Linux path.
|
|
/// Extracted for testability without requiring WSL to be available.
|
|
#[cfg(any(target_os = "windows", test))]
|
|
fn build_wsl_git_args<'a>(working_dir: &'a str, args: &[&'a str]) -> Vec<&'a str> {
|
|
let mut wsl_args = vec!["--", "git", "-C", working_dir];
|
|
wsl_args.extend_from_slice(args);
|
|
wsl_args
|
|
}
|
|
|
|
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
|
#[cfg(target_os = "windows")]
|
|
let output = {
|
|
if working_dir.starts_with('/') {
|
|
// WSL/Linux path — run git through WSL so it can resolve the path correctly.
|
|
let wsl_args = build_wsl_git_args(working_dir, args);
|
|
Command::new("wsl")
|
|
.hide_window()
|
|
.args(&wsl_args)
|
|
.output()
|
|
.map_err(|e| format!("Failed to execute git via WSL: {}", e))?
|
|
} else {
|
|
Command::new("git")
|
|
.hide_window()
|
|
.args(args)
|
|
.current_dir(working_dir)
|
|
.output()
|
|
.map_err(|e| format!("Failed to execute git: {}", e))?
|
|
}
|
|
};
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
let output = Command::new("git")
|
|
.args(args)
|
|
.current_dir(working_dir)
|
|
.output()
|
|
.map_err(|e| format!("Failed to execute git: {}", e))?;
|
|
|
|
if output.status.success() {
|
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
} else {
|
|
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_status(working_dir: String) -> Result<GitStatus, String> {
|
|
// Check if it's a git repo
|
|
let is_repo = run_git_command(&working_dir, &["rev-parse", "--git-dir"]).is_ok();
|
|
|
|
if !is_repo {
|
|
return Ok(GitStatus {
|
|
is_repo: false,
|
|
branch: None,
|
|
upstream: None,
|
|
ahead: 0,
|
|
behind: 0,
|
|
staged: vec![],
|
|
unstaged: vec![],
|
|
untracked: vec![],
|
|
});
|
|
}
|
|
|
|
// Get current branch
|
|
let branch = run_git_command(&working_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
|
|
.ok()
|
|
.map(|s| s.trim().to_string());
|
|
|
|
// Get upstream branch
|
|
let upstream = run_git_command(
|
|
&working_dir,
|
|
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
|
)
|
|
.ok()
|
|
.map(|s| s.trim().to_string());
|
|
|
|
// Get ahead/behind counts
|
|
let (ahead, behind) = if upstream.is_some() {
|
|
let rev_list =
|
|
run_git_command(&working_dir, &["rev-list", "--left-right", "--count", "@{u}...HEAD"])
|
|
.unwrap_or_default();
|
|
let parts: Vec<&str> = rev_list.trim().split('\t').collect();
|
|
if parts.len() == 2 {
|
|
(
|
|
parts[1].parse().unwrap_or(0),
|
|
parts[0].parse().unwrap_or(0),
|
|
)
|
|
} else {
|
|
(0, 0)
|
|
}
|
|
} else {
|
|
(0, 0)
|
|
};
|
|
|
|
// Get status with porcelain format
|
|
let status_output =
|
|
run_git_command(&working_dir, &["status", "--porcelain=v1"]).unwrap_or_default();
|
|
|
|
let mut staged = vec![];
|
|
let mut unstaged = vec![];
|
|
let mut untracked = vec![];
|
|
|
|
for line in status_output.lines() {
|
|
if line.len() < 3 {
|
|
continue;
|
|
}
|
|
|
|
let index_status = line.chars().next().unwrap_or(' ');
|
|
let worktree_status = line.chars().nth(1).unwrap_or(' ');
|
|
let path = line[3..].to_string();
|
|
|
|
// Untracked files
|
|
if index_status == '?' && worktree_status == '?' {
|
|
untracked.push(path);
|
|
continue;
|
|
}
|
|
|
|
// Staged changes (index status)
|
|
if index_status != ' ' && index_status != '?' {
|
|
staged.push(GitFileChange {
|
|
path: path.clone(),
|
|
status: match index_status {
|
|
'M' => "modified".to_string(),
|
|
'A' => "added".to_string(),
|
|
'D' => "deleted".to_string(),
|
|
'R' => "renamed".to_string(),
|
|
'C' => "copied".to_string(),
|
|
_ => "unknown".to_string(),
|
|
},
|
|
});
|
|
}
|
|
|
|
// Unstaged changes (worktree status)
|
|
if worktree_status != ' ' && worktree_status != '?' {
|
|
unstaged.push(GitFileChange {
|
|
path,
|
|
status: match worktree_status {
|
|
'M' => "modified".to_string(),
|
|
'D' => "deleted".to_string(),
|
|
_ => "unknown".to_string(),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(GitStatus {
|
|
is_repo: true,
|
|
branch,
|
|
upstream,
|
|
ahead,
|
|
behind,
|
|
staged,
|
|
unstaged,
|
|
untracked,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_diff(working_dir: String, file_path: Option<String>, staged: bool) -> Result<String, String> {
|
|
let mut args = vec!["diff"];
|
|
|
|
if staged {
|
|
args.push("--cached");
|
|
}
|
|
|
|
if let Some(ref path) = file_path {
|
|
args.push("--");
|
|
args.push(path);
|
|
}
|
|
|
|
run_git_command(&working_dir, &args)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_branches(working_dir: String) -> Result<Vec<GitBranch>, String> {
|
|
let output = run_git_command(&working_dir, &["branch", "-a", "--format=%(refname:short)\t%(HEAD)"])?;
|
|
|
|
let branches: Vec<GitBranch> = output
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let parts: Vec<&str> = line.split('\t').collect();
|
|
if parts.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let name = parts[0].to_string();
|
|
let is_current = parts.get(1).map(|s| *s == "*").unwrap_or(false);
|
|
let is_remote = name.starts_with("remotes/") || name.starts_with("origin/");
|
|
|
|
Some(GitBranch {
|
|
name,
|
|
is_current,
|
|
is_remote,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(branches)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_checkout(working_dir: String, branch: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["checkout", &branch])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_stage(working_dir: String, file_path: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["add", &file_path])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_unstage(working_dir: String, file_path: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["restore", "--staged", &file_path])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_stage_all(working_dir: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["add", "-A"])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_commit(working_dir: String, message: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["commit", "-m", &message])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_push(working_dir: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["push"])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_pull(working_dir: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["pull"])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_fetch(working_dir: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["fetch", "--all"])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_log(working_dir: String, limit: Option<u32>) -> Result<Vec<GitLogEntry>, String> {
|
|
let limit_str = limit.unwrap_or(10).to_string();
|
|
let output = run_git_command(
|
|
&working_dir,
|
|
&[
|
|
"log",
|
|
&format!("-{}", limit_str),
|
|
"--pretty=format:%H\t%h\t%an\t%ar\t%s",
|
|
],
|
|
)?;
|
|
|
|
let entries: Vec<GitLogEntry> = output
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let parts: Vec<&str> = line.split('\t').collect();
|
|
if parts.len() < 5 {
|
|
return None;
|
|
}
|
|
|
|
Some(GitLogEntry {
|
|
hash: parts[0].to_string(),
|
|
short_hash: parts[1].to_string(),
|
|
author: parts[2].to_string(),
|
|
date: parts[3].to_string(),
|
|
message: parts[4..].join("\t"),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(entries)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_discard(working_dir: String, file_path: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["checkout", "--", &file_path])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_create_branch(working_dir: String, branch_name: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["checkout", "-b", &branch_name])
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs::{self, File};
|
|
use std::io::Write;
|
|
use tempfile::TempDir;
|
|
|
|
// ==================== build_wsl_git_args tests ====================
|
|
|
|
#[test]
|
|
fn test_build_wsl_git_args_structure() {
|
|
let args = build_wsl_git_args("/home/naomi/code/project", &["status", "--porcelain=v1"]);
|
|
assert_eq!(args[0], "--");
|
|
assert_eq!(args[1], "git");
|
|
assert_eq!(args[2], "-C");
|
|
assert_eq!(args[3], "/home/naomi/code/project");
|
|
assert_eq!(args[4], "status");
|
|
assert_eq!(args[5], "--porcelain=v1");
|
|
assert_eq!(args.len(), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_wsl_git_args_no_extra_args() {
|
|
let args = build_wsl_git_args("/home/user/repo", &["init"]);
|
|
assert_eq!(args, vec!["--", "git", "-C", "/home/user/repo", "init"]);
|
|
}
|
|
|
|
// Helper to create a git repository in a temp directory
|
|
fn create_test_repo() -> TempDir {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Initialize git repo
|
|
run_git_command(&working_dir, &["init"]).unwrap();
|
|
|
|
// Configure git user for commits
|
|
run_git_command(&working_dir, &["config", "user.email", "test@example.com"]).unwrap();
|
|
run_git_command(&working_dir, &["config", "user.name", "Test User"]).unwrap();
|
|
|
|
// Disable GPG signing for tests (user may have it enabled globally)
|
|
run_git_command(&working_dir, &["config", "commit.gpgsign", "false"]).unwrap();
|
|
|
|
temp_dir
|
|
}
|
|
|
|
// Helper to create a file in the test repo
|
|
fn create_file(dir: &TempDir, name: &str, content: &str) {
|
|
let file_path = dir.path().join(name);
|
|
let mut file = File::create(file_path).unwrap();
|
|
file.write_all(content.as_bytes()).unwrap();
|
|
}
|
|
|
|
// ==================== GitStatus struct tests ====================
|
|
|
|
#[test]
|
|
fn test_git_status_serialization() {
|
|
let status = GitStatus {
|
|
is_repo: true,
|
|
branch: Some("main".to_string()),
|
|
upstream: Some("origin/main".to_string()),
|
|
ahead: 2,
|
|
behind: 1,
|
|
staged: vec![GitFileChange {
|
|
path: "file.txt".to_string(),
|
|
status: "modified".to_string(),
|
|
}],
|
|
unstaged: vec![],
|
|
untracked: vec!["new_file.txt".to_string()],
|
|
};
|
|
|
|
let json = serde_json::to_string(&status).unwrap();
|
|
assert!(json.contains("\"is_repo\":true"));
|
|
assert!(json.contains("\"branch\":\"main\""));
|
|
assert!(json.contains("\"ahead\":2"));
|
|
assert!(json.contains("\"behind\":1"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_status_not_a_repo() {
|
|
let status = GitStatus {
|
|
is_repo: false,
|
|
branch: None,
|
|
upstream: None,
|
|
ahead: 0,
|
|
behind: 0,
|
|
staged: vec![],
|
|
unstaged: vec![],
|
|
untracked: vec![],
|
|
};
|
|
|
|
let json = serde_json::to_string(&status).unwrap();
|
|
let deserialized: GitStatus = serde_json::from_str(&json).unwrap();
|
|
assert!(!deserialized.is_repo);
|
|
assert!(deserialized.branch.is_none());
|
|
}
|
|
|
|
// ==================== GitFileChange struct tests ====================
|
|
|
|
#[test]
|
|
fn test_git_file_change_serialization() {
|
|
let change = GitFileChange {
|
|
path: "src/main.rs".to_string(),
|
|
status: "added".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&change).unwrap();
|
|
assert!(json.contains("src/main.rs"));
|
|
assert!(json.contains("added"));
|
|
|
|
let deserialized: GitFileChange = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(deserialized.path, "src/main.rs");
|
|
assert_eq!(deserialized.status, "added");
|
|
}
|
|
|
|
// ==================== GitBranch struct tests ====================
|
|
|
|
#[test]
|
|
fn test_git_branch_serialization() {
|
|
let branch = GitBranch {
|
|
name: "feature/new-feature".to_string(),
|
|
is_current: true,
|
|
is_remote: false,
|
|
};
|
|
|
|
let json = serde_json::to_string(&branch).unwrap();
|
|
assert!(json.contains("feature/new-feature"));
|
|
assert!(json.contains("\"is_current\":true"));
|
|
assert!(json.contains("\"is_remote\":false"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_branch_remote() {
|
|
let branch = GitBranch {
|
|
name: "origin/main".to_string(),
|
|
is_current: false,
|
|
is_remote: true,
|
|
};
|
|
|
|
let json = serde_json::to_string(&branch).unwrap();
|
|
let deserialized: GitBranch = serde_json::from_str(&json).unwrap();
|
|
assert!(deserialized.is_remote);
|
|
assert!(!deserialized.is_current);
|
|
}
|
|
|
|
// ==================== GitLogEntry struct tests ====================
|
|
|
|
#[test]
|
|
fn test_git_log_entry_serialization() {
|
|
let entry = GitLogEntry {
|
|
hash: "abc123def456".to_string(),
|
|
short_hash: "abc123d".to_string(),
|
|
author: "Hikari".to_string(),
|
|
date: "2 hours ago".to_string(),
|
|
message: "feat: add new feature".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&entry).unwrap();
|
|
assert!(json.contains("abc123def456"));
|
|
assert!(json.contains("Hikari"));
|
|
assert!(json.contains("feat: add new feature"));
|
|
}
|
|
|
|
// ==================== git_status integration tests ====================
|
|
|
|
#[test]
|
|
fn test_git_status_not_a_git_repo() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
let result = git_status(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let status = result.unwrap();
|
|
assert!(!status.is_repo);
|
|
assert!(status.branch.is_none());
|
|
assert!(status.staged.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_status_empty_repo() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
let result = git_status(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let status = result.unwrap();
|
|
assert!(status.is_repo);
|
|
assert!(status.staged.is_empty());
|
|
assert!(status.unstaged.is_empty());
|
|
assert!(status.untracked.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_status_with_untracked_file() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create an untracked file
|
|
create_file(&temp_dir, "untracked.txt", "hello");
|
|
|
|
let result = git_status(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let status = result.unwrap();
|
|
assert!(status.is_repo);
|
|
assert!(status.untracked.contains(&"untracked.txt".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_status_with_staged_file() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create and stage a file
|
|
create_file(&temp_dir, "staged.txt", "hello");
|
|
run_git_command(&working_dir, &["add", "staged.txt"]).unwrap();
|
|
|
|
let result = git_status(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let status = result.unwrap();
|
|
assert!(status.is_repo);
|
|
assert!(!status.staged.is_empty());
|
|
assert_eq!(status.staged[0].path, "staged.txt");
|
|
assert_eq!(status.staged[0].status, "added");
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_status_with_modified_file() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create, stage, and commit a file
|
|
create_file(&temp_dir, "file.txt", "initial content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial commit"]).unwrap();
|
|
|
|
// Modify the file
|
|
create_file(&temp_dir, "file.txt", "modified content");
|
|
|
|
let result = git_status(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let status = result.unwrap();
|
|
assert!(status.is_repo);
|
|
assert!(!status.unstaged.is_empty());
|
|
assert_eq!(status.unstaged[0].path, "file.txt");
|
|
assert_eq!(status.unstaged[0].status, "modified");
|
|
}
|
|
|
|
// ==================== git_diff integration tests ====================
|
|
|
|
#[test]
|
|
fn test_git_diff_no_changes() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
let result = git_diff(working_dir, None, false);
|
|
assert!(result.is_ok());
|
|
assert!(result.unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_diff_with_changes() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create and commit a file
|
|
create_file(&temp_dir, "file.txt", "initial content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Modify the file
|
|
create_file(&temp_dir, "file.txt", "modified content");
|
|
|
|
let result = git_diff(working_dir, None, false);
|
|
assert!(result.is_ok());
|
|
let diff = result.unwrap();
|
|
assert!(diff.contains("diff"));
|
|
assert!(diff.contains("file.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_diff_staged() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create and commit a file
|
|
create_file(&temp_dir, "file.txt", "initial content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Modify and stage the file
|
|
create_file(&temp_dir, "file.txt", "modified content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
|
|
let result = git_diff(working_dir, None, true);
|
|
assert!(result.is_ok());
|
|
let diff = result.unwrap();
|
|
assert!(diff.contains("diff"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_diff_specific_file() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create and commit files
|
|
create_file(&temp_dir, "file1.txt", "content1");
|
|
create_file(&temp_dir, "file2.txt", "content2");
|
|
run_git_command(&working_dir, &["add", "-A"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Modify both files
|
|
create_file(&temp_dir, "file1.txt", "modified1");
|
|
create_file(&temp_dir, "file2.txt", "modified2");
|
|
|
|
// Get diff for only file1.txt
|
|
let result = git_diff(working_dir, Some("file1.txt".to_string()), false);
|
|
assert!(result.is_ok());
|
|
let diff = result.unwrap();
|
|
assert!(diff.contains("file1.txt"));
|
|
assert!(!diff.contains("file2.txt"));
|
|
}
|
|
|
|
// ==================== git_branches integration tests ====================
|
|
|
|
#[test]
|
|
fn test_git_branches_single_branch() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Need at least one commit for branches to show
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
let result = git_branches(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let branches = result.unwrap();
|
|
assert!(!branches.is_empty());
|
|
// Should have at least one branch (main or master)
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_branches_multiple_branches() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Initial commit
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Create additional branch
|
|
run_git_command(&working_dir, &["branch", "feature-branch"]).unwrap();
|
|
|
|
let result = git_branches(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let branches = result.unwrap();
|
|
assert!(branches.len() >= 2);
|
|
assert!(branches.iter().any(|b| b.name == "feature-branch"));
|
|
}
|
|
|
|
// ==================== git_stage and git_unstage tests ====================
|
|
|
|
#[test]
|
|
fn test_git_stage_file() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
|
|
let result = git_stage(working_dir.clone(), "file.txt".to_string());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify file is staged
|
|
let status = git_status(working_dir).unwrap();
|
|
assert!(status.staged.iter().any(|f| f.path == "file.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_unstage_file() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// First, commit a file so we have a HEAD to restore from
|
|
create_file(&temp_dir, "file.txt", "initial content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Modify and stage the file
|
|
create_file(&temp_dir, "file.txt", "modified content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
|
|
let result = git_unstage(working_dir.clone(), "file.txt".to_string());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify file is unstaged (should now be in unstaged/modified, not staged)
|
|
let status = git_status(working_dir).unwrap();
|
|
assert!(!status.staged.iter().any(|f| f.path == "file.txt"));
|
|
assert!(status.unstaged.iter().any(|f| f.path == "file.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_stage_all() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
create_file(&temp_dir, "file1.txt", "content1");
|
|
create_file(&temp_dir, "file2.txt", "content2");
|
|
|
|
let result = git_stage_all(working_dir.clone());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify all files are staged
|
|
let status = git_status(working_dir).unwrap();
|
|
assert_eq!(status.staged.len(), 2);
|
|
}
|
|
|
|
// ==================== git_commit tests ====================
|
|
|
|
#[test]
|
|
fn test_git_commit() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
|
|
let result = git_commit(working_dir.clone(), "test commit message".to_string());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify commit was made
|
|
let log = git_log(working_dir, Some(1)).unwrap();
|
|
assert!(!log.is_empty());
|
|
assert!(log[0].message.contains("test commit message"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_commit_nothing_to_commit() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Need initial commit first
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Try to commit with nothing staged
|
|
let result = git_commit(working_dir, "empty commit".to_string());
|
|
assert!(result.is_err()); // Should fail because nothing to commit
|
|
}
|
|
|
|
// ==================== git_log tests ====================
|
|
|
|
#[test]
|
|
fn test_git_log_empty_repo() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
let result = git_log(working_dir, Some(10));
|
|
// May fail on empty repo or return empty
|
|
if let Ok(commits) = result {
|
|
assert!(commits.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_log_with_commits() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Make multiple commits
|
|
for i in 1..=3 {
|
|
create_file(&temp_dir, &format!("file{}.txt", i), "content");
|
|
run_git_command(&working_dir, &["add", "-A"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", &format!("commit {}", i)]).unwrap();
|
|
}
|
|
|
|
let result = git_log(working_dir, Some(10));
|
|
assert!(result.is_ok());
|
|
|
|
let log = result.unwrap();
|
|
assert_eq!(log.len(), 3);
|
|
assert!(log[0].message.contains("commit 3")); // Most recent first
|
|
assert!(log[2].message.contains("commit 1"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_log_limit() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Make 5 commits
|
|
for i in 1..=5 {
|
|
create_file(&temp_dir, &format!("file{}.txt", i), "content");
|
|
run_git_command(&working_dir, &["add", "-A"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", &format!("commit {}", i)]).unwrap();
|
|
}
|
|
|
|
// Only get last 2
|
|
let result = git_log(working_dir, Some(2));
|
|
assert!(result.is_ok());
|
|
|
|
let log = result.unwrap();
|
|
assert_eq!(log.len(), 2);
|
|
}
|
|
|
|
// ==================== git_discard tests ====================
|
|
|
|
#[test]
|
|
fn test_git_discard_changes() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create and commit a file
|
|
create_file(&temp_dir, "file.txt", "original content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Modify the file
|
|
create_file(&temp_dir, "file.txt", "modified content");
|
|
|
|
// Discard changes
|
|
let result = git_discard(working_dir.clone(), "file.txt".to_string());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify file contents are restored
|
|
let content = fs::read_to_string(temp_dir.path().join("file.txt")).unwrap();
|
|
assert_eq!(content, "original content");
|
|
}
|
|
|
|
// ==================== git_create_branch tests ====================
|
|
|
|
#[test]
|
|
fn test_git_create_branch() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Initial commit required
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
let result = git_create_branch(working_dir.clone(), "new-branch".to_string());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify branch exists and is current
|
|
let branches = git_branches(working_dir).unwrap();
|
|
assert!(branches.iter().any(|b| b.name == "new-branch" && b.is_current));
|
|
}
|
|
|
|
// ==================== git_checkout tests ====================
|
|
|
|
#[test]
|
|
fn test_git_checkout() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Initial commit required
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Create a branch
|
|
run_git_command(&working_dir, &["branch", "other-branch"]).unwrap();
|
|
|
|
// Checkout the branch
|
|
let result = git_checkout(working_dir.clone(), "other-branch".to_string());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify current branch
|
|
let branches = git_branches(working_dir).unwrap();
|
|
let current = branches.iter().find(|b| b.is_current);
|
|
assert!(current.is_some());
|
|
assert_eq!(current.unwrap().name, "other-branch");
|
|
}
|
|
|
|
// ==================== run_git_command tests ====================
|
|
|
|
#[test]
|
|
fn test_run_git_command_success() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
let result = run_git_command(&working_dir, &["status"]);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_run_git_command_failure() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// This should fail because it's not a git repo
|
|
let result = run_git_command(&working_dir, &["log"]);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_run_git_command_invalid_dir() {
|
|
let result = run_git_command("/nonexistent/path", &["status"]);
|
|
assert!(result.is_err());
|
|
}
|
|
}
|