This commit is contained in:
Christoph Brandau
2026-06-27 17:42:58 +02:00
parent ef1974f31f
commit 25700821d1
6 changed files with 976 additions and 143 deletions
+143 -11
View File
@@ -53,6 +53,7 @@ pub struct GitCommit {
pub author_email: String,
pub date: String,
pub refs: Vec<String>,
pub parents: Vec<String>,
pub files: Vec<GitCommitFile>,
}
@@ -89,6 +90,9 @@ pub struct ConflictFile {
pub ours: Option<String>,
pub theirs: Option<String>,
pub base: Option<String>,
pub binary: bool,
pub ours_size: Option<u64>,
pub theirs_size: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -317,7 +321,7 @@ pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>,
[
"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<ConflictFile, String>
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<GitStatus, String> {
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<u64> {
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<GitStatus, String> {
let repo = resolve_repo(&path)?;
@@ -673,8 +738,8 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, 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<Vec<GitCommit>, 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<Vec<GitCommit>, 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<Vec<GitCommit>, 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<Vec<GitCommit>, 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<Vec<GitCommit>, 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");
+4 -3
View File
@@ -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");