Skip to content

Commit 3dfa6cc

Browse files
authored
feat: add worktree management UI and operations (#1216)
1 parent a5abbf7 commit 3dfa6cc

15 files changed

Lines changed: 537 additions & 507 deletions

File tree

apps/code/src/main/services/context-menu/schemas.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,15 @@ export const confirmDeleteArchivedTaskOutput = z.object({
112112
confirmed: z.boolean(),
113113
});
114114

115+
export const confirmDeleteWorktreeInput = z.object({
116+
worktreePath: z.string(),
117+
linkedTaskCount: z.number(),
118+
});
119+
120+
export const confirmDeleteWorktreeOutput = z.object({
121+
confirmed: z.boolean(),
122+
});
123+
115124
export type ConfirmDeleteTaskInput = z.infer<typeof confirmDeleteTaskInput>;
116125
export type ConfirmDeleteTaskResult = z.infer<typeof confirmDeleteTaskOutput>;
117126
export type ConfirmDeleteArchivedTaskInput = z.infer<
@@ -120,6 +129,12 @@ export type ConfirmDeleteArchivedTaskInput = z.infer<
120129
export type ConfirmDeleteArchivedTaskResult = z.infer<
121130
typeof confirmDeleteArchivedTaskOutput
122131
>;
132+
export type ConfirmDeleteWorktreeInput = z.infer<
133+
typeof confirmDeleteWorktreeInput
134+
>;
135+
export type ConfirmDeleteWorktreeResult = z.infer<
136+
typeof confirmDeleteWorktreeOutput
137+
>;
123138

124139
export type TaskContextMenuResult = z.infer<typeof taskContextMenuOutput>;
125140
export type ArchivedTaskContextMenuResult = z.infer<

apps/code/src/main/services/context-menu/service.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,25 @@ export class ContextMenuService {
8282
return { confirmed };
8383
}
8484

85+
async confirmDeleteWorktree({
86+
worktreePath,
87+
linkedTaskCount,
88+
}: {
89+
worktreePath: string;
90+
linkedTaskCount: number;
91+
}): Promise<{ confirmed: boolean }> {
92+
const confirmed = await this.confirm({
93+
title: "Delete Worktree",
94+
message: `Delete worktree at ${worktreePath}?`,
95+
detail:
96+
linkedTaskCount > 0
97+
? `This will remove ${linkedTaskCount} linked task${linkedTaskCount === 1 ? "" : "s"} and delete the worktree.`
98+
: "This will delete the worktree from disk.",
99+
confirmLabel: "Delete",
100+
});
101+
return { confirmed };
102+
}
103+
85104
async showTaskContextMenu(
86105
input: TaskContextMenuInput,
87106
): Promise<TaskContextMenuResult> {

apps/code/src/main/services/workspace/schemas.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,32 @@ export const getWorktreeTasksInput = z.object({
190190

191191
export const getWorktreeTasksOutput = z.array(localTaskSchema);
192192

193+
export const listGitWorktreesInput = z.object({
194+
mainRepoPath: z.string(),
195+
});
196+
197+
export const gitWorktreeEntrySchema = z.object({
198+
worktreePath: z.string(),
199+
head: z.string(),
200+
branch: z.string().nullable(),
201+
taskIds: z.array(z.string()),
202+
});
203+
204+
export const listGitWorktreesOutput = z.array(gitWorktreeEntrySchema);
205+
206+
export const getWorktreeSizeInput = z.object({
207+
worktreePath: z.string(),
208+
});
209+
210+
export const getWorktreeSizeOutput = z.object({
211+
sizeBytes: z.number(),
212+
});
213+
214+
export const deleteWorktreeInput = z.object({
215+
worktreePath: z.string(),
216+
mainRepoPath: z.string(),
217+
});
218+
193219
export const togglePinInput = z.object({
194220
taskId: z.string(),
195221
});
@@ -245,6 +271,9 @@ export type IsWorkspaceRunningInput = z.infer<typeof isWorkspaceRunningInput>;
245271
export type GetWorkspaceTerminalsInput = z.infer<
246272
typeof getWorkspaceTerminalsInput
247273
>;
274+
export type ListGitWorktreesInput = z.infer<typeof listGitWorktreesInput>;
275+
export type GetWorktreeSizeInput = z.infer<typeof getWorktreeSizeInput>;
276+
export type DeleteWorktreeInput = z.infer<typeof deleteWorktreeInput>;
248277

249278
export type WorkspaceTerminalCreatedPayload = z.infer<
250279
typeof workspaceTerminalCreatedPayload

apps/code/src/main/services/workspace/service.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import { execFile } from "node:child_process";
12
import * as fs from "node:fs";
23
import * as fsPromises from "node:fs/promises";
34
import path from "node:path";
5+
import { promisify } from "node:util";
46
import { createGitClient } from "@posthog/git/client";
57
import {
68
getCurrentBranch,
79
getDefaultBranch,
810
hasTrackedFiles,
11+
listWorktrees,
912
} from "@posthog/git/queries";
1013
import { CreateOrSwitchBranchSaga } from "@posthog/git/sagas/branch";
1114
import { DetachHeadSaga } from "@posthog/git/sagas/head";
@@ -44,6 +47,8 @@ import type {
4447
import { ScriptRunner } from "./scriptRunner";
4548
import { buildWorkspaceEnv } from "./workspaceEnv";
4649

50+
const execFileAsync = promisify(execFile);
51+
4752
type TaskAssociation =
4853
| { taskId: string; folderId: string; mode: "local" }
4954
| { taskId: string; folderId: string | null; mode: "cloud" }
@@ -1197,6 +1202,70 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
11971202
return result;
11981203
}
11991204

1205+
async listGitWorktrees(mainRepoPath: string): Promise<
1206+
Array<{
1207+
worktreePath: string;
1208+
head: string;
1209+
branch: string | null;
1210+
taskIds: string[];
1211+
}>
1212+
> {
1213+
const worktreeBasePath = getWorktreeLocation();
1214+
const rawWorktrees = await listWorktrees(mainRepoPath);
1215+
1216+
const twigWorktrees = rawWorktrees.filter((wt) => {
1217+
const isMainRepo = path.resolve(wt.path) === path.resolve(mainRepoPath);
1218+
const isUnderTwig = path
1219+
.resolve(wt.path)
1220+
.startsWith(path.resolve(worktreeBasePath));
1221+
return !isMainRepo && isUnderTwig;
1222+
});
1223+
1224+
return twigWorktrees.map((wt) => {
1225+
const taskIds = this.getWorktreeTasks(wt.path).map((t) => t.taskId);
1226+
return {
1227+
worktreePath: wt.path,
1228+
head: wt.head,
1229+
branch: wt.branch,
1230+
taskIds,
1231+
};
1232+
});
1233+
}
1234+
1235+
async getWorktreeSize(worktreePath: string): Promise<{ sizeBytes: number }> {
1236+
try {
1237+
const { stdout } = await execFileAsync("du", ["-s", worktreePath]);
1238+
const [sizeStr] = stdout.trim().split("\t");
1239+
const sizeBytes = sizeStr ? parseInt(sizeStr, 10) * 512 : 0;
1240+
return { sizeBytes };
1241+
} catch (error) {
1242+
log.warn(`Failed to get size for ${worktreePath}:`, error);
1243+
return { sizeBytes: 0 };
1244+
}
1245+
}
1246+
1247+
async deleteWorktree(
1248+
mainRepoPath: string,
1249+
worktreePath: string,
1250+
): Promise<void> {
1251+
const worktree = this.worktreeRepo.findByPath(worktreePath);
1252+
if (worktree) {
1253+
const workspace = this.workspaceRepo.findById(worktree.workspaceId);
1254+
if (workspace) {
1255+
await this.deleteWorkspace(workspace.taskId, mainRepoPath);
1256+
return;
1257+
}
1258+
}
1259+
1260+
const worktreeBasePath = getWorktreeLocation();
1261+
const manager = new WorktreeManager({ mainRepoPath, worktreeBasePath });
1262+
await manager.deleteWorktree(worktreePath);
1263+
1264+
if (worktree) {
1265+
this.worktreeRepo.deleteByWorkspaceId(worktree.workspaceId);
1266+
}
1267+
}
1268+
12001269
private async cleanupWorktree(
12011270
taskId: string,
12021271
mainRepoPath: string,

apps/code/src/main/trpc/routers/context-menu.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
confirmDeleteArchivedTaskOutput,
88
confirmDeleteTaskInput,
99
confirmDeleteTaskOutput,
10+
confirmDeleteWorktreeInput,
11+
confirmDeleteWorktreeOutput,
1012
fileContextMenuInput,
1113
fileContextMenuOutput,
1214
folderContextMenuInput,
@@ -34,6 +36,11 @@ export const contextMenuRouter = router({
3436
.output(confirmDeleteArchivedTaskOutput)
3537
.mutation(({ input }) => getService().confirmDeleteArchivedTask(input)),
3638

39+
confirmDeleteWorktree: publicProcedure
40+
.input(confirmDeleteWorktreeInput)
41+
.output(confirmDeleteWorktreeOutput)
42+
.mutation(({ input }) => getService().confirmDeleteWorktree(input)),
43+
3744
showTaskContextMenu: publicProcedure
3845
.input(taskContextMenuInput)
3946
.output(taskContextMenuOutput)

apps/code/src/main/trpc/routers/workspace.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
createWorkspaceInput,
66
createWorkspaceOutput,
77
deleteWorkspaceInput,
8+
deleteWorktreeInput,
89
getAllTaskTimestampsOutput,
910
getAllWorkspacesOutput,
1011
getLocalTasksInput,
@@ -16,10 +17,14 @@ import {
1617
getWorkspaceInfoOutput,
1718
getWorkspaceTerminalsInput,
1819
getWorkspaceTerminalsOutput,
20+
getWorktreeSizeInput,
21+
getWorktreeSizeOutput,
1922
getWorktreeTasksInput,
2023
getWorktreeTasksOutput,
2124
isWorkspaceRunningInput,
2225
isWorkspaceRunningOutput,
26+
listGitWorktreesInput,
27+
listGitWorktreesOutput,
2328
markActivityInput,
2429
markViewedInput,
2530
runStartScriptsInput,
@@ -111,6 +116,22 @@ export const workspaceRouter = router({
111116
.output(getWorktreeTasksOutput)
112117
.query(({ input }) => getService().getWorktreeTasks(input.worktreePath)),
113118

119+
listGitWorktrees: publicProcedure
120+
.input(listGitWorktreesInput)
121+
.output(listGitWorktreesOutput)
122+
.query(({ input }) => getService().listGitWorktrees(input.mainRepoPath)),
123+
124+
getWorktreeSize: publicProcedure
125+
.input(getWorktreeSizeInput)
126+
.output(getWorktreeSizeOutput)
127+
.query(({ input }) => getService().getWorktreeSize(input.worktreePath)),
128+
129+
deleteWorktree: publicProcedure
130+
.input(deleteWorktreeInput)
131+
.mutation(({ input }) =>
132+
getService().deleteWorktree(input.mainRepoPath, input.worktreePath),
133+
),
134+
114135
togglePin: publicProcedure
115136
.input(togglePinInput)
116137
.output(togglePinOutput)

apps/code/src/renderer/features/settings/components/SettingsDialog.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
Plugs,
1515
PlugsConnected,
1616
TrafficSignal,
17+
TreeStructure,
1718
User,
1819
Wrench,
1920
} from "@phosphor-icons/react";
@@ -31,6 +32,7 @@ import { ShortcutsSettings } from "./sections/ShortcutsSettings";
3132
import { SignalSourcesSettings } from "./sections/SignalSourcesSettings";
3233
import { UpdatesSettings } from "./sections/UpdatesSettings";
3334
import { WorkspacesSettings } from "./sections/WorkspacesSettings";
35+
import { WorktreesSettings } from "./sections/worktrees/WorktreesSettings";
3436

3537
interface SidebarItem {
3638
id: SettingsCategory;
@@ -43,6 +45,7 @@ const SIDEBAR_ITEMS: SidebarItem[] = [
4345
{ id: "general", label: "General", icon: <GearSix size={16} /> },
4446
{ id: "account", label: "Account", icon: <User size={16} /> },
4547
{ id: "workspaces", label: "Workspaces", icon: <Folder size={16} /> },
48+
{ id: "worktrees", label: "Worktrees", icon: <TreeStructure size={16} /> },
4649
{
4750
id: "personalization",
4851
label: "Personalization",
@@ -69,6 +72,7 @@ const CATEGORY_TITLES: Record<SettingsCategory, string> = {
6972
general: "General",
7073
account: "Account",
7174
workspaces: "Workspaces",
75+
worktrees: "Worktrees",
7276
personalization: "Personalization",
7377
"claude-code": "Claude Code",
7478
"mcp-servers": "MCP Servers",
@@ -83,6 +87,7 @@ const CATEGORY_COMPONENTS: Record<SettingsCategory, React.ComponentType> = {
8387
general: GeneralSettings,
8488
account: AccountSettings,
8589
workspaces: WorkspacesSettings,
90+
worktrees: WorktreesSettings,
8691
personalization: PersonalizationSettings,
8792
"claude-code": ClaudeCodeSettings,
8893
"mcp-servers": McpServersSettings,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Flex, Text } from "@radix-ui/themes";
2+
import type { Task } from "@shared/types";
3+
import type { WorktreeEntry } from "./WorktreeRow";
4+
import { WorktreeRow } from "./WorktreeRow";
5+
6+
export interface WorktreeGroup {
7+
folderPath: string;
8+
worktrees: WorktreeEntry[];
9+
}
10+
11+
function getFolderName(folderPath: string): string {
12+
const parts = folderPath.split("/");
13+
return parts[parts.length - 1] || folderPath;
14+
}
15+
16+
interface WorktreeGroupSectionProps {
17+
group: WorktreeGroup;
18+
taskMap: Map<string, Task>;
19+
deletingWorktrees: Set<string>;
20+
onDelete: (
21+
worktreePath: string,
22+
allTaskIds: string[],
23+
existingTaskIds: string[],
24+
folderPath: string,
25+
) => void;
26+
}
27+
28+
export function WorktreeGroupSection({
29+
group,
30+
taskMap,
31+
deletingWorktrees,
32+
onDelete,
33+
}: WorktreeGroupSectionProps) {
34+
const folderName = getFolderName(group.folderPath);
35+
36+
return (
37+
<Flex direction="column">
38+
<Text size="1" color="gray" mb="2">
39+
{folderName}
40+
</Text>
41+
<Flex direction="column">
42+
{group.worktrees.map((worktree, index) => (
43+
<WorktreeRow
44+
key={worktree.worktreePath}
45+
worktree={worktree}
46+
folderPath={group.folderPath}
47+
taskMap={taskMap}
48+
isDeleting={deletingWorktrees.has(worktree.worktreePath)}
49+
onDelete={onDelete}
50+
isLast={index === group.worktrees.length - 1}
51+
/>
52+
))}
53+
</Flex>
54+
</Flex>
55+
);
56+
}

0 commit comments

Comments
 (0)