authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-09 10:50:38-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 09:00:18-08:00
logf47dc3c78ca9496b80c4b4a2537c74bbd697fd9a
tree190d382f66e4eaa9fbe083b3d6d9e0c8fe203ecf
parente9cfe372bfaa35357b6c9402c5f1ff778fd72f3d

Extract git functions and add integration tests

Move git operations (listGitBranches, checkoutGitBranch, createGitWorktree, etc.) from main.ts into git.ts for testability. Add 20 integration tests that run real git commands against temporary repos covering checkout, branch creation, worktrees, conflicts, and thread-switching flows. Also fix: branch checkout now runs regardless of envMode, worktree creation passes cwd to ensureSession to avoid stale closure bug, and current branch auto-fills on new threads. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

3 files changed, 586 insertions(+), 3 deletions(-)

apps/desktop/src/git.test.ts created+379
......@@ -0,0 +1,379 @@
1import { existsSync } from "node:fs";
2import { mkdtemp, rm, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import path from "node:path";
5import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
7import {
8 checkoutGitBranch,
9 createGitBranch,
10 createGitWorktree,
11 initGitRepo,
12 listGitBranches,
13 removeGitWorktree,
14 runTerminalCommand,
15} from "./git";
16
17// ── Helpers ──
18
19/** Run a raw git command for test setup (not under test). */
20async function git(cwd: string, command: string): Promise<string> {
21 const result = await runTerminalCommand({
22 command: `git ${command}`,
23 cwd,
24 timeoutMs: 10_000,
25 });
26 if (result.code !== 0) {
27 throw new Error(`git ${command} failed: ${result.stderr}`);
28 }
29 return result.stdout.trim();
30}
31
32/** Create a repo with an initial commit so branches work. */
33async function initRepoWithCommit(cwd: string): Promise<void> {
34 await initGitRepo({ cwd });
35 await git(cwd, "config user.email 'test@test.com'");
36 await git(cwd, "config user.name 'Test'");
37 await writeFile(path.join(cwd, "README.md"), "# test\n");
38 await git(cwd, "add .");
39 await git(cwd, "commit -m 'initial commit'");
40}
41
42// ── Tests ──
43
44describe("git integration", () => {
45 let tmpDir: string;
46
47 beforeEach(async () => {
48 tmpDir = await mkdtemp(path.join(tmpdir(), "git-test-"));
49 });
50
51 afterEach(async () => {
52 await rm(tmpDir, { recursive: true, force: true });
53 });
54
55 // ── initGitRepo ──
56
57 describe("initGitRepo", () => {
58 it("creates a valid git repo", async () => {
59 await initGitRepo({ cwd: tmpDir });
60 expect(existsSync(path.join(tmpDir, ".git"))).toBe(true);
61 });
62
63 it("listGitBranches reports isRepo: true after init + commit", async () => {
64 await initRepoWithCommit(tmpDir);
65 const result = await listGitBranches({ cwd: tmpDir });
66 expect(result.isRepo).toBe(true);
67 expect(result.branches.length).toBeGreaterThanOrEqual(1);
68 });
69 });
70
71 // ── listGitBranches ──
72
73 describe("listGitBranches", () => {
74 it("returns isRepo: false for non-git directory", async () => {
75 const result = await listGitBranches({ cwd: tmpDir });
76 expect(result.isRepo).toBe(false);
77 expect(result.branches).toEqual([]);
78 });
79
80 it("returns the current branch with current: true", async () => {
81 await initRepoWithCommit(tmpDir);
82 const result = await listGitBranches({ cwd: tmpDir });
83 const current = result.branches.find((b) => b.current);
84 expect(current).toBeDefined();
85 expect(current!.current).toBe(true);
86 });
87
88 it("sorts current branch first", async () => {
89 await initRepoWithCommit(tmpDir);
90 await createGitBranch({ cwd: tmpDir, branch: "aaa-first-alpha" });
91 await createGitBranch({ cwd: tmpDir, branch: "zzz-last" });
92
93 const result = await listGitBranches({ cwd: tmpDir });
94 expect(result.branches[0]!.current).toBe(true);
95 });
96
97 it("lists multiple branches after creating them", async () => {
98 await initRepoWithCommit(tmpDir);
99 await createGitBranch({ cwd: tmpDir, branch: "feature-a" });
100 await createGitBranch({ cwd: tmpDir, branch: "feature-b" });
101
102 const result = await listGitBranches({ cwd: tmpDir });
103 const names = result.branches.map((b) => b.name);
104 expect(names).toContain("feature-a");
105 expect(names).toContain("feature-b");
106 });
107
108 it("isDefault is false when no remote exists", async () => {
109 await initRepoWithCommit(tmpDir);
110 const result = await listGitBranches({ cwd: tmpDir });
111 expect(result.branches.every((b) => b.isDefault === false)).toBe(true);
112 });
113 });
114
115 // ── checkoutGitBranch ──
116
117 describe("checkoutGitBranch", () => {
118 it("checks out an existing branch", async () => {
119 await initRepoWithCommit(tmpDir);
120 await createGitBranch({ cwd: tmpDir, branch: "feature" });
121
122 await checkoutGitBranch({ cwd: tmpDir, branch: "feature" });
123
124 const result = await listGitBranches({ cwd: tmpDir });
125 const current = result.branches.find((b) => b.current);
126 expect(current!.name).toBe("feature");
127 });
128
129 it("throws when branch does not exist", async () => {
130 await initRepoWithCommit(tmpDir);
131 await expect(
132 checkoutGitBranch({ cwd: tmpDir, branch: "nonexistent" }),
133 ).rejects.toThrow();
134 });
135
136 it("throws when checkout would overwrite uncommitted changes", async () => {
137 await initRepoWithCommit(tmpDir);
138 await createGitBranch({ cwd: tmpDir, branch: "other" });
139
140 // Create a conflicting change: modify README on current branch
141 await writeFile(path.join(tmpDir, "README.md"), "modified\n");
142 await git(tmpDir, "add README.md");
143
144 // On the other branch, README has original content — checkout should conflict
145 // Actually git checkout with staged changes can sometimes work, so let's
146 // create a real conflict: commit different content on other branch first.
147 // Simpler: write to a tracked file without committing, then checkout a branch
148 // where that file differs.
149
150 // First, checkout other branch cleanly
151 await git(tmpDir, "stash");
152 await checkoutGitBranch({ cwd: tmpDir, branch: "other" });
153 await writeFile(path.join(tmpDir, "README.md"), "other content\n");
154 await git(tmpDir, "add .");
155 await git(tmpDir, "commit -m 'other change'");
156
157 // Go back to default branch
158 const defaultBranch = (
159 await listGitBranches({ cwd: tmpDir })
160 ).branches.find((b) => !b.current)!.name;
161 await checkoutGitBranch({ cwd: tmpDir, branch: defaultBranch });
162
163 // Make uncommitted changes to the same file
164 await writeFile(path.join(tmpDir, "README.md"), "conflicting local\n");
165
166 // Checkout should fail due to uncommitted changes
167 await expect(
168 checkoutGitBranch({ cwd: tmpDir, branch: "other" }),
169 ).rejects.toThrow();
170 });
171 });
172
173 // ── createGitBranch ──
174
175 describe("createGitBranch", () => {
176 it("creates a new branch visible in listGitBranches", async () => {
177 await initRepoWithCommit(tmpDir);
178 await createGitBranch({ cwd: tmpDir, branch: "new-feature" });
179
180 const result = await listGitBranches({ cwd: tmpDir });
181 expect(result.branches.some((b) => b.name === "new-feature")).toBe(true);
182 });
183
184 it("throws when branch already exists", async () => {
185 await initRepoWithCommit(tmpDir);
186 await createGitBranch({ cwd: tmpDir, branch: "dupe" });
187 await expect(
188 createGitBranch({ cwd: tmpDir, branch: "dupe" }),
189 ).rejects.toThrow();
190 });
191 });
192
193 // ── createGitWorktree + removeGitWorktree ──
194
195 describe("createGitWorktree", () => {
196 it("creates a worktree directory at the expected path", async () => {
197 await initRepoWithCommit(tmpDir);
198 await createGitBranch({ cwd: tmpDir, branch: "wt-branch" });
199
200 const wtPath = path.join(tmpDir, "worktree-out");
201 const result = await createGitWorktree({
202 cwd: tmpDir,
203 branch: "wt-branch",
204 path: wtPath,
205 });
206
207 expect(result.worktree.path).toBe(wtPath);
208 expect(result.worktree.branch).toBe("wt-branch");
209 expect(existsSync(wtPath)).toBe(true);
210 expect(existsSync(path.join(wtPath, "README.md"))).toBe(true);
211
212 // Clean up worktree
213 await removeGitWorktree({ cwd: tmpDir, path: wtPath });
214 });
215
216 it("worktree has the correct branch checked out", async () => {
217 await initRepoWithCommit(tmpDir);
218 await createGitBranch({ cwd: tmpDir, branch: "wt-check" });
219
220 const wtPath = path.join(tmpDir, "wt-check-dir");
221 await createGitWorktree({
222 cwd: tmpDir,
223 branch: "wt-check",
224 path: wtPath,
225 });
226
227 // Verify the worktree is on the right branch
228 const branchOutput = await git(wtPath, "branch --show-current");
229 expect(branchOutput).toBe("wt-check");
230
231 await removeGitWorktree({ cwd: tmpDir, path: wtPath });
232 });
233
234 it("throws when branch is already checked out in main worktree", async () => {
235 await initRepoWithCommit(tmpDir);
236 // Try to create a worktree for the current branch (already checked out)
237 const branches = await listGitBranches({ cwd: tmpDir });
238 const currentBranch = branches.branches.find((b) => b.current)!.name;
239
240 const wtPath = path.join(tmpDir, "wt-conflict");
241 await expect(
242 createGitWorktree({
243 cwd: tmpDir,
244 branch: currentBranch,
245 path: wtPath,
246 }),
247 ).rejects.toThrow();
248 });
249
250 it("removeGitWorktree cleans up the worktree", async () => {
251 await initRepoWithCommit(tmpDir);
252 await createGitBranch({ cwd: tmpDir, branch: "wt-remove" });
253
254 const wtPath = path.join(tmpDir, "wt-remove-dir");
255 await createGitWorktree({
256 cwd: tmpDir,
257 branch: "wt-remove",
258 path: wtPath,
259 });
260 expect(existsSync(wtPath)).toBe(true);
261
262 await removeGitWorktree({ cwd: tmpDir, path: wtPath });
263 expect(existsSync(wtPath)).toBe(false);
264 });
265 });
266
267 // ── Full flow: local branch checkout ──
268
269 describe("full flow: local branch checkout", () => {
270 it("init → commit → create branch → checkout → verify current", async () => {
271 await initRepoWithCommit(tmpDir);
272 await createGitBranch({ cwd: tmpDir, branch: "feature-login" });
273 await checkoutGitBranch({ cwd: tmpDir, branch: "feature-login" });
274
275 const result = await listGitBranches({ cwd: tmpDir });
276 const current = result.branches.find((b) => b.current);
277 expect(current!.name).toBe("feature-login");
278 });
279 });
280
281 // ── Full flow: worktree creation from selected branch ──
282
283 describe("full flow: worktree creation", () => {
284 it("creates worktree from a non-current branch", async () => {
285 await initRepoWithCommit(tmpDir);
286 await createGitBranch({ cwd: tmpDir, branch: "feature-wt" });
287
288 const wtPath = path.join(tmpDir, "my-worktree");
289 const result = await createGitWorktree({
290 cwd: tmpDir,
291 branch: "feature-wt",
292 path: wtPath,
293 });
294
295 // Worktree exists
296 expect(existsSync(result.worktree.path)).toBe(true);
297
298 // Main repo still on original branch
299 const mainBranches = await listGitBranches({ cwd: tmpDir });
300 const mainCurrent = mainBranches.branches.find((b) => b.current);
301 expect(mainCurrent!.name).not.toBe("feature-wt");
302
303 // Worktree is on feature-wt
304 const wtBranch = await git(wtPath, "branch --show-current");
305 expect(wtBranch).toBe("feature-wt");
306
307 await removeGitWorktree({ cwd: tmpDir, path: wtPath });
308 });
309 });
310
311 // ── Full flow: thread switching simulation ──
312
313 describe("full flow: thread switching (checkout toggling)", () => {
314 it("checkout a → checkout b → checkout a → current matches", async () => {
315 await initRepoWithCommit(tmpDir);
316 await createGitBranch({ cwd: tmpDir, branch: "branch-a" });
317 await createGitBranch({ cwd: tmpDir, branch: "branch-b" });
318
319 // Simulate switching to thread A's branch
320 await checkoutGitBranch({ cwd: tmpDir, branch: "branch-a" });
321 let branches = await listGitBranches({ cwd: tmpDir });
322 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
323
324 // Simulate switching to thread B's branch
325 await checkoutGitBranch({ cwd: tmpDir, branch: "branch-b" });
326 branches = await listGitBranches({ cwd: tmpDir });
327 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b");
328
329 // Switch back to thread A
330 await checkoutGitBranch({ cwd: tmpDir, branch: "branch-a" });
331 branches = await listGitBranches({ cwd: tmpDir });
332 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
333 });
334 });
335
336 // ── Full flow: checkout conflict ──
337
338 describe("full flow: checkout conflict", () => {
339 it("uncommitted changes prevent checkout to a diverged branch", async () => {
340 await initRepoWithCommit(tmpDir);
341 await createGitBranch({ cwd: tmpDir, branch: "diverged" });
342
343 // Make diverged branch have different file content
344 await checkoutGitBranch({ cwd: tmpDir, branch: "diverged" });
345 await writeFile(path.join(tmpDir, "README.md"), "diverged content\n");
346 await git(tmpDir, "add .");
347 await git(tmpDir, "commit -m 'diverge'");
348
349 // Go back to default branch
350 const defaultBranch = (
351 await git(tmpDir, "rev-parse --abbrev-ref HEAD")
352 ).includes("diverged")
353 ? // we're on diverged, need to find the other one
354 (await listGitBranches({ cwd: tmpDir })).branches.find(
355 (b) => !b.current && b.name !== "diverged",
356 )!.name
357 : (await git(tmpDir, "rev-parse --abbrev-ref HEAD"));
358
359 // Actually, let's just get back to the initial branch explicitly
360 const allBranches = await listGitBranches({ cwd: tmpDir });
361 const initialBranch = allBranches.branches.find(
362 (b) => b.name !== "diverged",
363 )!.name;
364 await checkoutGitBranch({ cwd: tmpDir, branch: initialBranch });
365
366 // Make local uncommitted changes to the same file
367 await writeFile(path.join(tmpDir, "README.md"), "local uncommitted\n");
368
369 // Attempt checkout should fail
370 await expect(
371 checkoutGitBranch({ cwd: tmpDir, branch: "diverged" }),
372 ).rejects.toThrow();
373
374 // Current branch should still be the initial one
375 const result = await listGitBranches({ cwd: tmpDir });
376 expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);
377 });
378 });
379});
apps/desktop/src/git.ts created+202
......@@ -0,0 +1,202 @@
1import { spawn } from "node:child_process";
2import path from "node:path";
3
4import type {
5 GitCheckoutInput,
6 GitCreateBranchInput,
7 GitCreateWorktreeInput,
8 GitCreateWorktreeResult,
9 GitInitInput,
10 GitListBranchesInput,
11 GitListBranchesResult,
12 GitRemoveWorktreeInput,
13 TerminalCommandInput,
14 TerminalCommandResult,
15} from "@t3tools/contracts";
16
17export async function runTerminalCommand(
18 input: TerminalCommandInput,
19): Promise<TerminalCommandResult> {
20 const shellPath =
21 process.platform === "win32"
22 ? (process.env.ComSpec ?? "cmd.exe")
23 : (process.env.SHELL ?? "/bin/sh");
24
25 const args =
26 process.platform === "win32"
27 ? ["/d", "/s", "/c", input.command]
28 : ["-lc", input.command];
29
30 return new Promise((resolve, reject) => {
31 const child = spawn(shellPath, args, {
32 cwd: input.cwd,
33 env: process.env,
34 stdio: ["ignore", "pipe", "pipe"],
35 });
36
37 let stdout = "";
38 let stderr = "";
39 let timedOut = false;
40
41 const timeout = setTimeout(() => {
42 timedOut = true;
43 child.kill("SIGTERM");
44 setTimeout(() => {
45 if (!child.killed) {
46 child.kill("SIGKILL");
47 }
48 }, 1_000).unref();
49 }, input.timeoutMs ?? 30_000);
50
51 child.stdout?.on("data", (chunk: Buffer) => {
52 stdout += chunk.toString();
53 });
54
55 child.stderr?.on("data", (chunk: Buffer) => {
56 stderr += chunk.toString();
57 });
58
59 child.on("error", (error) => {
60 clearTimeout(timeout);
61 reject(error);
62 });
63
64 child.on("close", (code, signal) => {
65 clearTimeout(timeout);
66 resolve({
67 stdout,
68 stderr,
69 code: code ?? null,
70 signal: signal ?? null,
71 timedOut,
72 });
73 });
74 });
75}
76
77export async function listGitBranches(
78 input: GitListBranchesInput,
79): Promise<GitListBranchesResult> {
80 const result = await runTerminalCommand({
81 command: "git branch --no-color",
82 cwd: input.cwd,
83 timeoutMs: 10_000,
84 });
85
86 if (result.code !== 0) {
87 const stderr = result.stderr.trim();
88 if (stderr.includes("not a git repository")) {
89 return { branches: [], isRepo: false };
90 }
91 throw new Error(stderr || "git branch failed");
92 }
93
94 // Resolve the real default branch from the remote
95 const defaultRef = await runTerminalCommand({
96 command: "git symbolic-ref refs/remotes/origin/HEAD",
97 cwd: input.cwd,
98 timeoutMs: 5_000,
99 });
100 const defaultBranch =
101 defaultRef.code === 0
102 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
103 : null;
104
105 const branches = result.stdout
106 .split("\n")
107 .map((line) => line.trim())
108 .filter((line) => line.length > 0)
109 .map((line) => ({
110 name: line.replace(/^[*+]\s+/, ""),
111 current: line.startsWith("* "),
112 isDefault: line.replace(/^[*+]\s+/, "") === defaultBranch,
113 }))
114 .sort((a, b) => {
115 if (a.current !== b.current) return a.current ? -1 : 1;
116 if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
117 return a.name.localeCompare(b.name);
118 });
119
120 return { branches, isRepo: true };
121}
122
123export async function createGitWorktree(
124 input: GitCreateWorktreeInput,
125): Promise<GitCreateWorktreeResult> {
126 const sanitizedBranch = input.branch.replace(/\//g, "-");
127 const repoName = path.basename(input.cwd);
128 const worktreePath =
129 input.path ??
130 path.join(input.cwd, "..", `${repoName}-worktrees`, sanitizedBranch);
131
132 const result = await runTerminalCommand({
133 command: `git worktree add '${worktreePath.replace(/'/g, "'\\''")}' '${input.branch.replace(/'/g, "'\\''")}'`,
134 cwd: input.cwd,
135 timeoutMs: 30_000,
136 });
137
138 if (result.code !== 0) {
139 throw new Error(result.stderr.trim() || "git worktree add failed");
140 }
141
142 return {
143 worktree: {
144 path: worktreePath,
145 branch: input.branch,
146 },
147 };
148}
149
150export async function removeGitWorktree(
151 input: GitRemoveWorktreeInput,
152): Promise<void> {
153 const result = await runTerminalCommand({
154 command: `git worktree remove '${input.path.replace(/'/g, "'\\''")}'`,
155 cwd: input.cwd,
156 timeoutMs: 15_000,
157 });
158
159 if (result.code !== 0) {
160 throw new Error(result.stderr.trim() || "git worktree remove failed");
161 }
162}
163
164export async function createGitBranch(
165 input: GitCreateBranchInput,
166): Promise<void> {
167 const result = await runTerminalCommand({
168 command: `git branch '${input.branch.replace(/'/g, "'\\''")}'`,
169 cwd: input.cwd,
170 timeoutMs: 10_000,
171 });
172
173 if (result.code !== 0) {
174 throw new Error(result.stderr.trim() || "git branch create failed");
175 }
176}
177
178export async function checkoutGitBranch(
179 input: GitCheckoutInput,
180): Promise<void> {
181 const result = await runTerminalCommand({
182 command: `git checkout '${input.branch.replace(/'/g, "'\\''")}'`,
183 cwd: input.cwd,
184 timeoutMs: 10_000,
185 });
186
187 if (result.code !== 0) {
188 throw new Error(result.stderr.trim() || "git checkout failed");
189 }
190}
191
192export async function initGitRepo(input: GitInitInput): Promise<void> {
193 const result = await runTerminalCommand({
194 command: "git init",
195 cwd: input.cwd,
196 timeoutMs: 10_000,
197 });
198
199 if (result.code !== 0) {
200 throw new Error(result.stderr.trim() || "git init failed");
201 }
202}
apps/web/src/components/ChatView.tsx+5-3
......@@ -384,7 +384,9 @@ export default function ChatView() {
384384 setIsEditorMenuOpen(false);
385385 };
386386
387 const ensureSession = async (): Promise<EnsuredSessionInfo | null> => {
387 const ensureSession = async (
388 cwdOverride?: string,
389 ): Promise<EnsuredSessionInfo | null> => {
388390 if (!api || !activeThread || !activeProject) return null;
389391 if (activeThread.session && activeThread.session.status !== "closed") {
390392 const sessionThreadId = activeThread.session.threadId ?? null;
......@@ -406,7 +408,7 @@ export default function ChatView() {
406408 try {
407409 const session = await api.providers.startSession({
408410 provider: "codex",
409 cwd: activeProject.cwd || undefined,
411 cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd,
410412 model: selectedModel || undefined,
411413 resumeThreadId: priorCodexThreadId ?? undefined,
412414 approvalPolicy: runtimeSessionConfig.approvalPolicy,
......@@ -471,7 +473,7 @@ export default function ChatView() {
471473 const previousMessages = activeThread.messages;
472474 setPrompt("");
473475
474 const sessionInfo = await ensureSession();
476 const sessionInfo = await ensureSession(activeThread.worktreePath ?? undefined);
475477 if (!sessionInfo) return;
476478
477479 setIsSending(true);