From 25700821d19d76f5791742d1628712ac9c05f8da Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sat, 27 Jun 2026 17:42:58 +0200 Subject: [PATCH] neu --- src-tauri/src/git.rs | 154 +++++++++- src-tauri/src/main.rs | 7 +- src/App.svelte | 685 ++++++++++++++++++++++++++++++++++-------- src/app.css | 261 +++++++++++++++- src/lib/git.ts | 8 + src/lib/types.ts | 4 + 6 files changed, 976 insertions(+), 143 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index aeced7c..84b13cb 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -53,6 +53,7 @@ pub struct GitCommit { pub author_email: String, pub date: String, pub refs: Vec, + pub parents: Vec, pub files: Vec, } @@ -89,6 +90,9 @@ pub struct ConflictFile { pub ours: Option, pub theirs: Option, pub base: Option, + pub binary: bool, + pub ours_size: Option, + pub theirs_size: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -317,7 +321,7 @@ pub fn list_commits(path: String, limit: Option) -> Result, [ "log", "--decorate=short", - "--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%s%x1e", + "--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1e", "-n", limit.as_str(), ], @@ -349,7 +353,7 @@ pub fn list_file_history( let mut args = vec![ OsString::from("log"), OsString::from("--decorate=short"), - OsString::from("--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%s%x1e"), + OsString::from("--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1e"), OsString::from("-n"), OsString::from(limit), ]; @@ -480,18 +484,79 @@ pub fn read_conflict(path: String, file: String) -> Result let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; - let content = std::fs::read_to_string(repo.join(&file)) + let bytes = std::fs::read(repo.join(&file)) .map_err(|err| format!("Konfliktdatei konnte nicht gelesen werden: {err}"))?; + let binary = is_binary_bytes(&bytes); + + // For binary files we cannot offer a text merge, so we only report the side + // sizes; the UI lets the user pick which side to keep wholesale. + if binary { + return Ok(ConflictFile { + path: file.clone(), + content: String::new(), + ours: None, + theirs: None, + base: None, + binary: true, + ours_size: index_stage_size(&repo, 2, &file), + theirs_size: index_stage_size(&repo, 3, &file), + }); + } Ok(ConflictFile { base: read_index_stage(&repo, 1, &file), ours: read_index_stage(&repo, 2, &file), theirs: read_index_stage(&repo, 3, &file), + binary: false, + ours_size: index_stage_size(&repo, 2, &file), + theirs_size: index_stage_size(&repo, 3, &file), path: file, - content, + content: String::from_utf8_lossy(&bytes).to_string(), }) } +#[tauri::command] +pub fn resolve_conflict_side( + path: String, + file: String, + side: String, +) -> Result { + let repo = resolve_repo(&path)?; + validate_files(std::slice::from_ref(&file))?; + + let flag = match side.as_str() { + "ours" => "--ours", + "theirs" => "--theirs", + _ => return Err("Ungueltige Seite. Erlaubt sind 'ours' oder 'theirs'.".to_string()), + }; + + run_git_with_paths(&repo, &["checkout", flag], std::slice::from_ref(&file))?; + run_git_with_paths(&repo, &["add"], std::slice::from_ref(&file))?; + status_for_repo(&repo) +} + +fn is_binary_bytes(bytes: &[u8]) -> bool { + // Git's own heuristic: a NUL byte within the first 8000 bytes marks the + // blob as binary. + bytes.iter().take(8000).any(|byte| *byte == 0) +} + +fn index_stage_size(repo: &Path, stage: u8, file: &str) -> Option { + let spec = format!(":{stage}:{file}"); + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["cat-file", "-s", spec.as_str()]) + .output() + .ok()?; + + if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().parse().ok() + } else { + None + } +} + #[tauri::command] pub fn resolve_conflict(path: String, file: String, content: String) -> Result { let repo = resolve_repo(&path)?; @@ -673,8 +738,8 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result, String continue; } - let fields: Vec<&str> = record.splitn(7, FIELD_SEPARATOR).collect(); - if fields.len() != 7 { + let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect(); + if fields.len() != 8 { return Err(format!("Unerwarteter Git-Log-Eintrag: {record}")); } @@ -685,6 +750,11 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result, String .map(ToString::to_string) .collect(); + let parents = fields[6] + .split_whitespace() + .map(ToString::to_string) + .collect(); + let hash = fields[0].to_string(); let files = commit_files(repo, &hash)?; @@ -695,7 +765,8 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result, String author_email: fields[3].to_string(), date: fields[4].to_string(), refs, - summary: fields[6].to_string(), + parents, + summary: fields[7].to_string(), files, }); } @@ -716,8 +787,8 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result, String> { continue; } - let fields: Vec<&str> = record.splitn(7, FIELD_SEPARATOR).collect(); - if fields.len() != 7 { + let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect(); + if fields.len() != 8 { return Err(format!("Unerwarteter Git-Log-Eintrag: {record}")); } @@ -728,6 +799,11 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result, String> { .map(ToString::to_string) .collect(); + let parents = fields[6] + .split_whitespace() + .map(ToString::to_string) + .collect(); + commits.push(GitCommit { hash: fields[0].to_string(), short_hash: fields[1].to_string(), @@ -735,7 +811,8 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result, String> { author_email: fields[3].to_string(), date: fields[4].to_string(), refs, - summary: fields[6].to_string(), + parents, + summary: fields[7].to_string(), files: Vec::new(), }); } @@ -1451,7 +1528,7 @@ mod tests { #[test] fn parses_commit_history_records() { - let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fAda Lovelace\x1fada@example.com\x1f2026-06-26T12:34:56+02:00\x1fHEAD -> main, tag: v1\x1fAdd history panel\x1e"; + let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fAda Lovelace\x1fada@example.com\x1f2026-06-26T12:34:56+02:00\x1fHEAD -> main, tag: v1\x1f2222222222222222222222222222222222222222 3333333333333333333333333333333333333333\x1fAdd history panel\x1e"; let commits = parse_commit_log_metadata(raw).unwrap(); assert_eq!( @@ -1463,6 +1540,10 @@ mod tests { author_email: "ada@example.com".to_string(), date: "2026-06-26T12:34:56+02:00".to_string(), refs: vec!["HEAD -> main".to_string(), "tag: v1".to_string()], + parents: vec![ + "2222222222222222222222222222222222222222".to_string(), + "3333333333333333333333333333333333333333".to_string(), + ], summary: "Add history panel".to_string(), files: Vec::new(), }] @@ -1646,6 +1727,57 @@ mod tests { assert_eq!(contents.replace("\r\n", "\n"), "resolved\n"); } + #[test] + fn detects_binary_content_by_nul_byte() { + assert!(is_binary_bytes(&[0u8, 1, 2, 3])); + assert!(!is_binary_bytes(b"plain text content\n")); + } + + #[test] + fn binary_conflict_can_be_resolved_by_side() { + let repo = init_temp_repo("binary_conflict"); + fs::write(repo.path.join("img.bin"), [0u8, 1, 2, 3]).expect("base binary should be written"); + run_git_test(&repo.path, ["add", "img.bin"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "base"]); + let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); + + run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]); + fs::write(repo.path.join("img.bin"), [0u8, 9, 9, 9, 9, 9]).expect("feature binary"); + run_git_test(&repo.path, ["commit", "-q", "-am", "feature bin"]); + + run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]); + fs::write(repo.path.join("img.bin"), [0u8, 7, 7]).expect("main binary"); + run_git_test(&repo.path, ["commit", "-q", "-am", "main bin"]); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo.path) + .args(["merge", "--no-edit", "feature"]) + .output() + .expect("git merge should start"); + + let conflict = + read_conflict(repo.path.to_string_lossy().to_string(), "img.bin".to_string()).unwrap(); + assert!(conflict.binary); + assert!(conflict.content.is_empty()); + assert_eq!(conflict.ours_size, Some(3)); + assert_eq!(conflict.theirs_size, Some(6)); + + let status = resolve_conflict_side( + repo.path.to_string_lossy().to_string(), + "img.bin".to_string(), + "ours".to_string(), + ) + .unwrap(); + + assert!(!status.files.iter().any(|file| { + matches!(file.staged, Some(FileStatusKind::Conflicted)) + || matches!(file.unstaged, Some(FileStatusKind::Conflicted)) + })); + let bytes = fs::read(repo.path.join("img.bin")).unwrap(); + assert_eq!(bytes, vec![0u8, 7, 7]); + } + #[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 96638f0..879c1cf 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,8 +5,8 @@ mod git; use git::{ 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, read_conflict, resolve_conflict, restore_file_from_commit, - restore_files, restore_to_commit, stage_files, unstage_files, + open_repository, pull, push, read_conflict, resolve_conflict, resolve_conflict_side, + restore_file_from_commit, restore_files, restore_to_commit, stage_files, unstage_files, }; fn main() { @@ -31,7 +31,8 @@ fn main() { compare_commits, diff_file_against_working_tree, read_conflict, - resolve_conflict + resolve_conflict, + resolve_conflict_side ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.svelte b/src/App.svelte index 5efa024..b7e4dc4 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,4 +1,6 @@