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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user