Skip to content

Commit aada638

Browse files
author
Test
committed
feat(worktrees): add Prune Worktrees command
A worktree whose branch's upstream is gone — the remote branch was deleted, typically once its PR merged — is dead weight: the branch can never be updated again, yet both the directory and the local branch linger until someone cleans them up by hand, one at a time. Prune Worktrees collects every such worktree in one pass (the ref list is fetched once per repository, not once per worktree), shows a single confirmation that spells out the uncommitted file names in each one, then removes the worktree and deletes its branch with a non-force `git branch -d`. A branch git holds back as not fully merged is never force-deleted silently: the worktree still goes, the branch is listed, and dropping its commits takes a second explicit confirmation. Closes #198
1 parent 761ec09 commit aada638

8 files changed

Lines changed: 626 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ All commands are grouped under the `GSC:` prefix in the Command Palette.
4040
| Copy staged or WIP changes between existing worktrees | `GSC: Copy Staged Changes to Worktree...`, `GSC: Copy WIP Changes to Worktree...`, `GSC: Copy WIP from Worktree...`, `GSC: Move WIP from Worktree...` | [Copy changes to worktree](docs/copy-changes-to-worktree.md) |
4141
| Open a terminal in a selected worktree's directory | `GSC: Open Worktree Dev Terminal...` | [Open worktree dev terminal](docs/open-worktree-dev-terminal.md) |
4242
| Remove several Git worktrees at once with a single confirmation | `GSC: Remove Multiple Worktrees...` | [Remove multiple worktrees](docs/remove-multiple-worktrees.md) |
43+
| Clean up every worktree whose branch is dead — its upstream is gone because the remote branch was deleted (typically once the PR merged) — removing the worktree and deleting the local branch, after one confirmation that spells out any uncommitted files. Unmerged branches are never force-deleted without a second, explicit confirmation | `GSC: Prune Worktrees...` *(also a toolbar button in the "Worktrees" view)* ||
4344
| Browse all worktrees for the open repositories in a dedicated "Worktrees" view (activity bar), with dirty/ahead-behind/PR-review status, last commit + relative age, an upstream-gone flag with a dedicated inline Remove action, an auto-stash marker, live refresh on external git activity, and a badge showing the count of dirty worktrees, plus inline Open/Terminal/Copy WIP/Remove actions | *(tree view — right-click an item for Add to Workspace, Copy Path, Reveal in Finder/Explorer)* ||
4445
| Detect the GitHub-native [stacked PR](https://docs.github.com/en/rest/pulls/stacks) chain the current branch belongs to, and browse it in a dedicated "Stacks" view (activity bar) showing each PR's title and branch plus the branch the stack is ultimately targeting. Click a row (or its target chip) to check it out using the currently selected checkout/stash strategy; right-click for "Open PR in Browser". Nothing is cached — an in-view refresh icon re-checks live at any time. A status bar indicator (`$(layers) <position>/<size>`) appears only while the current branch is part of a stack; click it to reveal the Stacks view. | `GSC: Refresh Stacks` | [Stacks](docs/stacks.md) |
4546
| Create a new PR from selected commits in another GitHub PR | `GSC: Clone Pull Request...` | [GitHub PR clone](docs/github-pr-clone.md) |

package.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,12 @@
247247
"icon": "$(trash)",
248248
"category": "GSC"
249249
},
250+
{
251+
"command": "git-smart-checkout.pruneWorktrees",
252+
"title": "Prune Worktrees...",
253+
"icon": "$(clear-all)",
254+
"category": "GSC"
255+
},
250256
{
251257
"command": "git-smart-checkout.openWorktreeDevTerminal",
252258
"title": "Open Worktree Dev Terminal...",
@@ -438,10 +444,15 @@
438444
"group": "navigation@2"
439445
},
440446
{
441-
"command": "git-smart-checkout.worktree.refresh",
447+
"command": "git-smart-checkout.pruneWorktrees",
442448
"when": "view == git-smart-checkout.worktrees",
443449
"group": "navigation@3"
444450
},
451+
{
452+
"command": "git-smart-checkout.worktree.refresh",
453+
"when": "view == git-smart-checkout.worktrees",
454+
"group": "navigation@4"
455+
},
445456
{
446457
"command": "git-smart-checkout.stacks.refresh",
447458
"when": "view == git-smart-checkout.stacks",
@@ -544,6 +555,9 @@
544555
"command": "git-smart-checkout.removeMultipleWorktrees",
545556
"when": "git-smart-checkout.hasMultipleRemovableWorktrees"
546557
},
558+
{
559+
"command": "git-smart-checkout.pruneWorktrees"
560+
},
547561
{
548562
"command": "git-smart-checkout.openWorktreeDevTerminal"
549563
},

src/analytics/analytics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export enum AnalyticsEvent {
2424
PrReviewWorktreeRemoved = 'pr_review_worktree_removed',
2525
WorktreeRemoved = 'worktree_removed',
2626
MultipleWorktreesRemoved = 'multiple_worktrees_removed',
27+
WorktreesPruned = 'worktrees_pruned',
2728
CopyStagedChangesToWorktree = 'copy_staged_changes_to_worktree',
2829
CopyWipChangesToWorktree = 'copy_wip_changes_to_worktree',
2930
CopyWipChangesFromWorktree = 'copy_wip_changes_from_worktree',
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import * as vscode from 'vscode';
2+
3+
import { AnalyticsEvent, capture, captureException } from '../../analytics/analytics';
4+
import { GitExecutor } from '../../common/git/gitExecutor';
5+
import { VscodeGitProvider } from '../../common/git/vscodeGitProvider';
6+
import { LoggingService } from '../../logging/loggingService';
7+
import {
8+
collectPrunableWorktrees,
9+
formatPruneConfirmationDetail,
10+
PrunableWorktree,
11+
} from '../../services/worktreePruneService';
12+
import { refreshRemoveMultipleWorktreesVisibility } from '../utils/worktreeCommandVisibility';
13+
import {
14+
getWorktreeLabel,
15+
getWorktreeStashName,
16+
removeWorkspaceFoldersForPath,
17+
} from '../utils/worktreeRemoval';
18+
import { BaseCommand } from '../command';
19+
20+
const ACTION_PRUNE = 'Prune Worktrees';
21+
const ACTION_STASH_AND_PRUNE = 'Stash Changes and Prune';
22+
const ACTION_RESET_AND_PRUNE = 'Reset Changes and Prune';
23+
const ACTION_CANCEL = 'Cancel';
24+
const ACTION_FORCE_DELETE_BRANCHES = 'Force Delete Branches';
25+
const ACTION_KEEP_BRANCHES = 'Keep Branches';
26+
27+
type DirtyAction = 'clean' | 'stash' | 'reset';
28+
type FailedPrune = { candidate: PrunableWorktree; error: string };
29+
30+
/** `git branch -d` refuses a branch that still holds commits the upstream never got. */
31+
function isNotFullyMergedError(error: unknown): boolean {
32+
const stderr = typeof (error as { stderr?: unknown })?.stderr === 'string'
33+
? (error as { stderr: string }).stderr
34+
: '';
35+
const message = error instanceof Error ? error.message : String(error);
36+
return /not fully merged/i.test(`${message}\n${stderr}`);
37+
}
38+
39+
/**
40+
* Removes every worktree whose branch's upstream is gone (the remote branch was
41+
* deleted, typically once its PR merged) and deletes the now-dead local branch
42+
* along with it.
43+
*/
44+
export class PruneWorktreesCommand extends BaseCommand {
45+
constructor(
46+
logService: LoggingService,
47+
private vscodeGitProvider?: VscodeGitProvider
48+
) {
49+
super(logService);
50+
}
51+
52+
async execute(): Promise<void> {
53+
try {
54+
const git = await this.getGitExecutor(this.vscodeGitProvider, 'Prune Worktrees');
55+
const candidates = await vscode.window.withProgress(
56+
{
57+
location: vscode.ProgressLocation.Notification,
58+
title: 'Git Smart Checkout: Looking for worktrees to prune...',
59+
cancellable: false,
60+
},
61+
async () => collectPrunableWorktrees(git, this.logService, this.vscodeGitProvider)
62+
);
63+
64+
if (candidates.length === 0) {
65+
await vscode.window.showInformationMessage(
66+
'No worktrees to prune. A worktree is prunable once its branch’s upstream is gone — ' +
67+
'that is, the remote branch has been deleted.',
68+
'OK'
69+
);
70+
return;
71+
}
72+
73+
const dirtyCount = candidates.filter(({ dirtyFiles }) => dirtyFiles.length > 0).length;
74+
const dirtyAction = await this.confirmPrune(candidates, dirtyCount);
75+
if (!dirtyAction) {
76+
return;
77+
}
78+
79+
const { pruned, unmerged, failed } = await vscode.window.withProgress(
80+
{
81+
location: vscode.ProgressLocation.Notification,
82+
title: 'Git Smart Checkout: Prune Worktrees',
83+
cancellable: false,
84+
},
85+
async (progress) => this.pruneWorktrees(git, candidates, dirtyAction, progress)
86+
);
87+
88+
for (const { worktree } of pruned) {
89+
await removeWorkspaceFoldersForPath(worktree.path);
90+
}
91+
92+
const forceDeleted = await this.resolveUnmergedBranches(git, unmerged);
93+
94+
capture(AnalyticsEvent.WorktreesPruned, {
95+
count: pruned.length,
96+
had_dirty: dirtyCount > 0,
97+
dirty_action: dirtyAction,
98+
force_deleted_branches: forceDeleted.length,
99+
});
100+
101+
await refreshRemoveMultipleWorktreesVisibility(this.logService, this.vscodeGitProvider);
102+
103+
await this.reportResult(pruned, unmerged, forceDeleted, failed);
104+
} catch (error) {
105+
captureException(error);
106+
const message = error instanceof Error ? error.message : String(error);
107+
message && (await vscode.window.showErrorMessage(message, 'OK'));
108+
}
109+
}
110+
111+
private async confirmPrune(
112+
candidates: PrunableWorktree[],
113+
dirtyCount: number
114+
): Promise<DirtyAction | undefined> {
115+
const detail = formatPruneConfirmationDetail(candidates);
116+
const countLabel = `${candidates.length} worktree${candidates.length === 1 ? '' : 's'}`;
117+
118+
if (dirtyCount === 0) {
119+
const choice = await vscode.window.showWarningMessage(
120+
`Prune ${countLabel} whose upstream branch is gone? Each worktree is removed and its local branch deleted.`,
121+
{ modal: true, detail },
122+
ACTION_PRUNE,
123+
ACTION_CANCEL
124+
);
125+
126+
return choice === ACTION_PRUNE ? 'clean' : undefined;
127+
}
128+
129+
const choice = await vscode.window.showWarningMessage(
130+
`${countLabel} to prune, ${dirtyCount} with uncommitted changes. ` +
131+
'What would you like to do with the changes before pruning?',
132+
{ modal: true, detail },
133+
ACTION_STASH_AND_PRUNE,
134+
ACTION_RESET_AND_PRUNE,
135+
ACTION_CANCEL
136+
);
137+
138+
if (choice === ACTION_STASH_AND_PRUNE) {
139+
return 'stash';
140+
}
141+
142+
if (choice === ACTION_RESET_AND_PRUNE) {
143+
return 'reset';
144+
}
145+
146+
return undefined;
147+
}
148+
149+
/**
150+
* Removes each worktree then deletes its branch with a non-force `git branch -d`.
151+
* A branch git refuses as not-fully-merged is collected rather than forced — the
152+
* caller asks before any commits are dropped. One worktree failing does not stop
153+
* the rest.
154+
*/
155+
private async pruneWorktrees(
156+
git: GitExecutor,
157+
candidates: PrunableWorktree[],
158+
dirtyAction: DirtyAction,
159+
progress: vscode.Progress<{ message?: string }>
160+
): Promise<{ pruned: PrunableWorktree[]; unmerged: PrunableWorktree[]; failed: FailedPrune[] }> {
161+
const pruned: PrunableWorktree[] = [];
162+
const unmerged: PrunableWorktree[] = [];
163+
const failed: FailedPrune[] = [];
164+
165+
for (const [index, candidate] of candidates.entries()) {
166+
const { worktree, branch, dirtyFiles } = candidate;
167+
progress.report({
168+
message: `Pruning ${getWorktreeLabel(worktree)} (${index + 1}/${candidates.length})...`,
169+
});
170+
171+
try {
172+
if (dirtyFiles.length > 0 && dirtyAction !== 'clean') {
173+
const worktreeGit = new GitExecutor(worktree.path, this.logService, this.vscodeGitProvider);
174+
if (dirtyAction === 'stash') {
175+
await worktreeGit.createStash(getWorktreeStashName(worktree));
176+
} else {
177+
await worktreeGit.discardAllWorktreeChanges();
178+
}
179+
}
180+
181+
await git.worktreeRemove(worktree.path, false);
182+
pruned.push(candidate);
183+
} catch (error) {
184+
failed.push({ candidate, error: error instanceof Error ? error.message : String(error) });
185+
continue;
186+
}
187+
188+
try {
189+
await git.deleteBranch(branch, false);
190+
} catch (error) {
191+
if (isNotFullyMergedError(error)) {
192+
unmerged.push(candidate);
193+
} else {
194+
failed.push({ candidate, error: error instanceof Error ? error.message : String(error) });
195+
}
196+
}
197+
}
198+
199+
return { pruned, unmerged, failed };
200+
}
201+
202+
/** Offers a single force-delete for the branches `git branch -d` held back. */
203+
private async resolveUnmergedBranches(
204+
git: GitExecutor,
205+
unmerged: PrunableWorktree[]
206+
): Promise<string[]> {
207+
if (unmerged.length === 0) {
208+
return [];
209+
}
210+
211+
const detail = unmerged.map(({ branch }) => `• ${branch}`).join('\n');
212+
const choice = await vscode.window.showWarningMessage(
213+
`${unmerged.length} branch${unmerged.length === 1 ? ' was' : 'es were'} not fully merged, ` +
214+
'so the worktree was removed but the branch kept. Delete them anyway? Their unmerged commits will be lost.',
215+
{ modal: true, detail },
216+
ACTION_FORCE_DELETE_BRANCHES,
217+
ACTION_KEEP_BRANCHES
218+
);
219+
220+
if (choice !== ACTION_FORCE_DELETE_BRANCHES) {
221+
return [];
222+
}
223+
224+
const deleted: string[] = [];
225+
for (const { branch } of unmerged) {
226+
try {
227+
await git.deleteBranch(branch, true);
228+
deleted.push(branch);
229+
} catch (error) {
230+
this.logService.warn(
231+
`[Prune Worktrees] Failed to force-delete branch ${branch}: ${
232+
error instanceof Error ? error.message : String(error)
233+
}`
234+
);
235+
}
236+
}
237+
238+
return deleted;
239+
}
240+
241+
private async reportResult(
242+
pruned: PrunableWorktree[],
243+
unmerged: PrunableWorktree[],
244+
forceDeleted: string[],
245+
failed: FailedPrune[]
246+
): Promise<void> {
247+
if (pruned.length > 0) {
248+
const keptBranches = unmerged
249+
.map(({ branch }) => branch)
250+
.filter((branch) => !forceDeleted.includes(branch));
251+
const deletedCount = pruned.length - keptBranches.length;
252+
const lines = [
253+
`Pruned ${pruned.length} worktree${pruned.length === 1 ? '' : 's'}, ` +
254+
`deleted ${deletedCount} branch${deletedCount === 1 ? '' : 'es'}.`,
255+
];
256+
if (keptBranches.length > 0) {
257+
lines.push(`Kept unmerged branch${keptBranches.length === 1 ? '' : 'es'}: ${keptBranches.join(', ')}.`);
258+
}
259+
await vscode.window.showInformationMessage(lines.join(' '), 'OK');
260+
}
261+
262+
if (failed.length > 0) {
263+
const detail = failed
264+
.map(({ candidate, error }) => `${getWorktreeLabel(candidate.worktree)}: ${error}`)
265+
.join('\n');
266+
await vscode.window.showErrorMessage(
267+
`Failed to prune ${failed.length} worktree${failed.length === 1 ? '' : 's'}:\n${detail}`,
268+
'OK'
269+
);
270+
}
271+
}
272+
}

src/common/git/gitExecutor.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,32 @@ export function parseStashNameStatusOutput(
141141
return entries;
142142
}
143143

144+
/**
145+
* Parses `git status --porcelain` (v1) output into the changed/untracked paths it reports.
146+
* Each line is `XY <path>`; rename and copy entries are `XY <old> -> <new>`, and we report the
147+
* destination. Paths containing special characters arrive C-quoted (`"src/\303\251.ts"`) — the
148+
* surrounding quotes and the escapes git adds for `"` and `\` are undone so the path reads
149+
* naturally in UI.
150+
*/
151+
export function parseStatusPorcelainPaths(output: string): string[] {
152+
return output
153+
.split('\n')
154+
.filter((line) => line.trim().length > 0)
155+
.map((line) => {
156+
const pathPart = line.slice(3).trimEnd();
157+
const arrowIndex = pathPart.lastIndexOf(' -> ');
158+
return unquoteStatusPath(arrowIndex === -1 ? pathPart : pathPart.slice(arrowIndex + 4));
159+
})
160+
.filter((path) => path.length > 0);
161+
}
162+
163+
function unquoteStatusPath(path: string): string {
164+
if (!path.startsWith('"') || !path.endsWith('"') || path.length < 2) {
165+
return path;
166+
}
167+
return path.slice(1, -1).replace(/\\(["\\])/g, '$1');
168+
}
169+
144170
export function parseWorktreeListPorcelain(output: string): IGitWorktree[] {
145171
const worktrees: IGitWorktree[] = [];
146172
let current: IGitWorktree | undefined;
@@ -752,11 +778,15 @@ export class GitExecutor {
752778
return dirtyFileCount !== 0;
753779
}
754780

781+
/** Paths of the changed/untracked files reported by `git status --porcelain`. */
782+
async listDirtyFiles(): Promise<string[]> {
783+
const { stdout } = await this.#execGitCommand(['status', '--porcelain']);
784+
return parseStatusPorcelainPaths(stdout);
785+
}
786+
755787
/** Number of changed/untracked files reported by `git status --porcelain`. */
756788
async getDirtyFileCount(): Promise<number> {
757-
const { stdout } = await this.#execGitCommand(['status', '--porcelain']);
758-
const trimmed = stdout.trim();
759-
return trimmed.length === 0 ? 0 : trimmed.split('\n').length;
789+
return (await this.listDirtyFiles()).length;
760790
}
761791

762792
/** Subject and committer-timestamp (unix seconds) of `ref`'s tip commit. */

0 commit comments

Comments
 (0)