diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 6354bba..affa2cb 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -2530,6 +2530,41 @@ pub async fn commit_ai_review( parse_ai_review(&raw) } +/// Forward patch from the working file to a historical version, for selective restoration. +#[tauri::command(async)] +pub fn get_file_restore_patch(path: String, commit: String, file: String) -> Result { + let repo = resolve_repo(&path)?; + validate_files(std::slice::from_ref(&file))?; + let commit = verify_commit(&repo, &commit)?; + if !fs::symlink_metadata(repo.join(&file)).is_ok_and(|metadata| metadata.is_file()) { + return Err("Line restoration requires an existing regular file.".into()); + } + let entry = run_git(&repo, ["ls-tree", "-z", &commit, "--", &file])?; + if !entry.starts_with(b"100644 ") && !entry.starts_with(b"100755 ") { + return Err("This revision does not contain a regular file at this path.".into()); + } + let output = run_git(&repo, ["diff", "-R", "--no-renames", "--no-ext-diff", "--no-textconv", "--unified=3", &commit, "--", &file])?; + let patch = String::from_utf8_lossy(&output).lines() + .filter(|line| !line.starts_with("old mode ") && !line.starts_with("new mode ")) + .collect::>().join("\n"); + Ok(if patch.is_empty() { patch } else { format!("{patch}\n") }) +} + +fn validate_restore_patch(repo: &Path, file: &str, patch: &str, patch_path: &Path) -> Result<(), String> { + if !fs::symlink_metadata(repo.join(file)).is_ok_and(|metadata| metadata.is_file()) { + return Err("Line restoration requires an existing regular file.".into()); + } + if patch.lines().any(|line| ["old mode ", "new mode ", "new file mode ", "deleted file mode ", "rename from ", "rename to ", "copy from ", "copy to ", "GIT binary patch", "Binary files "].iter().any(|prefix| line.starts_with(prefix))) { + return Err("Only text-line changes can be restored here.".into()); + } + let stats = run_git(repo, [OsStr::new("apply"), OsStr::new("--numstat"), OsStr::new("-z"), patch_path.as_os_str()])?; + let entries: Vec<_> = stats.split(|byte| *byte == 0).filter(|entry| !entry.is_empty()).collect(); + if entries.len() != 1 || entries[0].splitn(3, |byte| *byte == b'\t').nth(2) != Some(file.as_bytes()) { + return Err("The selected patch must only modify the selected file.".into()); + } + Ok(()) +} + #[tauri::command(async)] pub fn apply_file_patch( path: String, @@ -2545,6 +2580,9 @@ pub fn apply_file_patch( let patch_path = write_temp_patch(&patch)?; let result = match action.as_str() { + "restore-lines" => validate_restore_patch(&repo, &file, &patch, &patch_path) + .and_then(|_| check_apply_patch(&repo, &patch_path, &[])) + .and_then(|_| run_apply_patch(&repo, &patch_path, &[])), "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"]) @@ -10007,6 +10045,46 @@ mod tests { ); } + #[test] + fn restore_lines_preserves_unselected_changes_and_index() { + let repo = init_temp_repo("restore_selected_lines"); + fs::write(repo.path.join("file.txt"), "old\nkeep old\nbase\n").unwrap(); + run_git_test(&repo.path, ["add", "file.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "historical"]); + let commit = verify_commit(&repo.path, "HEAD").unwrap(); + fs::write(repo.path.join("file.txt"), "current\nkeep current\nstaged\n").unwrap(); + run_git_test(&repo.path, ["add", "file.txt"]); + let index_before = run_git(&repo.path, ["show", ":file.txt"]).unwrap(); + let full_patch = get_file_restore_patch(repo.path.to_string_lossy().into_owned(), commit.clone(), "file.txt".into()).unwrap(); + assert!(full_patch.contains("-current\n")); + assert!(full_patch.contains("+old\n")); + let selected = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n-current\n+old\n keep current\n staged\n"; + apply_file_patch(repo.path.to_string_lossy().into_owned(), "file.txt".into(), selected.into(), "restore-lines".into()).unwrap(); + assert_eq!(fs::read_to_string(repo.path.join("file.txt")).unwrap(), "old\nkeep current\nstaged\n"); + assert_eq!(run_git(&repo.path, ["show", ":file.txt"]).unwrap(), index_before); + let remaining = get_file_restore_patch(repo.path.to_string_lossy().into_owned(), commit, "file.txt".into()).unwrap(); + apply_file_patch(repo.path.to_string_lossy().into_owned(), "file.txt".into(), remaining, "restore-lines".into()).unwrap(); + assert_eq!(fs::read_to_string(repo.path.join("file.txt")).unwrap(), "old\nkeep old\nbase\n"); + assert_eq!(run_git(&repo.path, ["show", ":file.txt"]).unwrap(), index_before); + } + + #[test] + fn restore_lines_rejects_stale_or_wrong_file_patches() { + let repo = init_temp_repo("restore_lines_guard"); + fs::write(repo.path.join("file.txt"), "before\n").unwrap(); + run_git_test(&repo.path, ["add", "file.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "before"]); + let commit = verify_commit(&repo.path, "HEAD").unwrap(); + fs::write(repo.path.join("file.txt"), "after\n").unwrap(); + let patch = get_file_restore_patch(repo.path.to_string_lossy().into_owned(), commit, "file.txt".into()).unwrap(); + fs::write(repo.path.join("other.txt"), "after\n").unwrap(); + assert!(apply_file_patch(repo.path.to_string_lossy().into_owned(), "other.txt".into(), patch.clone(), "restore-lines".into()).is_err()); + fs::write(repo.path.join("file.txt"), "newer work\n").unwrap(); + assert!(apply_file_patch(repo.path.to_string_lossy().into_owned(), "file.txt".into(), patch, "restore-lines".into()).is_err()); + assert_eq!(fs::read_to_string(repo.path.join("file.txt")).unwrap(), "newer work\n"); + assert_eq!(fs::read_to_string(repo.path.join("other.txt")).unwrap(), "after\n"); + } + #[test] fn apply_file_patch_stages_and_discards_selected_changes() { let repo = init_temp_repo("apply_file_patch"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index cbf787c..46c2850 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -19,7 +19,7 @@ use git::{ compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch, delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_bisect_state, get_commit_note, - get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune, + get_file_blame, get_file_patch, get_file_restore_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune, git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository, last_commit_message, list_branches, list_commits, list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files, @@ -392,6 +392,7 @@ async fn main() { stash_drop, restore_files, get_file_patch, + get_file_restore_patch, apply_file_patch, commit, amend_commit, diff --git a/src/App.svelte b/src/App.svelte index d6e9af9..5c54df2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -71,6 +71,7 @@ cancelCodeSearch, cancelFileHistory, applyFilePatch, + getFileRestorePatch, createBranch, createTag, deleteBranch, @@ -495,6 +496,8 @@ let selectedDiffPath = ""; let diffHighlightQuery = ""; let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null; + let linePatchRestoreCommit = ""; + let linePatchRestoreRepo = ""; let linePatchOpen = false; let linePatchFile: GitFileStatus | null = null; let linePatchStaged = false; @@ -5019,6 +5022,8 @@ async function openLinePatch(file: GitFileStatus, staged: boolean) { if (!activeRepoPath) return; + linePatchRestoreCommit = ""; + linePatchRestoreRepo = ""; linePatchOpen = true; linePatchFile = file; linePatchStaged = staged; @@ -5039,13 +5044,59 @@ } } + async function openHistoricalLineRestore() { + if (!activeRepoPath || !comparison || comparison.to_hash || isBusy) return; + const file = comparison.files.find(item => item.path === selectedDiffPath); + if (!file || file.status !== "modified" || file.old_path) return; + linePatchRestoreCommit = comparison.from_hash; + linePatchRestoreRepo = activeRepoPath; + linePatchFile = { path: file.path, old_path: null, staged: null, unstaged: "modified" }; + linePatchStaged = false; + linePatchText = ""; + linePatchError = ""; + compareDialogOpen = false; + fileHistoryDialogOpen = false; + globalSearchOpen = false; + linePatchOpen = true; + await refreshLinePatch(); + } + + async function restoreSelectedLines(patch: string) { + if (!linePatchFile || !linePatchRestoreCommit || isBusy) return; + const file = linePatchFile.path; + const repo = linePatchRestoreRepo; + const commit = linePatchRestoreCommit; + if (repo !== activeRepoPath) { linePatchError = "The active repository changed. Reopen the comparison."; return; } + operation = appLanguage === "de" ? "Ausgewählte Zeilen wiederherstellen" : "Restoring selected lines"; + linePatchError = ""; + try { + applyStatus(await applyFilePatch(repo, file, patch, "restore-lines")); + linePatchText = await getFileRestorePatch(repo, commit, file); + comparison = await diffFileAgainstWorkingTree(repo, commit, file); + await refreshRepositoryViews(repo, { branches: false, commits: false }); + await refreshFileHistory(repo, file, true); + } catch (error) { linePatchError = errorToMessage(error); } + finally { operation = ""; } + } + async function refreshLinePatch() { if (!activeRepoPath || !linePatchFile) return; + if (linePatchRestoreCommit) { + linePatchLoading = true; + linePatchError = ""; + try { linePatchText = await getFileRestorePatch(linePatchRestoreRepo, linePatchRestoreCommit, linePatchFile.path); } + catch (error) { linePatchError = errorToMessage(error); } + finally { linePatchLoading = false; } + return; + } await openLinePatch(linePatchFile, linePatchStaged); } function closeLinePatch() { if (isBusy) return; + if (linePatchRestoreCommit) compareDialogOpen = !!comparison; + linePatchRestoreCommit = ""; + linePatchRestoreRepo = ""; linePatchOpen = false; linePatchFile = null; linePatchText = ""; @@ -5132,6 +5183,7 @@ } async function applyLinePatch(action: PatchApplyAction, patch: string, scope: "hunk" | "lines") { + if (action === "restore-lines") { await restoreSelectedLines(patch); return; } if (!activeRepoPath || !linePatchFile || isBusy) return; const file = linePatchFile; const staged = linePatchStaged; @@ -6681,6 +6733,7 @@ {/await} diff --git a/src/lib/components/CompareDialog.svelte b/src/lib/components/CompareDialog.svelte index fcae5f8..d3f2982 100644 --- a/src/lib/components/CompareDialog.svelte +++ b/src/lib/components/CompareDialog.svelte @@ -28,6 +28,7 @@ language?: "en" | "de"; onClose: () => void; onRestore?: () => void; + onRestoreLines?: () => void; onSelectFile: (file: GitDiffFile) => void; } @@ -42,6 +43,7 @@ language = "en", onClose = () => {}, onRestore = undefined, + onRestoreLines = undefined, onSelectFile = () => {}, }: Props = $props(); @@ -242,6 +244,11 @@
+ {#if onRestoreLines && !comparison.to_hash && comparison.files.some(file => file.path === selectedDiffPath && file.status === "modified" && !file.old_path)} + + {/if} {#if restoreLabel && onRestore} + {#if !restoreCommit}{/if}
+ {#if restoreCommit}

{t("Grün: aus der alten Version übernehmen. Rot: aus der aktuellen Datei entfernen. Für einen Zeilenaustausch beide Zeilen auswählen. Die Auswahl wird nicht gestagt.", "Green: take from the old version. Red: remove from the current file. Select both lines to replace a line. Changes remain unstaged.")}

{/if}
{#if isLoading}
{t("Änderungen werden geladen …", "Loading changes …")}
@@ -330,8 +360,10 @@ {t("Abschnitt", "Hunk")} {index + 1}{hunk.header}
+ {#if restoreCommit}{:else} + {/if}
@@ -358,14 +390,16 @@ {#if hasTextPatch}
0}>{selectedCount} {t(selectedCount === 1 ? "Zeile ausgewählt" : "Zeilen ausgewählt", selectedCount === 1 ? "line selected" : "lines selected")}
-
+
{#if restoreCommit}{:else}{/if}
{/if}