Skip to content

Commit 2b32124

Browse files
committed
Add stale worktree pruning to sidebar
- Add a sidebar entry to open worktree cleanup - Show merged PR links and merge age in the dialog - Add bulk prune for stale records and cover age formatting
1 parent ac5d32b commit 2b32124

4 files changed

Lines changed: 142 additions & 9 deletions

File tree

apps/web/src/components/Sidebar.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import { useTerminalStateStore } from "../terminalStateStore";
7676
import { useThreadSelectionStore } from "../threadSelectionStore";
7777
import type { Thread } from "../types";
7878
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
79+
import { useWorktreeCleanupStore } from "../worktreeCleanupStore";
7980
import {
8081
getArm64IntelBuildWarningDescription,
8182
getDesktopUpdateActionError,
@@ -490,6 +491,7 @@ export default function Sidebar() {
490491
});
491492
const queryClient = useQueryClient();
492493
const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
494+
const openWorktreeCleanupDialog = useWorktreeCleanupStore((s) => s.openDialog);
493495
const [addingProject, setAddingProject] = useState(false);
494496
const [newCwd, setNewCwd] = useState("");
495497
const [isPickingFolder, setIsPickingFolder] = useState(false);
@@ -2026,6 +2028,16 @@ export default function Sidebar() {
20262028
<span className="text-xs">Open Workspace</span>
20272029
</SidebarMenuButton>
20282030
</SidebarMenuItem>
2031+
<SidebarMenuItem>
2032+
<SidebarMenuButton
2033+
size="sm"
2034+
className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
2035+
onClick={() => openWorktreeCleanupDialog()}
2036+
>
2037+
<GitMergeIcon className="size-3.5" />
2038+
<span className="text-xs">Worktree cleanup</span>
2039+
</SidebarMenuButton>
2040+
</SidebarMenuItem>
20292041
<SidebarMenuItem>
20302042
<SidebarMenuButton
20312043
size="sm"

apps/web/src/components/WorktreeCleanupDialog.tsx

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
gitPruneWorktreesMutationOptions,
1111
gitRemoveWorktreeMutationOptions,
1212
} from "~/lib/gitReactQuery";
13-
import { formatWorktreePathForDisplay } from "~/worktreeCleanup";
13+
import { formatBranchAge, formatWorktreePathForDisplay } from "~/worktreeCleanup";
1414
import { useWorktreeCleanupStore } from "~/worktreeCleanupStore";
1515
import { toastManager } from "./ui/toast";
1616
import { Badge } from "./ui/badge";
@@ -64,10 +64,37 @@ export function WorktreeCleanupDialog() {
6464
const hasCandidates = candidates.length > 0;
6565
const isBusy = removeWorktreeMutation.isPending || pruneWorktreesMutation.isPending;
6666

67+
const staleCandidates = useMemo(
68+
() =>
69+
candidates.filter(
70+
(c) => !c.pathExists && resolveWorktreeUsageCount(c, threadWorktreePaths) === 0,
71+
),
72+
[candidates, threadWorktreePaths],
73+
);
74+
const hasStaleCandidates = staleCandidates.length > 0;
75+
6776
const handleClose = () => {
6877
closeDialog();
6978
};
7079

80+
const handlePruneAllStale = async () => {
81+
if (!cwd || !hasStaleCandidates) return;
82+
try {
83+
await pruneWorktreesMutation.mutateAsync({ cwd });
84+
toastManager.add({
85+
type: "success",
86+
title: "Stale records pruned",
87+
description: `Pruned ${staleCandidates.length} stale worktree record${staleCandidates.length === 1 ? "" : "s"}.`,
88+
});
89+
} catch (error) {
90+
toastManager.add({
91+
type: "error",
92+
title: "Could not prune stale records",
93+
description: error instanceof Error ? error.message : "Unknown error.",
94+
});
95+
}
96+
};
97+
7198
const handleRemoveCandidate = async (candidate: GitWorktreeCleanupCandidate) => {
7299
if (!cwd) return;
73100
const usageCount = resolveWorktreeUsageCount(candidate, threadWorktreePaths);
@@ -151,10 +178,21 @@ export function WorktreeCleanupDialog() {
151178
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
152179
<div className="min-w-0 space-y-2">
153180
<div className="flex flex-wrap items-center gap-2">
154-
<Badge variant="outline" size="sm">
155-
<GitMergeIcon className="size-3.5" />
156-
Merged PR #{candidate.prNumber}
157-
</Badge>
181+
<a
182+
href={candidate.prUrl}
183+
target="_blank"
184+
rel="noopener noreferrer"
185+
className="no-underline"
186+
>
187+
<Badge
188+
variant="outline"
189+
size="sm"
190+
className="cursor-pointer hover:bg-accent"
191+
>
192+
<GitMergeIcon className="size-3.5" />
193+
Merged PR #{candidate.prNumber}
194+
</Badge>
195+
</a>
158196
{candidate.pathExists ? (
159197
<Badge variant="success" size="sm">
160198
On disk
@@ -182,6 +220,11 @@ export function WorktreeCleanupDialog() {
182220
{" · "}
183221
Path{" "}
184222
<span className="font-mono text-foreground">{displayPath}</span>
223+
{" · "}
224+
Merged{" "}
225+
<span className="text-foreground">
226+
{formatBranchAge(candidate.mergedAt)}
227+
</span>
185228
</div>
186229
</div>
187230
</div>
@@ -215,9 +258,26 @@ export function WorktreeCleanupDialog() {
215258
<div className="text-xs text-muted-foreground">
216259
{candidates.length} candidate{candidates.length === 1 ? "" : "s"} found
217260
</div>
218-
<Button variant="outline" size="sm" onClick={handleClose}>
219-
Close
220-
</Button>
261+
<div className="flex items-center gap-2">
262+
{hasStaleCandidates ? (
263+
<Button
264+
variant="destructive-outline"
265+
size="sm"
266+
disabled={isBusy}
267+
onClick={() => void handlePruneAllStale()}
268+
>
269+
{pruneWorktreesMutation.isPending ? (
270+
<LoaderCircleIcon className="size-3.5 animate-spin" />
271+
) : (
272+
<Trash2Icon className="size-3.5" />
273+
)}
274+
Prune all stale ({staleCandidates.length})
275+
</Button>
276+
) : null}
277+
<Button variant="outline" size="sm" onClick={handleClose}>
278+
Close
279+
</Button>
280+
</div>
221281
</div>
222282
</DialogFooter>
223283
</DialogPopup>

apps/web/src/worktreeCleanup.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { ProjectId, ThreadId } from "@okcode/contracts";
22
import { describe, expect, it } from "vitest";
33

44
import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
5-
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";
5+
import {
6+
formatBranchAge,
7+
formatWorktreePathForDisplay,
8+
getOrphanedWorktreePathForThread,
9+
} from "./worktreeCleanup";
610

711
function makeThread(overrides: Partial<Thread> = {}): Thread {
812
return {
@@ -76,6 +80,38 @@ describe("getOrphanedWorktreePathForThread", () => {
7680
});
7781
});
7882

83+
describe("formatBranchAge", () => {
84+
const now = new Date("2026-04-06T12:00:00.000Z");
85+
86+
it("returns 'just now' for timestamps less than a minute ago", () => {
87+
expect(formatBranchAge("2026-04-06T11:59:30.000Z", now)).toBe("just now");
88+
});
89+
90+
it("returns minutes for recent merges", () => {
91+
expect(formatBranchAge("2026-04-06T11:45:00.000Z", now)).toBe("15m ago");
92+
});
93+
94+
it("returns hours for same-day merges", () => {
95+
expect(formatBranchAge("2026-04-06T05:00:00.000Z", now)).toBe("7h ago");
96+
});
97+
98+
it("returns days for merges within a week", () => {
99+
expect(formatBranchAge("2026-04-03T12:00:00.000Z", now)).toBe("3d ago");
100+
});
101+
102+
it("returns weeks for merges within a month", () => {
103+
expect(formatBranchAge("2026-03-16T12:00:00.000Z", now)).toBe("3w ago");
104+
});
105+
106+
it("returns months for older merges", () => {
107+
expect(formatBranchAge("2026-01-06T12:00:00.000Z", now)).toBe("3mo ago");
108+
});
109+
110+
it("returns 'just now' for future timestamps", () => {
111+
expect(formatBranchAge("2026-04-07T00:00:00.000Z", now)).toBe("just now");
112+
});
113+
});
114+
79115
describe("formatWorktreePathForDisplay", () => {
80116
it("shows only the last path segment for unix-like paths", () => {
81117
const result = formatWorktreePathForDisplay(

apps/web/src/worktreeCleanup.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,31 @@ export function getOrphanedWorktreePathForThread(
3232
return isShared ? null : targetWorktreePath;
3333
}
3434

35+
/**
36+
* Return a human-readable relative-time label for how long ago a branch was
37+
* merged. Expects an ISO-8601 `mergedAt` timestamp and an optional `now`
38+
* override (for deterministic testing).
39+
*/
40+
export function formatBranchAge(mergedAt: string, now: Date = new Date()): string {
41+
const mergedDate = new Date(mergedAt);
42+
const diffMs = now.getTime() - mergedDate.getTime();
43+
if (diffMs < 0) return "just now";
44+
45+
const seconds = Math.floor(diffMs / 1_000);
46+
const minutes = Math.floor(seconds / 60);
47+
const hours = Math.floor(minutes / 60);
48+
const days = Math.floor(hours / 24);
49+
const weeks = Math.floor(days / 7);
50+
const months = Math.floor(days / 30);
51+
52+
if (months >= 1) return `${months}mo ago`;
53+
if (weeks >= 1) return `${weeks}w ago`;
54+
if (days >= 1) return `${days}d ago`;
55+
if (hours >= 1) return `${hours}h ago`;
56+
if (minutes >= 1) return `${minutes}m ago`;
57+
return "just now";
58+
}
59+
3560
export function formatWorktreePathForDisplay(worktreePath: string): string {
3661
const trimmed = worktreePath.trim();
3762
if (!trimmed) {

0 commit comments

Comments
 (0)