diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8148ca8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo build *)", + "Bash(npm run *)", + "Bash(kill %1)" + ] + } +} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 06a0104..fd1d776 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -63,6 +63,25 @@ pub struct GitCommitFile { pub status: FileStatusKind, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GitDiffFile { + pub path: String, + pub old_path: Option, + pub status: FileStatusKind, + pub additions: u32, + pub deletions: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GitCommitComparison { + pub from_hash: String, + pub from_short: String, + pub to_hash: String, + pub to_short: String, + pub files: Vec, + pub patch: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitRepositoryFile { pub path: String, @@ -330,6 +349,96 @@ pub fn restore_file_from_commit( status_for_repo(&repo) } +#[tauri::command] +pub fn compare_commits( + path: String, + from: String, + to: String, +) -> Result { + let repo = resolve_repo(&path)?; + let from_hash = verify_commit(&repo, &from)?; + let to_hash = verify_commit(&repo, &to)?; + + let name_status = run_git( + &repo, + [ + "diff", + "--name-status", + "-M", + "-z", + from_hash.as_str(), + to_hash.as_str(), + ], + )?; + let numstat = run_git( + &repo, + [ + "diff", + "--numstat", + "-M", + "-z", + from_hash.as_str(), + to_hash.as_str(), + ], + )?; + let patch_output = run_git(&repo, ["diff", "-M", from_hash.as_str(), to_hash.as_str()])?; + + let files = parse_diff_files(&name_status, &numstat)?; + let patch = String::from_utf8_lossy(&patch_output).to_string(); + + Ok(GitCommitComparison { + from_short: short_hash(&from_hash), + to_short: short_hash(&to_hash), + from_hash, + to_hash, + files, + patch, + }) +} + +#[tauri::command] +pub fn diff_file_against_working_tree( + path: String, + commit: String, + file: String, +) -> Result { + let repo = resolve_repo(&path)?; + validate_files(std::slice::from_ref(&file))?; + let commit_hash = verify_commit(&repo, &commit)?; + + let name_status = run_git_with_paths( + &repo, + &["diff", "--name-status", "-M", "-z", commit_hash.as_str()], + std::slice::from_ref(&file), + )?; + let numstat = run_git_with_paths( + &repo, + &["diff", "--numstat", "-M", "-z", commit_hash.as_str()], + std::slice::from_ref(&file), + )?; + let patch_output = run_git_with_paths( + &repo, + &["diff", "-M", commit_hash.as_str()], + std::slice::from_ref(&file), + )?; + + let files = parse_diff_files(&name_status, &numstat)?; + let patch = String::from_utf8_lossy(&patch_output).to_string(); + + Ok(GitCommitComparison { + from_short: short_hash(&commit_hash), + to_short: "working tree".to_string(), + from_hash: commit_hash, + to_hash: String::new(), + files, + patch, + }) +} + +fn short_hash(hash: &str) -> String { + hash.chars().take(7).collect() +} + fn resolve_repo(path: &str) -> Result { if path.trim().is_empty() { return Err("Repository-Pfad darf nicht leer sein.".to_string()); @@ -609,6 +718,72 @@ fn parse_commit_files(output: &[u8]) -> Result, String> { Ok(files) } +fn parse_diff_files(name_status: &[u8], numstat: &[u8]) -> Result, String> { + let status_files = parse_commit_files(name_status)?; + let counts = parse_numstat_z(numstat); + + let files = status_files + .into_iter() + .map(|file| { + let (additions, deletions) = counts + .iter() + .find(|(path, _, _)| *path == file.path) + .map(|(_, additions, deletions)| (*additions, *deletions)) + .unwrap_or((0, 0)); + + GitDiffFile { + path: file.path, + old_path: file.old_path, + status: file.status, + additions, + deletions, + } + }) + .collect(); + + Ok(files) +} + +fn parse_numstat_z(output: &[u8]) -> Vec<(String, u32, u32)> { + let tokens: Vec = output + .split(|byte| *byte == 0) + .filter(|entry| !entry.is_empty()) + .map(|entry| String::from_utf8_lossy(entry).to_string()) + .collect(); + + let mut result = Vec::new(); + let mut index = 0; + + while index < tokens.len() { + let mut parts = tokens[index].splitn(3, '\t'); + let additions = parse_numstat_count(parts.next().unwrap_or("")); + let deletions = parse_numstat_count(parts.next().unwrap_or("")); + let rest = parts.next().unwrap_or("").to_string(); + index += 1; + + // For renames/copies, `--numstat -z` leaves the path empty and emits the + // old and new paths as two separate NUL-terminated tokens. + let path = if rest.is_empty() { + if index + 1 >= tokens.len() { + break; + } + let new_path = tokens[index + 1].clone(); + index += 2; + new_path + } else { + rest + }; + + result.push((path, additions, deletions)); + } + + result +} + +fn parse_numstat_count(value: &str) -> u32 { + value.trim().parse().unwrap_or(0) +} + fn map_name_status(status: &str) -> FileStatusKind { match status.chars().next() { Some('M') => FileStatusKind::Modified, @@ -1231,6 +1406,105 @@ mod tests { ); } + #[test] + fn parse_diff_files_merges_name_status_with_numstat_counts() { + let name_status = b"M\0src/main.rs\0A\0README.md\0R100\0old.txt\0new.txt\0"; + let numstat = b"4\t2\tsrc/main.rs\010\t0\tREADME.md\00\t0\t\0old.txt\0new.txt\0"; + + let files = parse_diff_files(name_status, numstat).unwrap(); + + assert_eq!( + files, + vec![ + GitDiffFile { + path: "src/main.rs".to_string(), + old_path: None, + status: FileStatusKind::Modified, + additions: 4, + deletions: 2, + }, + GitDiffFile { + path: "README.md".to_string(), + old_path: None, + status: FileStatusKind::Added, + additions: 10, + deletions: 0, + }, + GitDiffFile { + path: "new.txt".to_string(), + old_path: Some("old.txt".to_string()), + status: FileStatusKind::Renamed, + additions: 0, + deletions: 0, + }, + ] + ); + } + + #[test] + fn parse_numstat_treats_binary_dashes_as_zero() { + let counts = parse_numstat_z(b"-\t-\tlogo.png\0"); + + assert_eq!(counts, vec![("logo.png".to_string(), 0, 0)]); + } + + #[test] + fn compare_commits_reports_changes_between_two_commits() { + let repo = init_temp_repo("compare_commits"); + commit_initial_file(&repo.path); + let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + + fs::write(repo.path.join("old.txt"), "original\nsecond line\n") + .expect("tracked file should change"); + fs::write(repo.path.join("added.txt"), "brand new\n").expect("added file should be written"); + run_git_test(&repo.path, ["add", "old.txt", "added.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "second"]); + let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + + let comparison = compare_commits( + repo.path.to_string_lossy().to_string(), + first_commit, + second_commit, + ) + .unwrap(); + + assert!(comparison + .files + .iter() + .any(|file| file.path == "old.txt" && file.status == FileStatusKind::Modified)); + assert!(comparison + .files + .iter() + .any(|file| file.path == "added.txt" && file.status == FileStatusKind::Added)); + assert!(comparison.patch.contains("second line")); + } + + #[test] + fn diff_file_against_working_tree_reports_uncommitted_changes() { + let repo = init_temp_repo("diff_against_working_tree"); + commit_initial_file(&repo.path); + let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + + // Change the file on disk without committing. + fs::write(repo.path.join("old.txt"), "working tree change\n") + .expect("working tree file should change"); + + let comparison = diff_file_against_working_tree( + repo.path.to_string_lossy().to_string(), + first_commit, + "old.txt".to_string(), + ) + .unwrap(); + + assert_eq!(comparison.to_short, "working tree"); + assert!(comparison.to_hash.is_empty()); + assert!(comparison + .files + .iter() + .any(|file| file.path == "old.txt" && file.status == FileStatusKind::Modified)); + assert!(comparison.patch.contains("working tree change")); + } + #[test] fn restore_staged_added_file_removes_it_from_index_and_worktree() { let repo = init_temp_repo("restore_staged_added_file"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2df1077..a83f5da 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,9 +3,10 @@ mod git; use git::{ - checkout_branch, commit, get_status, list_branches, list_commits, list_file_history, - list_repository_files, merge_branch, open_repository, pull, push, restore_file_from_commit, - restore_files, restore_to_commit, stage_files, unstage_files, + checkout_branch, commit, compare_commits, diff_file_against_working_tree, get_status, + list_branches, list_commits, list_file_history, list_repository_files, merge_branch, + open_repository, pull, push, restore_file_from_commit, restore_files, restore_to_commit, + stage_files, unstage_files, }; fn main() { @@ -26,7 +27,9 @@ fn main() { restore_file_from_commit, merge_branch, list_repository_files, - list_file_history + list_file_history, + compare_commits, + diff_file_against_working_tree ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.svelte b/src/App.svelte index 6ebb3b4..632babf 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,6 +1,7 @@