Skip to content
Merged
Show file tree
Hide file tree
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
34 changes: 34 additions & 0 deletions src/main/git/undo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,40 @@ describe('readUndoSnapshot validity & snapshot integration', () => {
expect((await readUndoSnapshot(dir, tip, 'origin/main'))?.kind).toBe('commit')
})

test('treats a pushed commit as pushed when its configured upstream ref was deleted', async () => {
// A stale branch still tracking a remote branch that no longer exists (the
// remote deleted it, or renamed its default). git keeps reporting the
// upstream (here 'origin/gone'), but the ref doesn't resolve — the tip must
// still count as pushed via any *other* remote-tracking branch that has it.
const dir = seedRepo()
put(dir, 'foo.txt', 'hi')
const tip = rawCommit(dir, 'add foo')
// The commit lives on a real remote branch, but NOT on the (missing) upstream.
git(dir, 'update-ref', 'refs/remotes/origin/main', tip)

// Configured upstream 'origin/gone' has no ref: don't mistake the ancestor
// probe's failure for "unpushed" — fall back to remote-containment.
expect(await readUndoSnapshot(dir, tip, 'origin/gone')).toBeNull()
// And the mutation refuses too (no record → derived path), rather than
// rewriting published history.
await expect(undo(dir)).rejects.toThrow(/nothing to undo/i)
expect(head(dir)).toBe(tip)
})

test('the mutation refuses to undo a recorded op whose tip is already pushed', async () => {
const dir = seedRepo()
put(dir, 'foo.txt', 'hi')
await commitSelection(dir, 'add foo', COMMIT_ALL) // records this commit
const tip = head(dir)
// Configure an upstream that already contains the tip → pushed.
git(dir, 'update-ref', 'refs/remotes/origin/main', tip)
git(dir, 'config', 'branch.main.remote', 'origin')
git(dir, 'config', 'branch.main.merge', 'refs/heads/main')

await expect(undo(dir)).rejects.toThrow(/already pushed/i)
expect(head(dir)).toBe(tip) // nothing rewritten
})

test('a reset stays undoable even when its new tip is already pushed', async () => {
const dir = seedRepo()
put(dir, 'b.txt', 'b')
Expand Down
31 changes: 30 additions & 1 deletion src/main/git/undo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,33 @@ async function currentUpstream(repoPath: string): Promise<string | null> {
}
}

/** Whether `ref` resolves to a commit that actually exists in this repo. */
async function refExists(repoPath: string, ref: string): Promise<boolean> {
return runRead(repoPath, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).then(
(out) => out.trim() !== '',
() => false
)
}

/**
* Whether `sha` is unpublished — not yet on the remote. With an upstream, that's
* "not an ancestor of the upstream" (ahead of it); without one, "not contained
* in any remote-tracking branch". Mirrors GitHub Desktop's local-commits set,
* which is exactly what gates whether an undo is offered.
*
* A configured upstream can be gone from the remote — e.g. a stale local branch
* still tracking a remote branch that was deleted (or renamed, like `master` →
* `main`). git keeps reporting that upstream, but its ref no longer resolves, so
* the ancestor probe would error and we'd read the failure as "unpushed" and
* wrongly offer to undo an already-published commit. When the upstream ref is
* missing, fall back to the no-upstream test (contained in *any* remote branch).
*/
async function isUnpushed(
repoPath: string,
sha: string,
upstream: string | null
): Promise<boolean> {
if (upstream) {
if (upstream && (await refExists(repoPath, upstream))) {
return runRead(repoPath, ['merge-base', '--is-ancestor', sha, upstream]).then(
() => false,
() => true
Expand Down Expand Up @@ -266,6 +281,20 @@ export async function undo(repoPath: string): Promise<UndoResult> {
const record = await readUndoRecord(repoPath)

if (record && record.postSha === head) {
// Never rewrite published history. A recorded op is undoable only while its
// tip is unpushed — the same gate readUndoSnapshot uses to decide whether
// to even offer the affordance. This guards the mutation itself, so a stale
// renderer snapshot or the "Undo Last Action" menu command can't slip a
// pushed tip past the check. A reset is exempt: undoing it moves HEAD
// *forward* to restore commits, so it can never rewrite remote history.
if (
record.kind !== 'reset' &&
!(await isUnpushed(repoPath, head, await currentUpstream(repoPath)))
) {
throw new Error(
'This commit is already pushed, so undoing it would rewrite published history.'
)
}
if (record.kind === 'commit' && record.preSha === null) {
await undoFirstCommit(repoPath)
} else if (record.kind === 'commit') {
Expand Down
Loading