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 } from "@t3tools/contracts";
7import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
8import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
9
10import { checkoutNewTaskBranch } from "./checkout-new-task-branch";
11
12const exec = NodeUtil.promisify(NodeChildProcess.execFile);
13let directory: string;
14let cwd: string;
15const git = (...args: string[]) => exec("git", ["-C", cwd, ...args]);
16const branch = { name: "feature/a", current: false, isDefault: false, worktreePath: null };
17const environmentId = EnvironmentId.make("branch-test-environment");
18
19beforeEach(async () => {
20 directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-branch-selection-"));
21 cwd = NodePath.join(directory, "project");
22 await exec("git", ["init", "-b", "main", cwd]);
23 await git("config", "user.name", "Branch test");
24 await git("config", "user.email", "branch-test@example.com");
25 await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "main\n");
26 await git("add", ".");
27 await git("commit", "-m", "main");
28 await git("checkout", "-b", branch.name);
29 await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "feature\n");
30 await git("commit", "-am", "feature");
31 await git("checkout", "main");
32});
33
34afterEach(async () => {
35 await NodeFSP.rm(directory, { recursive: true, force: true });
36});
37
38function selectBranch(switchRef: Parameters<typeof checkoutNewTaskBranch>[0]["switchRef"]) {
39 return checkoutNewTaskBranch({
40 branch,
41 project: { environmentId, workspaceRoot: cwd },
42 workspaceMode: "local",
43 switchRef,
44 });
45}
46
47const switchRef: Parameters<typeof checkoutNewTaskBranch>[0]["switchRef"] = (request) =>
48 settlePromise(async () => {
49 expect(request.environmentId).toBe(environmentId);
50 await exec("git", ["-C", request.input.cwd, "checkout", request.input.refName]);
51 const { stdout } = await exec("git", ["-C", request.input.cwd, "branch", "--show-current"]);
52 return { refName: stdout.trim() };
53 });
54
55describe("new-task branch checkout", () => {
56 it("switches main to the older thread's feature branch before returning a selection", async () => {
57 const result = await selectBranch(switchRef);
58 expect(result._tag).toBe("Success");
59 if (result._tag !== "Success") throw new Error("Checkout failed");
60 expect(result.value.name).toBe("feature/a");
61 expect(result.value.current).toBe(true);
62 expect((await git("branch", "--show-current")).stdout.trim()).toBe("feature/a");
63 expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe("feature\n");
64 });
65
66 it("does not release the selection while checkout is still pending", async () => {
67 const entered = Promise.withResolvers<void>();
68 const release = Promise.withResolvers<void>();
69 let completed = false;
70 const selection = selectBranch(async (request) => {
71 entered.resolve();
72 await release.promise;
73 return switchRef(request);
74 }).then((result) => {
75 completed = true;
76 return result;
77 });
78 await entered.promise;
79 expect(completed).toBe(false);
80 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
81 release.resolve();
82 expect((await selection)._tag).toBe("Success");
83 expect((await git("branch", "--show-current")).stdout.trim()).toBe("feature/a");
84 });
85
86 it("returns checkout failure without selecting the branch or losing dirty files", async () => {
87 await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "unsaved local changes\n");
88 const result = await selectBranch(switchRef);
89 expect(result._tag).toBe("Failure");
90 if (result._tag !== "Failure") throw new Error("Expected checkout to fail");
91 expect(String(squashAtomCommandFailure(result))).toContain("would be overwritten");
92 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
93 expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe(
94 "unsaved local changes\n",
95 );
96 });
97
98 it("fails when the source project is unavailable instead of releasing a composer selection", async () => {
99 const result = await checkoutNewTaskBranch({
100 branch,
101 project: null,
102 workspaceMode: "local",
103 switchRef: () => {
104 throw new Error("An unavailable project must not run checkout");
105 },
106 });
107 expect(result._tag).toBe("Failure");
108 if (result._tag !== "Failure") throw new Error("Expected an unavailable-project failure");
109 expect(String(squashAtomCommandFailure(result))).toContain("selected project is unavailable");
110 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
111 });
112
113 it("reuses an existing worktree without switching the project checkout", async () => {
114 const worktreePath = NodePath.join(directory, "worktree");
115 await git("worktree", "add", worktreePath, "feature/a");
116 const result = await checkoutNewTaskBranch({
117 branch: { ...branch, worktreePath },
118 project: { environmentId, workspaceRoot: cwd },
119 workspaceMode: "local",
120 switchRef: () => {
121 throw new Error("Existing worktrees must not switch the project checkout");
122 },
123 });
124 expect(result._tag).toBe("Success");
125 if (result._tag !== "Success") throw new Error("Worktree selection failed");
126 expect(result.value.worktreePath).toBe(worktreePath);
127 expect((await git("branch", "--show-current")).stdout.trim()).toBe("main");
128 expect(await NodeFSP.readFile(NodePath.join(worktreePath, "file.txt"), "utf8")).toBe(
129 "feature\n",
130 );
131 });
132});