Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 49 additions & 10 deletions src/lib/gitActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,28 @@ function discarded(ok: boolean): boolean {
return ok;
}

/**
* Bind an action to the repository the user actually confirmed it against.
*
* Every confirmation here is awaited with no timeout, and the work that follows reads
* `appState.repo` when it runs — not when the dialog opened. Switch repositories mid-prompt and
* the confirmed action lands on the new one. The discard guards do not save us: `expected_diff`
* pins the DIFF, not the repository, so two clones or worktrees holding the same path with the
* same diff pass that check and the wrong copy is irreversibly discarded.
*
* Capture before the await, verify after it, refuse if it moved. Refusing rather than retargeting
* is deliberate: the user authorised an action against what they were looking at, and silently
* applying it somewhere else is not the same action.
*/
function sameRepoAfterPrompt(): () => boolean {
const opened = appState.repo;
return () => {
if (appState.repo === opened) return true;
appState.status = "Repository changed while that dialog was open — nothing was done.";
return false;
};
}

// Run a working-copy op (non-destructive): guard → run → refresh working changes + graph.
async function runWorktree(label: string, fn: () => Promise<unknown>): Promise<boolean> {
if (!isTauri()) {
Expand Down Expand Up @@ -588,13 +610,14 @@ async function runDestructive(
appState.status = "Open a repository first.";
return false;
}
const sameRepo = sameRepoAfterPrompt();
const { confirmed, backup } = await dialogs.confirmDestructive({
title: label,
consequence,
confirmLabel: label,
backupDefault: appState.autoBackupDestructive,
});
if (!confirmed) return false;
if (!confirmed || !sameRepo()) return false;
try {
appState.status = `${label}…`;
const res = await fn(backup);
Expand Down Expand Up @@ -630,13 +653,14 @@ async function runDestructiveRebase(
appState.status = "Open a repository first.";
return false;
}
const sameRepo = sameRepoAfterPrompt();
const { confirmed, backup } = await dialogs.confirmDestructive({
title: label,
consequence,
confirmLabel: label,
backupDefault: appState.autoBackupDestructive,
});
if (!confirmed) return false;
if (!confirmed || !sameRepo()) return false;
try {
appState.status = `${label}…`;
const res = await fn(backup);
Expand Down Expand Up @@ -681,15 +705,21 @@ async function runRemote(
appState.status = "Open a repository first.";
return false;
}
// Capture before the FIRST attempt, not before the credentials prompt: a switch during
// that first await would otherwise be recorded as the "original" repo and the guard would
// wave the retry through.
const sameRepo = sameRepoAfterPrompt();
appState.startRemoteProgress(label);
try {
// First attempt: no credentials (system helper / SSH agent / keychain).
let outcome = await fn((l) => appState.pushRemoteLog(l));

if (outcome.authFailed) {
// Auth failed — prompt for credentials and retry once.
// Auth failed — prompt for credentials and retry once. `fn` reads `appState.repo` when
// invoked, so a switch anywhere in this flow would retry against the NEW repository with
// the credentials just entered — pushing to a remote the user never chose.
const creds = await dialogs.confirmCredentials({ title: `${label}: sign in` });
if (creds) {
if (creds && sameRepo()) {
outcome = await fn((l) => appState.pushRemoteLog(l), creds);
}
// If user cancelled the credentials dialog, fall through with the original outcome.
Expand Down Expand Up @@ -922,13 +952,14 @@ export const gitActions = {
// spanning the entire file, so a hunk discard can revert every unstaged change in
// it — a boundary the user cannot see from the dialog.
const n = changedLines;
const sameRepo = sameRepoAfterPrompt();
const confirmed = await dialogs.confirm({
title: "Discard hunk",
message: discardMessage(n, "changed line", path, revertsToIndex),
confirmLabel: "Discard",
danger: true,
});
if (!confirmed) return false;
if (!confirmed || !sameRepo()) return false;
return discarded(
await runWorktree(`Discard hunk in ${path}`, () =>
api.discardHunk(appState.repo, path, hunkIndex, expectedDiff, appState.effectiveDiffContext),
Expand All @@ -951,13 +982,14 @@ export const gitActions = {
return false;
}
const n = selected.length;
const sameRepo = sameRepoAfterPrompt();
const confirmed = await dialogs.confirm({
title: "Discard lines",
message: discardMessage(n, "line", path, revertsToIndex),
confirmLabel: "Discard",
danger: true,
});
if (!confirmed) return false;
if (!confirmed || !sameRepo()) return false;
return discarded(
await runWorktree(`Discard ${n} line(s) in ${path}`, () =>
api.discardLines(
Expand Down Expand Up @@ -1040,13 +1072,14 @@ export const gitActions = {
return false;
}
const n = paths.length;
const sameRepo = sameRepoAfterPrompt();
const confirmed = await dialogs.confirm({
title: "Discard changes",
message: `Permanently discard changes to ${n} file${n === 1 ? "" : "s"}. This cannot be undone. (Stash instead to keep them.)`,
confirmLabel: "Discard",
danger: true,
});
if (!confirmed) return false;
if (!confirmed || !sameRepo()) return false;
return runWorktree(`Discard ${n} file(s)`, () => api.discard(appState.repo, paths));
},

Expand All @@ -1060,13 +1093,14 @@ export const gitActions = {
return false;
}
const n = paths.length;
const sameRepo = sameRepoAfterPrompt();
const confirmed = await dialogs.confirm({
title: "Remove untracked files",
message: `Permanently discard changes to ${n} file${n === 1 ? "" : "s"}. This cannot be undone. (Stash instead to keep them.)`,
confirmLabel: "Discard",
danger: true,
});
if (!confirmed) return false;
if (!confirmed || !sameRepo()) return false;
return runWorktree(`Clean ${n} file(s)`, () => api.clean(appState.repo, paths));
},

Expand Down Expand Up @@ -1134,14 +1168,15 @@ export const gitActions = {
return false;
}
if (forceWithLease) {
const sameRepo = sameRepoAfterPrompt();
const ok = await dialogs.confirm({
title: "Force push",
message:
"Force-push with lease? This overwrites the remote branch if it matches your last fetch. Any commits others pushed since your last fetch will be lost.",
confirmLabel: "Force push",
danger: true,
});
if (!ok) return false;
if (!ok || !sameRepo()) return false;
}

// Determine the current branch + whether it already has an upstream tracking ref.
Expand Down Expand Up @@ -1220,13 +1255,17 @@ export const gitActions = {
(n): n is string => !!n && others.includes(n),
) ?? null;
const bases = preferred ? [preferred, ...others.filter((n) => n !== preferred)] : others;
// Capture before the first await: `branch` and `bases` were read from the CURRENT repo
// above, and a switch during the subjects lookup would otherwise be recorded as the
// original — creating a PR in the new repo from the old one's selections.
const sameRepo = sameRepoAfterPrompt();
// Best-effort prefill from the commit subjects on base..HEAD.
const subjects = await api
.branchSubjects(appState.repo, bases[0], 20)
.catch(() => [] as string[]);
const prefill = prefillFromSubjects(subjects, branch);
const v = await dialogs.openCreatePr(branch, bases, prefill.title, prefill.body);
if (!v) return;
if (!v || !sameRepo()) return;
await run("Create pull request", async () => {
const number = await api.githubPrCreate(appState.repo, v.title, v.body, v.base, v.draft);
// The auto-push may have just created the branch's upstream.
Expand Down