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
11 changes: 11 additions & 0 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1963,6 +1963,17 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
),
),
);

// Keep Git's worktree bookkeeping tidy after removing a checkout.
yield* executeGit(
"GitCore.removeWorktree.prune",
input.cwd,
["worktree", "prune", "--expire", "now"],
{
allowNonZeroExit: true,
timeoutMs: 15_000,
},
).pipe(Effect.catch(() => Effect.void));
});

const renameBranch: GitCoreShape["renameBranch"] = (input) =>
Expand Down
135 changes: 135 additions & 0 deletions apps/server/src/git/worktreeCleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { realpathSync } from "node:fs";

export interface WorktreeCleanupCandidate {
readonly path: string;
readonly branch: string;
readonly prNumber: number;
readonly prTitle: string;
readonly prUrl: string;
readonly mergedAt: string;
readonly pathExists: boolean;
readonly prunable: boolean;
}

interface WorktreeListEntry {
path: string;
branch: string | null;
prunable: boolean;
}

interface MergedPullRequestLike {
readonly number: number;
readonly title: string;
readonly url: string;
readonly headBranch: string;
readonly mergedAt: string;
}

function canonicalizePath(value: string): string {
try {
return realpathSync.native(value);
} catch {
return value.replace(/\\/g, "/").replace(/\/+$/, "");
}
}

function parseWorktreeList(stdout: string): WorktreeListEntry[] {
const entries: WorktreeListEntry[] = [];
let current: WorktreeListEntry | null = null;

for (const rawLine of stdout.split("\n")) {
const line = rawLine.trimEnd();
if (line.length === 0) {
if (current) {
entries.push(current);
}
current = null;
continue;
}

if (line.startsWith("worktree ")) {
if (current) {
entries.push(current);
}
current = {
path: line.slice("worktree ".length).trim(),
branch: null,
prunable: false,
};
continue;
}

if (!current) {
continue;
}

if (line.startsWith("branch refs/heads/")) {
current.branch = line.slice("branch refs/heads/".length).trim() || null;
continue;
}

if (line.startsWith("prunable")) {
current.prunable = true;
}
}

if (current) {
entries.push(current);
}

return entries;
}

export function collectMergedWorktreeCleanupCandidates(input: {
cwd: string;
worktreeListStdout: string;
mergedPullRequests: ReadonlyArray<MergedPullRequestLike>;
}): WorktreeCleanupCandidate[] {
const mergedPullRequestsByHeadBranch = new Map(
input.mergedPullRequests.map((pr) => [pr.headBranch, pr] as const),
);
const rootPath = canonicalizePath(input.cwd);

return parseWorktreeList(input.worktreeListStdout)
.flatMap((entry) => {
const branch = entry.branch?.trim() ?? "";
if (!branch) {
return [];
}
const pullRequest = mergedPullRequestsByHeadBranch.get(branch);
if (!pullRequest) {
return [];
}

const candidatePath = entry.path.trim();
if (!candidatePath) {
return [];
}

if (canonicalizePath(candidatePath) === rootPath) {
return [];
}

return [
{
path: candidatePath,
branch,
prNumber: pullRequest.number,
prTitle: pullRequest.title,
prUrl: pullRequest.url,
mergedAt: pullRequest.mergedAt,
pathExists: !entry.prunable,
prunable: entry.prunable,
},
];
})
.toSorted((a, b) => {
if (a.pathExists !== b.pathExists) {
return a.pathExists ? -1 : 1;
}
if (a.mergedAt !== b.mergedAt) {
return b.mergedAt.localeCompare(a.mergedAt);
}
return a.branch.localeCompare(b.branch);
});
}
49 changes: 49 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { clamp } from "effect/Number";
import { Open, resolveAvailableEditors } from "./open";
import { ServerConfig } from "./config";
import { GitCore } from "./git/Services/GitCore.ts";
import { collectMergedWorktreeCleanupCandidates } from "./git/worktreeCleanup.ts";
import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
import {
ATTACHMENTS_ROUTE_PREFIX,
Expand Down Expand Up @@ -1183,6 +1184,40 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
.pipe(Effect.provideService(RuntimeEnv, gitEnv));
}

case WS_METHODS.gitListMergedWorktreeCleanupCandidates: {
const body = stripRequestTag(request.body);
const snapshot = yield* projectionReadModelQuery.getSnapshot();
const gitEnv = yield* resolveRuntimeEnvironment({ cwd: body.cwd, readModel: snapshot });
const mergedPullRequests = yield* gitManager
.listPullRequests({
cwd: body.cwd,
state: "merged",
limit: 500,
})
.pipe(Effect.provideService(RuntimeEnv, gitEnv));
const worktreeList = yield* git
.execute({
operation: "GitCore.listMergedWorktreeCleanupCandidates.worktreeList",
cwd: body.cwd,
args: ["worktree", "list", "--porcelain"],
timeoutMs: 5_000,
allowNonZeroExit: true,
})
.pipe(Effect.provideService(RuntimeEnv, gitEnv));

return collectMergedWorktreeCleanupCandidates({
cwd: body.cwd,
worktreeListStdout: worktreeList.stdout,
mergedPullRequests: mergedPullRequests.pullRequests.map((pr) => ({
number: pr.number,
title: pr.title,
url: pr.url,
headBranch: pr.headBranch,
mergedAt: pr.updatedAt,
})),
});
}

case WS_METHODS.prReviewGetConfig: {
const body = stripRequestTag(request.body);
yield* prReview
Expand Down Expand Up @@ -1341,6 +1376,20 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return yield* git.removeWorktree(body).pipe(Effect.provideService(RuntimeEnv, gitEnv));
}

case WS_METHODS.gitPruneWorktrees: {
const body = stripRequestTag(request.body);
const snapshot = yield* projectionReadModelQuery.getSnapshot();
const gitEnv = yield* resolveRuntimeEnvironment({ cwd: body.cwd, readModel: snapshot });
return yield* git
.execute({
operation: "GitCore.pruneWorktrees",
cwd: body.cwd,
args: ["worktree", "prune", "--expire", "now"],
timeoutMs: 15_000,
})
.pipe(Effect.provideService(RuntimeEnv, gitEnv), Effect.asVoid);
}

case WS_METHODS.gitCreateBranch: {
const body = stripRequestTag(request.body);
const snapshot = yield* projectionReadModelQuery.getSnapshot();
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
SettingsIcon,
SunIcon,
GitBranchIcon,
GitMergeIcon,
SearchIcon,
KeyboardIcon,
} from "lucide-react";
Expand All @@ -22,6 +23,7 @@ import { useStore } from "~/store";
import { useCommandPaletteStore } from "~/commandPaletteStore";
import { useHandleNewThread } from "~/hooks/useHandleNewThread";
import { useTheme } from "~/hooks/useTheme";
import { useWorktreeCleanupStore } from "~/worktreeCleanupStore";
import {
CommandDialog,
CommandDialogPopup,
Expand Down Expand Up @@ -141,6 +143,7 @@ function CommandsView() {
const pushMruProject = useCommandPaletteStore((s) => s.pushMruProject);
const pushMruThread = useCommandPaletteStore((s) => s.pushMruThread);
const mruThreadIds = useCommandPaletteStore((s) => s.mruThreadIds);
const openWorktreeCleanupDialog = useWorktreeCleanupStore((s) => s.openDialog);
const routeThreadId = useParams({
strict: false,
select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
Expand Down Expand Up @@ -273,6 +276,19 @@ function CommandsView() {
},
});

cmds.push({
id: "action-cleanup-merged-worktrees",
label: "Review merged worktrees",
keywords: ["cleanup", "delete", "prune", "merged", "worktree", "git"],
icon: GitMergeIcon,
group: "Actions",
hidden: !currentProjectId,
onSelect: () => {
closePalette();
openWorktreeCleanupDialog();
},
});

// ── Appearance ──
cmds.push({
id: "appearance-theme-light",
Expand Down Expand Up @@ -357,6 +373,7 @@ function CommandsView() {
setTheme,
pushMruProject,
pushMruThread,
openWorktreeCleanupDialog,
]);

// Filter commands by query
Expand Down
Loading
Loading