add more features

This commit is contained in:
Christoph Brandau
2026-06-27 13:49:56 +02:00
parent 3b9d636fdb
commit 6a78e768bc
7 changed files with 989 additions and 18 deletions
+274
View File
@@ -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<String>,
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<GitDiffFile>,
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<GitCommitComparison, String> {
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<GitCommitComparison, String> {
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<PathBuf, String> {
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<Vec<GitCommitFile>, String> {
Ok(files)
}
fn parse_diff_files(name_status: &[u8], numstat: &[u8]) -> Result<Vec<GitDiffFile>, 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<String> = 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");