Add selective line restoration, file-history annotation, and sidebar section menu #52

Merged
Christoph merged 5 commits from UI-UX into main 2026-09-18 18:48:06 +00:00
7 changed files with 186 additions and 8 deletions
Showing only changes of commit 96f7c9f2df - Show all commits
+78
View File
@@ -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<String, String> {
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::<Vec<_>>().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");
+2 -1
View File
@@ -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,
+54
View File
@@ -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 @@
<module.default
file={linePatchFile}
staged={linePatchStaged}
restoreCommit={linePatchRestoreCommit}
patch={linePatchText}
{isBusy}
isLoading={linePatchLoading}
@@ -6947,6 +7000,7 @@
restoreLabel={pendingRestoreFile ? (appLanguage === "de" ? "Datei wiederherstellen" : "Restore file") : ""}
onClose={closeCompareDialog}
onRestore={restorePreviewedCommitFile}
onRestoreLines={openHistoricalLineRestore}
onSelectFile={selectDiffFile}
/>
{/await}
+7
View File
@@ -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 @@
</div>
</div>
<div class="dialog-header-actions">
{#if onRestoreLines && !comparison.to_hash && comparison.files.some(file => file.path === selectedDiffPath && file.status === "modified" && !file.old_path)}
<button class="btn-secondary compare-restore" type="button" onclick={onRestoreLines} disabled={isBusy}>
<RotateCcw size={15} aria-hidden="true" /><span>{isGerman ? "Zeilen wiederherstellen …" : "Restore lines…"}</span>
</button>
{/if}
{#if restoreLabel && onRestore}
<button class="btn-secondary compare-restore" type="button" onclick={onRestore} disabled={isBusy} title={restoreLabel}>
<RotateCcw size={15} aria-hidden="true" />
+40 -6
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { ArrowDown, ArrowUp, Check, ExternalLink, FileDiff, LoaderCircle, Minus, Plus, RefreshCw, Trash2, X } from "@lucide/svelte";
import { ArrowDown, ArrowUp, Check, ExternalLink, FileDiff, LoaderCircle, Minus, Plus, RefreshCw, RotateCcw, Trash2, X } from "@lucide/svelte";
import type { GitFileStatus, PatchApplyAction } from "../types";
type PatchLineKind = "context" | "add" | "delete" | "meta";
@@ -36,6 +36,7 @@
error: string;
language?: "en" | "de";
diffName?: string;
restoreCommit?: string;
onClose: () => void;
onRefresh: () => void | Promise<void>;
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
@@ -51,6 +52,7 @@
error = "",
language = "en",
diffName = "diff tool",
restoreCommit = "",
onClose = () => {},
onRefresh = () => {},
onApply = () => {},
@@ -66,7 +68,7 @@
let lastSelectedLineId = $state("");
const t = (de: string, en: string) => isGerman ? de : en;
let scopeLabel = $derived(staged ? t("Gestagte Änderungen", "Staged changes") : t("Nicht gestagte Änderungen", "Unstaged changes"));
let scopeLabel = $derived(restoreCommit ? t(`Wiederherstellen aus ${restoreCommit.slice(0, 8)}`, `Restore from ${restoreCommit.slice(0, 8)}`) : staged ? t("Gestagte Änderungen", "Staged changes") : t("Nicht gestagte Änderungen", "Unstaged changes"));
let activeHunk = $state(0);
let hasTextPatch = $derived(!isLoading && !error && !!patch.trim() && !parsed.binary && parsed.hunks.length > 0);
function clearSelection() { selectedLineIds = new Set(); lastSelectedLineId = ""; }
@@ -226,7 +228,34 @@
const output: string[] = [];
let previousIncluded = false;
for (const line of hunk.lines) {
if (restoreCommit) {
// Pair replacement lines so restoring just one pair keeps its original position.
const metadata = new Map<string, string>();
hunk.lines.forEach((line, index) => {
if (hunk.lines[index + 1]?.kind === "meta") metadata.set(line.id, hunk.lines[index + 1].text);
});
const emit = (line: PatchLine, prefix: string) => {
output.push(prefix + line.text.slice(1));
const marker = metadata.get(line.id);
if (marker) output.push(marker);
};
for (let index = 0; index < hunk.lines.length;) {
const line = hunk.lines[index];
if (line.kind === "context") { emit(line, " "); index++; continue; }
if (line.kind === "meta") { index++; continue; }
const removed: PatchLine[] = [], added: PatchLine[] = [];
while (index < hunk.lines.length && hunk.lines[index].kind !== "context") {
const changed = hunk.lines[index++];
if (changed.kind === "delete") removed.push(changed);
if (changed.kind === "add") added.push(changed);
}
for (let offset = 0; offset < Math.max(removed.length, added.length); offset++) {
const before = removed[offset], after = added[offset];
if (before) emit(before, selectedLineIds.has(before.id) ? "-" : " ");
if (after && selectedLineIds.has(after.id)) emit(after, "+");
}
}
} else for (const line of hunk.lines) {
if (line.kind === "context") {
output.push(line.text);
previousIncluded = true;
@@ -296,16 +325,17 @@
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label={t("Geänderte Zeilen", "Changed lines")}>
<div class="dialog line-patch-dialog" class:restoring={!!restoreCommit} role="dialog" aria-modal="true" aria-label={t("Geänderte Zeilen", "Changed lines")}>
<header class="dialog-header unified-dialog-header">
<div class="patch-identity unified-dialog-heading"><span class="unified-dialog-icon" aria-hidden="true"><FileDiff size={23} aria-hidden="true" /></span><div class="unified-dialog-text"><p class="dialog-title" title={displayPath}>{displayPath}</p><span class="patch-scope">{scopeLabel}</span></div></div>
<div class="dialog-header-actions">
<button class="external-diff" type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={t(`In ${diffName} öffnen`, `Open in ${diffName}`)}><span>{t("Extern öffnen", "Open externally")}</span><ExternalLink size={14} /><span>·</span><span>{diffName}</span></button>
{#if !restoreCommit}<button class="external-diff" type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={t(`In ${diffName} öffnen`, `Open in ${diffName}`)}><span>{t("Extern öffnen", "Open externally")}</span><ExternalLink size={14} /><span>·</span><span>{diffName}</span></button>{/if}
<button class="icon-action" type="button" onclick={onRefresh} disabled={isBusy || isLoading} aria-label={t("Aktualisieren", "Refresh")} title={t("Aktualisieren", "Refresh")}><RefreshCw size={16} /></button>
<button data-dialog-close class="icon-action" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={16} /></button>
</div>
</header>
{#if restoreCommit}<p class="restore-help">{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.")}</p>{/if}
<div class="line-patch-body">
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} />{t("Änderungen werden geladen …", "Loading changes …")}</div>
@@ -330,8 +360,10 @@
</button>
<strong>{t("Abschnitt", "Hunk")} {index + 1}</strong><code title={hunk.header}>{hunk.header}</code>
<div class="line-patch-hunk-actions">
{#if restoreCommit}<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("restore-lines", hunk)} disabled={isBusy}><RotateCcw size={14} />{t("Abschnitt wiederherstellen", "Restore hunk")}</button>{:else}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction(staged ? "discard-staged" : "discard-unstaged", hunk)} disabled={isBusy}><Trash2 size={14} />{t("Verwerfen", "Discard")}</button>
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applyHunkAction(staged ? "unstage" : "stage", hunk)} disabled={isBusy}>{#if staged}<Minus size={14} />{:else}<Plus size={14} />{/if}{staged ? t("Abschnitt unstagen", "Unstage hunk") : t("Abschnitt stagen", "Stage hunk")}</button>
{/if}
</div>
</div>
<div class="line-patch-lines">
@@ -358,14 +390,16 @@
{#if hasTextPatch}
<footer class="patch-footer">
<div class="selection-summary"><span class="selection-symbol" class:has-selection={selectedCount > 0}><Check size={13} /></span><strong aria-live="polite">{selectedCount} {t(selectedCount === 1 ? "Zeile ausgewählt" : "Zeilen ausgewählt", selectedCount === 1 ? "line selected" : "lines selected")}</strong><button class="clear-selection" type="button" onclick={clearSelection} disabled={isBusy || selectedCount === 0}>{t("Auswahl aufheben", "Clear selection")}</button></div>
<div class="selection-actions"><button class="discard-selection" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy || selectedCount === 0}>{t("Auswahl verwerfen", "Discard selected")}</button><button class="stage-selection" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy || selectedCount === 0}>{selectedCount} {t(selectedCount === 1 ? "Zeile" : "Zeilen", selectedCount === 1 ? "line" : "lines")} {staged ? t("unstagen", "to unstage") : t("stagen", "to stage")}</button></div>
<div class="selection-actions">{#if restoreCommit}<button class="stage-selection" type="button" onclick={() => applySelected("restore-lines")} disabled={isBusy || selectedCount === 0}><RotateCcw size={14} />{t("Auswahl wiederherstellen", "Restore selected")}</button>{:else}<button class="discard-selection" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy || selectedCount === 0}>{t("Auswahl verwerfen", "Discard selected")}</button><button class="stage-selection" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy || selectedCount === 0}>{selectedCount} {t(selectedCount === 1 ? "Zeile" : "Zeilen", selectedCount === 1 ? "line" : "lines")} {staged ? t("unstagen", "to unstage") : t("stagen", "to stage")}</button>{/if}</div>
</footer>
{/if}
</div>
</div>
<style>
.restore-help{margin:0;padding:10px 20px;border-bottom:1px solid var(--color-border);color:var(--color-ink-muted);font-size:12px;line-height:1.5;flex-shrink:0}
.line-patch-dialog{width:min(1700px,100%);height:min(960px,100%);grid-template-rows:auto minmax(0,1fr) auto;font-size:13px}
.line-patch-dialog.restoring{grid-template-rows:auto auto minmax(0,1fr) auto}
.dialog-header{padding:12px 18px}.patch-identity{display:flex;align-items:center;gap:12px;min-width:0}.patch-identity>div{min-width:0}.patch-identity :global(svg){flex:none;color:var(--color-ink-muted)}.patch-identity .dialog-title{font-size:15px;line-height:1.4;margin:0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.patch-scope{display:block;color:var(--color-ink-muted);font-size:12px;margin-top:2px}
.dialog-header-actions{gap:8px}.dialog-header-actions .external-diff{display:flex;align-items:center;gap:8px;border:0;background:transparent;font-size:12px;color:var(--color-ink-muted);padding:5px 10px}.line-patch-dialog .icon-action{display:inline-flex;align-items:center;justify-content:center;flex:none;width:30px;min-width:30px;height:30px;min-height:30px;padding:0;border:1px solid var(--color-border);background:transparent;color:var(--color-ink-muted)}
.patch-toolbar{display:flex;align-items:center;gap:20px;padding:8px 18px;min-height:46px;border-bottom:1px solid var(--color-border-subtle);background:var(--app-dialog-bg)}.patch-toolbar strong{font-size:12px;font-weight:600}.range-hint{color:var(--color-ink-faint);font-size:12px}.patch-summary{display:flex;align-items:center;gap:10px;margin-left:auto;color:var(--color-ink-muted);font-size:12px;white-space:nowrap}.patch-summary .add-count{color:var(--code-add-text)}.patch-summary .delete-count{color:var(--code-delete-text);margin-right:10px}
+4
View File
@@ -753,3 +753,7 @@ export function submoduleAction(path: string, modulePath: string, action: "updat
export function checkoutSubmoduleRevision(path: string, modulePath: string, revision: string, kind: "tag" | "commit"): Promise<void> {
return invoke("checkout_submodule_revision", { path, modulePath, revision, kind });
}
export function getFileRestorePatch(path: string, commit: string, file: string): Promise<string> {
return invoke("get_file_restore_patch", { path, commit, file });
}
+1 -1
View File
@@ -214,7 +214,7 @@ export interface GitFileStatus {
unstaged: FileStatusKind | null;
}
export type PatchApplyAction = "stage" | "unstage" | "discard-unstaged" | "discard-staged";
export type PatchApplyAction = "restore-lines" | "stage" | "unstage" | "discard-unstaged" | "discard-staged";
export interface GitBranch {
name: string;