diff --git a/src/main/git/worktree-removal-real-git.test.ts b/src/main/git/worktree-removal-real-git.test.ts index 93fb6d01ed3..9d5261ff036 100644 --- a/src/main/git/worktree-removal-real-git.test.ts +++ b/src/main/git/worktree-removal-real-git.test.ts @@ -90,6 +90,40 @@ describe('worktree removal against the real Git binary', () => { expect(await git(['branch', '--list', 'feature'], repoPath)).toBe('') }) + it('preserves a branch whose only unmerged commit is empty', async () => { + await git(['commit', '--allow-empty', '-qm', 'unique empty commit'], worktreePath) + const head = (await git(['rev-parse', 'HEAD'], worktreePath)).trim() + + const result = await removeWorktree(repoPath, worktreePath, false) + + expect(existsSync(worktreePath)).toBe(false) + expect(await isRegistered(worktreePath)).toBe(false) + expect(result).toEqual({ preservedBranch: { branchName: 'feature', head } }) + expect((await git(['rev-parse', 'refs/heads/feature'], repoPath)).trim()).toBe(head) + }) + + it('preserves unmerged commits that cancel out their file changes', async () => { + await writeFile(join(worktreePath, 'seed.txt'), 'temporary change\n') + await git(['commit', '-qam', 'change seed'], worktreePath) + await git(['revert', '--no-edit', 'HEAD'], worktreePath) + const head = (await git(['rev-parse', 'HEAD'], worktreePath)).trim() + + const result = await removeWorktree(repoPath, worktreePath, false) + + expect(result).toEqual({ preservedBranch: { branchName: 'feature', head } }) + expect((await git(['rev-parse', 'refs/heads/feature'], repoPath)).trim()).toBe(head) + }) + + it('still deletes a squash-merged branch with net file changes', async () => { + await writeFile(join(worktreePath, 'seed.txt'), 'feature change\n') + await git(['commit', '-qam', 'change seed'], worktreePath) + await git(['merge', '--squash', 'feature'], repoPath) + await git(['commit', '-qm', 'squash feature'], repoPath) + + expect(await removeWorktree(repoPath, worktreePath, false)).toEqual({}) + expect(await git(['branch', '--list', 'feature'], repoPath)).toBe('') + }) + it('refuses to delete a dirty checkout', async () => { await writeFile(join(worktreePath, 'seed.txt'), 'edited\n') diff --git a/src/shared/git-branch-cleanup.ts b/src/shared/git-branch-cleanup.ts index 5f4642a8ed9..4b7d75b208a 100644 --- a/src/shared/git-branch-cleanup.ts +++ b/src/shared/git-branch-cleanup.ts @@ -244,6 +244,20 @@ export async function branchHasNoUnmergedChangesOnAnyTarget( if (!mergeBase) { continue } + const branchChanges = await readOptionalGitRawStdout(runGit, [ + 'diff', + '--name-only', + mergeBase, + branchRef + ]) + if (!branchChanges) { + // An unchanged tree does not prove empty or reverted commits were merged. + try { + await runGit(['merge-base', '--is-ancestor', branchRef, targetOid]) + } catch { + continue + } + } if (await branchMergesWithoutTreeChanges(runGit, targetOid, branchRef, capabilities)) { return true }