1import * as NodeChildProcess from "node:child_process";
2import * as NodeFSP from "node:fs/promises";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import * as NodeUtil from "node:util";
6import { EnvironmentId, VcsListRefsInput } from "@t3tools/contracts";
7import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
8import * as Schema from "effect/Schema";
9import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
10
11import { checkoutNewTaskBranch } from "./checkout-new-task-branch";
12
13const exec = NodeUtil.promisify(NodeChildProcess.execFile);
14let directory: string;
15let cwd: string;
16const git = (...args: string[]) => exec("git", ["-C", cwd, ...args]);
17const branch = { name: "feature/a", current: false, isDefault: false, worktreePath: null };
18const environmentId = EnvironmentId.make("branch-test-environment");
19
20beforeEach(async () => {
21 directory = await NodeFSP.realpath(
22 await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-branch-selection-")),
23 );
24 cwd = NodePath.join(directory, "project");
25 await exec("git", ["init", "-b", "main", cwd]);
26 await git("config", "user.name", "Branch test");
27 await git("config", "user.email", "branch-test@example.com");
28 await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "main\n");
29 await git("add", ".");
30 await git("commit", "-m", "main");
31 await git("checkout", "-b", branch.name);
32 await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "feature\n");
33 await git("commit", "-am", "feature");
34 await git("checkout", "main");
35});
36
37afterEach(async () => {
38 await NodeFSP.rm(directory, { recursive: true, force: true });
39});
40
41type CheckoutInput = Parameters<typeof checkoutNewTaskBranch>[0];
42const decodeListRefsInput = Schema.decodeUnknownSync(VcsListRefsInput);
43const REF_PAGE_SIZE = 2;
44
45const readRefs: CheckoutInput["readRefs"] = (request) =>
46 settlePromise(async () => {
47 expect(request.environmentId).toBe(environmentId);
48 const input = decodeListRefsInput(request.input);
49 const { stdout: rootPath } = await exec("git", [
50 "-C",
51 input.cwd,
52 "rev-parse",
53 "--show-toplevel",
54 ]);
55 const { stdout: head } = await exec("git", [
56 "-C",
57 input.cwd,
58 "rev-parse",
59 "--symbolic-full-name",
60 "HEAD",
61 ]);
62 const { stdout } = await exec("git", [
63 "-C",
64 input.cwd,
65 "for-each-ref",
66 "--sort=refname",
67 "--format=%(refname:short)%09%(worktreepath)%09%(upstream)",
68 "refs/heads",
69 ]);
70 const refs = stdout
71 .trimEnd()
72 .split("\n")
73 .map((line) => {
74 const [name = "", path, upstreamRef] = line.split("\t");
75 return {
76 name,
77 current: head.trim() === `refs/heads/${name}`,
78 isDefault: name === "main",
79 isRemote: false,
80 worktreePath: path || null,
81 ...(upstreamRef ? { upstreamRef } : {}),
82 };
83 })
84 .filter((ref) => ref.name.includes(input.query ?? ""));
85 const cursor = input.cursor ?? 0;
86 return {
87 isAtRepositoryRoot: (await NodeFSP.realpath(input.cwd)) === rootPath.trim(),
88 refs: refs.slice(cursor, cursor + REF_PAGE_SIZE),
89 isRepo: true,
90 hasPrimaryRemote: false,
91 nextCursor: cursor + REF_PAGE_SIZE < refs.length ? cursor + REF_PAGE_SIZE : null,
92 totalCount: refs.length,
93 };
94 });
95
96const refuseSwitch: CheckoutInput["switchRef"] = () => {
97 throw new Error("An occupied branch must not trigger checkout");
98};
99
100function selectBranch(switchRef: CheckoutInput["switchRef"]) {
101 return checkoutNewTaskBranch({
102 branch,
103 project: { environmentId, workspaceRoot: cwd },
104 workspaceMode: "local",
105 readRefs,
106 switchRef,
107 });
108}
109
110const switchRef: CheckoutInput["switchRef"] = (request) =>
111 settlePromise(async () => {
112 expect(request.environmentId).toBe(environmentId);
113 await exec("git", ["-C", request.input.cwd, "checkout", request.input.refName]);
114 const { stdout } = await exec("git", ["-C", request.input.cwd, "branch", "--show-current"]);
115 return { refName: stdout.trim() || null };
116 });
117
118async function addRemote(remoteName: string, sourceRef = branch.name) {
119 const remotePath = NodePath.join(directory, `${remoteName.replaceAll("/", "-")}.git`);
120 await exec("git", ["init", "--bare", remotePath]);
121 await git("remote", "add", remoteName, remotePath);
122 await git("push", remoteName, `${sourceRef}:refs/heads/${branch.name}`);
123 const { stdout } = await git(
124 "rev-parse",
125 "--verify",
126 "--symbolic-full-name",
127 `refs/remotes/${remoteName}/${branch.name}`,
128 );
129 return {
130 ...branch,
131 name: stdout.trim().replace("refs/remotes/", ""),
132 isRemote: true,
133 remoteName,
134 };
135}
136
137describe("new-task branch checkout", () => {
138 it("switches to a free branch and clears its historical worktree path", async () => {
139 const previousWorktree = NodePath.join(directory, "previous-worktree");
140 await git("worktree", "add", previousWorktree, branch.name);
141 await exec("git", ["-C", previousWorktree, "checkout", "-b", "other-branch"]);
142 const result = await checkoutNewTaskBranch({
143 branch: { ...branch, worktreePath: previousWorktree },
144 project: { environmentId, workspaceRoot: cwd },
145 workspaceMode: "local",
146 readRefs,
147 switchRef,
148 });
149 expect(result._tag).toBe("Success");
150 if (result._tag !== "Success") throw new Error("Checkout failed");
151 expect(result.value.name).toBe("feature/a");
152 expect(result.value.current).toBe(true);
153 expect(result.value.worktreePath).toBeNull();
154 expect((await git("branch", "--show-current")).stdout.trim()).toBe("feature/a");
155 expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe("feature\n");
156 });
157
158 it("does not release the selection while checkout is still pending", async () => {
159 const entered = Promise.withResolvers<void>();
160 const release = Promise.withResolvers<void>();
161 let completed = false;
162 const selection = selectBranch(async (request) => {
163 entered.resolve();
164 await release.promise;
165 return switchRef(request);
166 }).then((result) => {
167 completed = true;
168 return result;
169 });
170 await entered.promise;
171 expect(completed).toBe(false);
172 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
173 release.resolve();
174 expect((await selection)._tag).toBe("Success");
175 expect((await git("branch", "--show-current")).stdout.trim()).toBe("feature/a");
176 });
177
178 it("returns checkout failure without selecting the branch or losing dirty files", async () => {
179 await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "unsaved local changes\n");
180 const result = await selectBranch(switchRef);
181 expect(result._tag).toBe("Failure");
182 if (result._tag !== "Failure") throw new Error("Expected checkout to fail");
183 expect(String(squashAtomCommandFailure(result))).toContain("would be overwritten");
184 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
185 expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe(
186 "unsaved local changes\n",
187 );
188 });
189
190 it("reuses an existing worktree without switching the project checkout", async () => {
191 const worktreePath = NodePath.join(directory, "worktree");
192 await git("worktree", "add", worktreePath, "feature/a");
193 const result = await checkoutNewTaskBranch({
194 branch: { ...branch, worktreePath },
195 project: { environmentId, workspaceRoot: cwd },
196 workspaceMode: "local",
197 readRefs,
198 switchRef: () => {
199 throw new Error("Existing worktrees must not switch the project checkout");
200 },
201 });
202 expect(result._tag).toBe("Success");
203 if (result._tag !== "Success") throw new Error("Worktree selection failed");
204 expect(result.value.worktreePath).toBe(worktreePath);
205 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
206 expect(await NodeFSP.readFile(NodePath.join(worktreePath, "file.txt"), "utf8")).toBe(
207 "feature\n",
208 );
209 });
210
211 it("reuses an occupied picker worktree when the project root is a symlink", async () => {
212 const worktreePath = NodePath.join(directory, "worktree");
213 await git("worktree", "add", worktreePath, branch.name);
214 const workspaceRoot = NodePath.join(directory, "project-alias");
215 await NodeFSP.symlink(cwd, workspaceRoot, "junction");
216
217 const result = await checkoutNewTaskBranch({
218 branch: { ...branch, worktreePath },
219 project: { environmentId, workspaceRoot },
220 workspaceMode: "local",
221 readRefs,
222 switchRef: refuseSwitch,
223 });
224
225 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
226 expect(await NodeFSP.readFile(NodePath.join(workspaceRoot, "file.txt"), "utf8")).toBe("main\n");
227 expect(
228 (await exec("git", ["-C", worktreePath, "branch", "--show-current"])).stdout.trim(),
229 ).toBe(branch.name);
230 expect(await NodeFSP.readFile(NodePath.join(worktreePath, "file.txt"), "utf8")).toBe(
231 "feature\n",
232 );
233 expect(
234 result._tag,
235 result._tag === "Failure" ? String(squashAtomCommandFailure(result)) : undefined,
236 ).toBe("Success");
237 if (result._tag !== "Success") throw new Error("Symlink-root worktree selection failed");
238 expect(result.value.worktreePath).toBe(worktreePath);
239 });
240
241 it("resolves historical branch metadata to its worktree beyond a long query's first page", async () => {
242 const name = `feature/${"a".repeat(120)}/${"b".repeat(150)}`;
243 await git("branch", "-m", branch.name, name);
244 await git("branch", `a-${name}`);
245 await git("branch", `b-${name}`);
246 await git("worktree", "add", NodePath.join(directory, "decoy"), `a-${name}`);
247 const worktreePath = NodePath.join(directory, "worktree");
248 await git("worktree", "add", worktreePath, name);
249
250 const result = await checkoutNewTaskBranch({
251 branch: { ...branch, name },
252 project: { environmentId, workspaceRoot: cwd },
253 workspaceMode: "local",
254 readRefs,
255 switchRef: refuseSwitch,
256 });
257
258 expect(result._tag).toBe("Success");
259 if (result._tag !== "Success") throw new Error("Historical branch selection failed");
260 expect(result.value).toMatchObject({
261 name,
262 worktreePath,
263 });
264 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
265 expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe("main\n");
266 });
267
268 it("resolves a non-origin remote to the right worktree when the remote name contains slashes", async () => {
269 const remoteBranch = await addRemote("team/upstream");
270 await git("branch", "--set-upstream-to", remoteBranch.name, branch.name);
271 await git("branch", "upstream/feature/a");
272 await git("worktree", "add", NodePath.join(directory, "decoy"), "upstream/feature/a");
273 const worktreePath = NodePath.join(directory, "worktree");
274 await git("worktree", "add", worktreePath, branch.name);
275
276 const result = await checkoutNewTaskBranch({
277 branch: remoteBranch,
278 project: { environmentId, workspaceRoot: cwd },
279 workspaceMode: "local",
280 readRefs,
281 switchRef: refuseSwitch,
282 });
283
284 expect(result._tag).toBe("Success");
285 if (result._tag !== "Success") throw new Error("Remote branch selection failed");
286 expect(result.value).toMatchObject({
287 name: branch.name,
288 isRemote: false,
289 worktreePath,
290 });
291 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
292 });
293
294 it("switches to the selected remote instead of reusing a worktree tracking another remote", async () => {
295 const originBranch = await addRemote("origin");
296 await git("branch", "--set-upstream-to", originBranch.name, branch.name);
297 const worktreePath = NodePath.join(directory, "worktree");
298 await git("worktree", "add", worktreePath, branch.name);
299 await git("checkout", "-b", "upstream-source");
300 await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "upstream feature\n");
301 await git("commit", "-am", "divergent upstream feature");
302 const remoteBranch = await addRemote("team/upstream", "HEAD");
303 const selectedCommit = (await git("rev-parse", remoteBranch.name)).stdout.trim();
304 expect((await git("rev-parse", originBranch.name)).stdout.trim()).not.toBe(selectedCommit);
305 await git("checkout", "main");
306
307 const result = await checkoutNewTaskBranch({
308 branch: remoteBranch,
309 project: { environmentId, workspaceRoot: cwd },
310 workspaceMode: "local",
311 readRefs,
312 switchRef,
313 });
314
315 expect(result._tag).toBe("Success");
316 if (result._tag !== "Success") throw new Error("Remote checkout failed");
317 expect(result.value.worktreePath).toBeNull();
318 expect((await git("rev-parse", "HEAD")).stdout.trim()).toBe(selectedCommit);
319 expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe(
320 "upstream feature\n",
321 );
322 expect(await NodeFSP.readFile(NodePath.join(worktreePath, "file.txt"), "utf8")).toBe(
323 "feature\n",
324 );
325 });
326
327 it.each([
328 { source: "historical", nested: true },
329 { source: "picker", nested: false },
330 ])(
331 "rejects $source worktree reuse when project root is unsafe (nested: $nested)",
332 async ({ source, nested }) => {
333 const projectRoot = cwd;
334 const worktreePath = NodePath.join(directory, "worktree");
335 await git("worktree", "add", worktreePath, branch.name);
336 if (nested) {
337 cwd = NodePath.join(projectRoot, "packages", "app");
338 await NodeFSP.mkdir(cwd, { recursive: true });
339 }
340 let checkoutAttempted = false;
341
342 const result = await checkoutNewTaskBranch({
343 branch: { ...branch, worktreePath: source === "picker" ? worktreePath : null },
344 project: { environmentId, workspaceRoot: cwd },
345 workspaceMode: "local",
346 readRefs: async (request) => {
347 const result = await readRefs(request);
348 if (!nested && result._tag === "Success") {
349 // Older servers cannot prove whether this project is at the repository root.
350 const { isAtRepositoryRoot: _isAtRepositoryRoot, ...value } = result.value;
351 return { ...result, value };
352 }
353 return result;
354 },
355 switchRef: (request) => {
356 checkoutAttempted = true;
357 return refuseSwitch(request);
358 },
359 });
360
361 expect(checkoutAttempted).toBe(false);
362 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
363 expect(await NodeFSP.readFile(NodePath.join(projectRoot, "file.txt"), "utf8")).toBe("main\n");
364 expect(
365 (await exec("git", ["-C", worktreePath, "branch", "--show-current"])).stdout.trim(),
366 ).toBe(branch.name);
367 expect(await NodeFSP.readFile(NodePath.join(worktreePath, "file.txt"), "utf8")).toBe(
368 "feature\n",
369 );
370 expect(result._tag).toBe("Failure");
371 if (result._tag !== "Failure") throw new Error("Expected unsafe worktree reuse to fail");
372 if (nested) {
373 expect(String(squashAtomCommandFailure(result))).toMatch(/subdirectory/i);
374 }
375 },
376 );
377
378 it("keeps a nested project's cwd when historical metadata names the already-current branch", async () => {
379 await git("checkout", branch.name);
380 cwd = NodePath.join(cwd, "packages", "app");
381 await NodeFSP.mkdir(cwd, { recursive: true });
382
383 const result = await selectBranch(refuseSwitch);
384
385 expect(result._tag).toBe("Success");
386 if (result._tag !== "Success") throw new Error("Current branch selection failed");
387 expect(result.value).toMatchObject({
388 name: branch.name,
389 current: true,
390 worktreePath: null,
391 });
392 expect((await git("branch", "--show-current")).stdout.trim()).toBe(branch.name);
393 });
394});