Skip to content

Commit 98db173

Browse files
committed
feat: add worktree management surface
1 parent 4c16c68 commit 98db173

21 files changed

Lines changed: 1704 additions & 609 deletions

packages/server/src/__tests__/worktree-commands.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe("Worktree Commands", () => {
3333
let eventBus: EventBus;
3434
let db: ReturnType<typeof openDatabase>;
3535
let workspaceId: string;
36+
let tempPaths: string[];
3637

3738
beforeEach(async () => {
3839
repoDir = join(
@@ -51,6 +52,7 @@ describe("Worktree Commands", () => {
5152
runMigrations(db);
5253
eventBus = new EventBus();
5354
workspaceMgr = new WorkspaceManager({ db, eventBus });
55+
tempPaths = [];
5456

5557
const workspace = await workspaceMgr.open({ path: repoDir });
5658
workspaceId = workspace.id;
@@ -71,6 +73,7 @@ describe("Worktree Commands", () => {
7173
afterEach(async () => {
7274
await rm(repoDir, { recursive: true, force: true });
7375
await rm(otherRepoDir, { recursive: true, force: true });
76+
await Promise.all(tempPaths.map((tempPath) => rm(tempPath, { recursive: true, force: true })));
7477
});
7578

7679
it("returns status for a worktree belonging to the workspace repo", async () => {
@@ -118,4 +121,83 @@ describe("Worktree Commands", () => {
118121
expect(result.ok).toBe(false);
119122
expect(result.error?.code).toBe("worktree_not_found");
120123
});
124+
125+
it("emits worktreeChanged after create and remove", async () => {
126+
const createdPath = join(
127+
tmpdir(),
128+
`worktree-command-created-${Date.now()}-${Math.random().toString(36).slice(2)}`
129+
);
130+
const linkedPath = join(
131+
tmpdir(),
132+
`worktree-command-linked-${Date.now()}-${Math.random().toString(36).slice(2)}`
133+
);
134+
tempPaths.push(createdPath, linkedPath);
135+
136+
await execFileAsync("git", ["branch", "feature/existing-worktree"], { cwd: repoDir });
137+
await execFileAsync("git", ["worktree", "add", linkedPath, "feature/existing-worktree"], {
138+
cwd: repoDir,
139+
});
140+
const linkedWorkspace = await workspaceMgr.open({ path: linkedPath });
141+
await execFileAsync("git", ["branch", "feature/worktree-manager"], { cwd: repoDir });
142+
143+
const emitted: Array<{ workspaceId: string; worktreeChanged?: boolean }> = [];
144+
eventBus.on("git.state.changed", (event) => emitted.push(event));
145+
146+
const createResult = await dispatch(
147+
{
148+
kind: "command",
149+
id: "worktree-create-event",
150+
op: "worktree.create",
151+
args: {
152+
workspaceId,
153+
branch: "feature/worktree-manager",
154+
path: createdPath,
155+
},
156+
},
157+
ctx
158+
);
159+
160+
expect(createResult.ok).toBe(true);
161+
expect(emitted).toEqual(
162+
expect.arrayContaining([
163+
expect.objectContaining({
164+
workspaceId,
165+
worktreeChanged: true,
166+
}),
167+
expect.objectContaining({
168+
workspaceId: linkedWorkspace.id,
169+
worktreeChanged: true,
170+
}),
171+
])
172+
);
173+
174+
emitted.length = 0;
175+
176+
const removeResult = await dispatch(
177+
{
178+
kind: "command",
179+
id: "worktree-remove-event",
180+
op: "worktree.remove",
181+
args: {
182+
workspaceId,
183+
worktreePath: createdPath,
184+
},
185+
},
186+
ctx
187+
);
188+
189+
expect(removeResult.ok).toBe(true);
190+
expect(emitted).toEqual(
191+
expect.arrayContaining([
192+
expect.objectContaining({
193+
workspaceId,
194+
worktreeChanged: true,
195+
}),
196+
expect.objectContaining({
197+
workspaceId: linkedWorkspace.id,
198+
worktreeChanged: true,
199+
}),
200+
])
201+
);
202+
});
121203
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { CommandContext } from "../ws/dispatch.js";
2+
3+
export function emitGitStateChanged(
4+
ctx: CommandContext,
5+
workspaceId: string,
6+
options?: {
7+
treeChanged?: boolean;
8+
branchChanged?: boolean;
9+
worktreeChanged?: boolean;
10+
}
11+
) {
12+
ctx.eventBus.emit({
13+
type: "git.state.changed",
14+
workspaceId,
15+
treeChanged: options?.treeChanged,
16+
branchChanged: options?.branchChanged,
17+
worktreeChanged: options?.worktreeChanged,
18+
});
19+
}

packages/server/src/commands/git.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,7 @@ import {
2020
import { getFileDiff } from "../git/diff.js";
2121
import type { CommandContext } from "../ws/dispatch.js";
2222
import { registerCommand } from "../ws/dispatch.js";
23-
24-
function emitGitStateChanged(
25-
ctx: CommandContext,
26-
workspaceId: string,
27-
options?: {
28-
treeChanged?: boolean;
29-
branchChanged?: boolean;
30-
worktreeChanged?: boolean;
31-
}
32-
) {
33-
ctx.eventBus.emit({
34-
type: "git.state.changed",
35-
workspaceId,
36-
treeChanged: options?.treeChanged,
37-
branchChanged: options?.branchChanged,
38-
worktreeChanged: options?.worktreeChanged,
39-
});
40-
}
23+
import { emitGitStateChanged } from "./git-events.js";
4124

4225
const gitHttpAuthSchema = z.object({
4326
username: z.string(),

packages/server/src/commands/worktree.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,42 @@
55
import { z } from "zod";
66
import {
77
createWorktree,
8+
getGitCommonDirPath,
89
getWorktreeDiff,
910
getWorktreeStatus,
1011
getWorktreeTree,
1112
listWorktrees,
1213
removeWorktree,
1314
resolveWorktreePath,
1415
} from "../git/worktree.js";
16+
import type { CommandContext } from "../ws/dispatch.js";
1517
import { registerCommand } from "../ws/dispatch.js";
18+
import { emitGitStateChanged } from "./git-events.js";
19+
20+
async function findRelatedWorkspaceIds(
21+
ctx: CommandContext,
22+
workspacePath: string
23+
): Promise<string[]> {
24+
const targetCommonDir = await getGitCommonDirPath(workspacePath);
25+
const relatedWorkspaceIds = await Promise.all(
26+
ctx.workspaceMgr.list().map(async (workspace) => {
27+
try {
28+
const commonDir = await getGitCommonDirPath(workspace.path);
29+
return commonDir === targetCommonDir ? workspace.id : null;
30+
} catch {
31+
return null;
32+
}
33+
})
34+
);
35+
36+
return relatedWorkspaceIds.filter((workspaceId): workspaceId is string => Boolean(workspaceId));
37+
}
38+
39+
function emitWorktreeChangedForWorkspaceIds(ctx: CommandContext, workspaceIds: string[]) {
40+
for (const workspaceId of workspaceIds) {
41+
emitGitStateChanged(ctx, workspaceId, { worktreeChanged: true });
42+
}
43+
}
1644

1745
// worktree.list
1846
registerCommand("worktree.list", z.object({ workspaceId: z.string() }), async (args, ctx) => {
@@ -85,7 +113,11 @@ registerCommand(
85113
if (!workspace) {
86114
throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` };
87115
}
88-
return { worktree: await createWorktree(workspace.path, args.branch, args.path) };
116+
117+
const relatedWorkspaceIds = await findRelatedWorkspaceIds(ctx, workspace.path);
118+
const worktree = await createWorktree(workspace.path, args.branch, args.path);
119+
emitWorktreeChangedForWorkspaceIds(ctx, relatedWorkspaceIds);
120+
return { worktree };
89121
}
90122
);
91123

@@ -103,8 +135,10 @@ registerCommand(
103135
throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` };
104136
}
105137

138+
const relatedWorkspaceIds = await findRelatedWorkspaceIds(ctx, workspace.path);
106139
const worktreePath = await resolveWorktreePath(workspace.path, args.worktreePath);
107140
await removeWorktree(workspace.path, worktreePath, args.force);
141+
emitWorktreeChangedForWorkspaceIds(ctx, relatedWorkspaceIds);
108142
return {};
109143
}
110144
);

packages/server/src/git/worktree.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ function normalizeWorktreePath(worktreePath: string): string {
1313
return path.resolve(worktreePath);
1414
}
1515

16+
export async function getGitCommonDirPath(repoPath: string): Promise<string> {
17+
const { stdout } = await runGit(repoPath, ["rev-parse", "--git-common-dir"]);
18+
return normalizeWorktreePath(path.resolve(repoPath, stdout.trim()));
19+
}
20+
1621
export async function resolveWorktreePath(repoPath: string, worktreePath: string): Promise<string> {
1722
const normalizedRequested = normalizeWorktreePath(worktreePath);
1823
const worktrees = await listWorktrees(repoPath);
@@ -171,12 +176,13 @@ export async function getWorktreeTree(worktreePath: string): Promise<FileNode[]>
171176
export async function createWorktree(
172177
repoPath: string,
173178
branch: string,
174-
path: string
179+
worktreePath: string
175180
): Promise<WorktreeInfo> {
176-
await runGit(repoPath, ["worktree", "add", path, branch]);
181+
await runGit(repoPath, ["worktree", "add", worktreePath, branch]);
177182

178183
const worktrees = await listWorktrees(repoPath);
179-
const created = worktrees.find((wt) => wt.path === path);
184+
const normalizedRequested = normalizeWorktreePath(worktreePath);
185+
const created = worktrees.find((wt) => normalizeWorktreePath(wt.path) === normalizedRequested);
180186

181187
if (!created) {
182188
throw new Error("Failed to find created worktree");

0 commit comments

Comments
 (0)