Implement interactive hunk staging and discarding

Introduce a new dialog that allows users to view file changes as individual hunks and selectively stage, unstage, or discard them. This provides more granular control over modifications, similar to `git add -p`.

Enhance branch panel usability by allowing double-click to checkout a branch.
This commit is contained in:
Christoph Brandau
2026-07-01 14:47:36 +02:00
parent 0bbd5ce1e8
commit 5752243e6e
9 changed files with 636 additions and 11 deletions
+181
View File
@@ -385,6 +385,59 @@ pub fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<G
status_for_repo(&repo)
}
#[tauri::command]
pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String, String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
let base_args = if staged {
&[
"diff",
"--cached",
"--no-ext-diff",
"--no-textconv",
"--unified=3",
][..]
} else {
&["diff", "--no-ext-diff", "--no-textconv", "--unified=3"][..]
};
let output = run_git_with_paths(&repo, base_args, &[file])?;
Ok(String::from_utf8_lossy(&output).to_string())
}
#[tauri::command]
pub fn apply_file_patch(
path: String,
file: String,
patch: String,
action: String,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
if patch.trim().is_empty() {
return Err("Kein Patch ausgewaehlt.".to_string());
}
let patch_path = write_temp_patch(&patch)?;
let result = match action.as_str() {
"stage" => check_apply_patch(&repo, &patch_path, &["--cached"])
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached"])),
"unstage" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])),
"discard-unstaged" => check_apply_patch(&repo, &patch_path, &["--reverse"])
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])),
"discard-staged" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])
.and_then(|_| check_apply_patch(&repo, &patch_path, &["--reverse"]))
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"]))
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])),
_ => Err("Ungueltige Patch-Aktion.".to_string()),
};
let _ = std::fs::remove_file(&patch_path);
result?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
@@ -2184,6 +2237,45 @@ fn validate_files(files: &[String]) -> Result<(), String> {
Ok(())
}
fn write_temp_patch(patch: &str) -> Result<PathBuf, String> {
let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"gitlite_patch_{}_{}.patch",
std::process::id(),
counter
));
std::fs::write(&path, patch.as_bytes())
.map_err(|err| format!("Patch-Datei konnte nicht geschrieben werden: {err}"))?;
Ok(path)
}
fn check_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> {
run_apply_patch_command(repo, patch_path, options, true)
}
fn run_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> {
run_apply_patch_command(repo, patch_path, options, false)
}
fn run_apply_patch_command(
repo: &Path,
patch_path: &Path,
options: &[&str],
check_only: bool,
) -> Result<(), String> {
let mut args = Vec::with_capacity(options.len() + 5);
args.push(OsString::from("apply"));
if check_only {
args.push(OsString::from("--check"));
}
args.extend(options.iter().map(OsString::from));
args.push(OsString::from("--recount"));
args.push(OsString::from("--whitespace=nowarn"));
args.push(patch_path.as_os_str().to_os_string());
run_git(repo, args).map(|_| ())
}
#[cfg(unix)]
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
use std::os::unix::fs::PermissionsExt;
@@ -3388,6 +3480,95 @@ mod tests {
assert!(err.contains("existiert bereits"));
}
#[test]
fn apply_file_patch_stages_and_discards_selected_changes() {
let repo = init_temp_repo("apply_file_patch");
fs::write(repo.path.join("old.txt"), "one\ntwo\nthree\n")
.expect("initial file should be written");
run_git_test(&repo.path, ["add", "old.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "init"]);
fs::write(repo.path.join("old.txt"), "one\nTWO\nthree\nfour\n")
.expect("changed file should be written");
let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n";
let status = apply_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
selected_patch.to_string(),
"stage".to_string(),
)
.expect("selected line should stage");
assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified));
assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified));
assert_eq!(
git_output_test(&repo.path, ["show", ":old.txt"]),
"one\nTWO\nthree"
);
assert_eq!(
fs::read_to_string(repo.path.join("old.txt"))
.expect("working tree should be readable")
.replace("\r\n", "\n"),
"one\nTWO\nthree\nfour\n"
);
let unstaged_patch = get_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
false,
)
.expect("unstaged patch should load");
assert!(unstaged_patch.contains("+four"));
let status = apply_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
unstaged_patch,
"discard-unstaged".to_string(),
)
.expect("unstaged line should discard");
assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified));
assert_eq!(status.files[0].unstaged, None);
assert_eq!(
fs::read_to_string(repo.path.join("old.txt"))
.expect("working tree should be readable")
.replace("\r\n", "\n"),
"one\nTWO\nthree\n"
);
}
#[test]
fn get_file_patch_splits_distant_changes_like_interactive_diff() {
let repo = init_temp_repo("file_patch_hunks");
let original = (1..=30)
.map(|line| format!("line {line}\n"))
.collect::<String>();
fs::write(repo.path.join("old.txt"), original).expect("initial file should be written");
run_git_test(&repo.path, ["add", "old.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "init"]);
let changed = (1..=30)
.map(|line| match line {
5 => "line five changed\n".to_string(),
20 => "line twenty changed\n".to_string(),
_ => format!("line {line}\n"),
})
.collect::<String>();
fs::write(repo.path.join("old.txt"), changed).expect("changed file should be written");
let patch = get_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
false,
)
.expect("patch should load");
let hunk_count = patch.lines().filter(|line| line.starts_with("@@ ")).count();
assert_eq!(hunk_count, 2, "{patch}");
}
#[test]
fn restore_to_commit_resets_branch_to_selected_commit() {
let repo = init_temp_repo("restore_to_commit");