authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-09 23:58:27-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 09:00:18-08:00
logc13e9f89a977518a07426a68df48603127fc2ec7
tree881ae3fd5649587a84e0f254eda0c46fbce36437
parentd1ecacda43718b0a96e9fca1cd7d30f82a64a574

Restore git toolbar via websocket backend

Co-authored-by: codex <codex@users.noreply.github.com>

13 files changed, 1087 insertions(+), 2 deletions(-)

apps/server/src/git.test.ts created+423
......@@ -0,0 +1,423 @@
1import { existsSync } from "node:fs";
2import { mkdtemp, rm, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import path from "node:path";
5import { 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 disposable temp directory that cleans up automatically. */
33async function makeTmpDir() {
34 const dir = await mkdtemp(path.join(tmpdir(), "git-test-"));
35 return {
36 path: dir,
37 [Symbol.asyncDispose]: async () => {
38 await rm(dir, { recursive: true, force: true });
39 },
40 };
41}
42
43/** Create a repo with an initial commit so branches work. */
44async function initRepoWithCommit(cwd: string): Promise<void> {
45 await initGitRepo({ cwd });
46 await git(cwd, "config user.email 'test@test.com'");
47 await git(cwd, "config user.name 'Test'");
48 await writeFile(path.join(cwd, "README.md"), "# test\n");
49 await git(cwd, "add .");
50 await git(cwd, "commit -m 'initial commit'");
51}
52
53// ── Tests ──
54
55describe("git integration", () => {
56 // ── initGitRepo ──
57
58 describe("initGitRepo", () => {
59 it("creates a valid git repo", async () => {
60 await using tmp = await makeTmpDir();
61 await initGitRepo({ cwd: tmp.path });
62 expect(existsSync(path.join(tmp.path, ".git"))).toBe(true);
63 });
64
65 it("listGitBranches reports isRepo: true after init + commit", async () => {
66 await using tmp = await makeTmpDir();
67 await initRepoWithCommit(tmp.path);
68 const result = await listGitBranches({ cwd: tmp.path });
69 expect(result.isRepo).toBe(true);
70 expect(result.branches.length).toBeGreaterThanOrEqual(1);
71 });
72 });
73
74 // ── listGitBranches ──
75
76 describe("listGitBranches", () => {
77 it("returns isRepo: false for non-git directory", async () => {
78 await using tmp = await makeTmpDir();
79 const result = await listGitBranches({ cwd: tmp.path });
80 expect(result.isRepo).toBe(false);
81 expect(result.branches).toEqual([]);
82 });
83
84 it("returns the current branch with current: true", async () => {
85 await using tmp = await makeTmpDir();
86 await initRepoWithCommit(tmp.path);
87 const result = await listGitBranches({ cwd: tmp.path });
88 const current = result.branches.find((b) => b.current);
89 expect(current).toBeDefined();
90 expect(current!.current).toBe(true);
91 });
92
93 it("sorts current branch first", async () => {
94 await using tmp = await makeTmpDir();
95 await initRepoWithCommit(tmp.path);
96 await createGitBranch({ cwd: tmp.path, branch: "aaa-first-alpha" });
97 await createGitBranch({ cwd: tmp.path, branch: "zzz-last" });
98
99 const result = await listGitBranches({ cwd: tmp.path });
100 expect(result.branches[0]!.current).toBe(true);
101 });
102
103 it("lists multiple branches after creating them", async () => {
104 await using tmp = await makeTmpDir();
105 await initRepoWithCommit(tmp.path);
106 await createGitBranch({ cwd: tmp.path, branch: "feature-a" });
107 await createGitBranch({ cwd: tmp.path, branch: "feature-b" });
108
109 const result = await listGitBranches({ cwd: tmp.path });
110 const names = result.branches.map((b) => b.name);
111 expect(names).toContain("feature-a");
112 expect(names).toContain("feature-b");
113 });
114
115 it("isDefault is false when no remote exists", async () => {
116 await using tmp = await makeTmpDir();
117 await initRepoWithCommit(tmp.path);
118 const result = await listGitBranches({ cwd: tmp.path });
119 expect(result.branches.every((b) => b.isDefault === false)).toBe(true);
120 });
121 });
122
123 // ── checkoutGitBranch ──
124
125 describe("checkoutGitBranch", () => {
126 it("checks out an existing branch", async () => {
127 await using tmp = await makeTmpDir();
128 await initRepoWithCommit(tmp.path);
129 await createGitBranch({ cwd: tmp.path, branch: "feature" });
130
131 await checkoutGitBranch({ cwd: tmp.path, branch: "feature" });
132
133 const result = await listGitBranches({ cwd: tmp.path });
134 const current = result.branches.find((b) => b.current);
135 expect(current!.name).toBe("feature");
136 });
137
138 it("throws when branch does not exist", async () => {
139 await using tmp = await makeTmpDir();
140 await initRepoWithCommit(tmp.path);
141 await expect(checkoutGitBranch({ cwd: tmp.path, branch: "nonexistent" })).rejects.toThrow();
142 });
143
144 it("throws when checkout would overwrite uncommitted changes", async () => {
145 await using tmp = await makeTmpDir();
146 await initRepoWithCommit(tmp.path);
147 await createGitBranch({ cwd: tmp.path, branch: "other" });
148
149 // Create a conflicting change: modify README on current branch
150 await writeFile(path.join(tmp.path, "README.md"), "modified\n");
151 await git(tmp.path, "add README.md");
152
153 // First, checkout other branch cleanly
154 await git(tmp.path, "stash");
155 await checkoutGitBranch({ cwd: tmp.path, branch: "other" });
156 await writeFile(path.join(tmp.path, "README.md"), "other content\n");
157 await git(tmp.path, "add .");
158 await git(tmp.path, "commit -m 'other change'");
159
160 // Go back to default branch
161 const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
162 (b) => !b.current,
163 )!.name;
164 await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch });
165
166 // Make uncommitted changes to the same file
167 await writeFile(path.join(tmp.path, "README.md"), "conflicting local\n");
168
169 // Checkout should fail due to uncommitted changes
170 await expect(checkoutGitBranch({ cwd: tmp.path, branch: "other" })).rejects.toThrow();
171 });
172 });
173
174 // ── createGitBranch ──
175
176 describe("createGitBranch", () => {
177 it("creates a new branch visible in listGitBranches", async () => {
178 await using tmp = await makeTmpDir();
179 await initRepoWithCommit(tmp.path);
180 await createGitBranch({ cwd: tmp.path, branch: "new-feature" });
181
182 const result = await listGitBranches({ cwd: tmp.path });
183 expect(result.branches.some((b) => b.name === "new-feature")).toBe(true);
184 });
185
186 it("throws when branch already exists", async () => {
187 await using tmp = await makeTmpDir();
188 await initRepoWithCommit(tmp.path);
189 await createGitBranch({ cwd: tmp.path, branch: "dupe" });
190 await expect(createGitBranch({ cwd: tmp.path, branch: "dupe" })).rejects.toThrow();
191 });
192 });
193
194 // ── createGitWorktree + removeGitWorktree ──
195
196 describe("createGitWorktree", () => {
197 it("creates a worktree with a new branch from the base branch", async () => {
198 await using tmp = await makeTmpDir();
199 await initRepoWithCommit(tmp.path);
200
201 const wtPath = path.join(tmp.path, "worktree-out");
202 const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
203 (b) => b.current,
204 )!.name;
205
206 const result = await createGitWorktree({
207 cwd: tmp.path,
208 branch: currentBranch,
209 newBranch: "wt-branch",
210 path: wtPath,
211 });
212
213 expect(result.worktree.path).toBe(wtPath);
214 expect(result.worktree.branch).toBe("wt-branch");
215 expect(existsSync(wtPath)).toBe(true);
216 expect(existsSync(path.join(wtPath, "README.md"))).toBe(true);
217
218 // Clean up worktree before tmp dir disposal
219 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
220 });
221
222 it("worktree has the new branch checked out", async () => {
223 await using tmp = await makeTmpDir();
224 await initRepoWithCommit(tmp.path);
225
226 const wtPath = path.join(tmp.path, "wt-check-dir");
227 const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
228 (b) => b.current,
229 )!.name;
230
231 await createGitWorktree({
232 cwd: tmp.path,
233 branch: currentBranch,
234 newBranch: "wt-check",
235 path: wtPath,
236 });
237
238 // Verify the worktree is on the new branch
239 const branchOutput = await git(wtPath, "branch --show-current");
240 expect(branchOutput).toBe("wt-check");
241
242 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
243 });
244
245 it("throws when new branch name already exists", async () => {
246 await using tmp = await makeTmpDir();
247 await initRepoWithCommit(tmp.path);
248 await createGitBranch({ cwd: tmp.path, branch: "existing" });
249
250 const wtPath = path.join(tmp.path, "wt-conflict");
251 const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
252 (b) => b.current,
253 )!.name;
254
255 await expect(
256 createGitWorktree({
257 cwd: tmp.path,
258 branch: currentBranch,
259 newBranch: "existing",
260 path: wtPath,
261 }),
262 ).rejects.toThrow();
263 });
264
265 it("listGitBranches from worktree cwd reports worktree branch as current", async () => {
266 await using tmp = await makeTmpDir();
267 await initRepoWithCommit(tmp.path);
268
269 const wtPath = path.join(tmp.path, "wt-list-dir");
270 const mainBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
271 (b) => b.current,
272 )!.name;
273
274 await createGitWorktree({
275 cwd: tmp.path,
276 branch: mainBranch,
277 newBranch: "wt-list",
278 path: wtPath,
279 });
280
281 // listGitBranches from the worktree should show wt-list as current
282 const wtBranches = await listGitBranches({ cwd: wtPath });
283 expect(wtBranches.isRepo).toBe(true);
284 const wtCurrent = wtBranches.branches.find((b) => b.current);
285 expect(wtCurrent!.name).toBe("wt-list");
286
287 // Main repo should still show the original branch as current
288 const mainBranches = await listGitBranches({ cwd: tmp.path });
289 const mainCurrent = mainBranches.branches.find((b) => b.current);
290 expect(mainCurrent!.name).toBe(mainBranch);
291
292 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
293 });
294
295 it("removeGitWorktree cleans up the worktree", async () => {
296 await using tmp = await makeTmpDir();
297 await initRepoWithCommit(tmp.path);
298
299 const wtPath = path.join(tmp.path, "wt-remove-dir");
300 const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
301 (b) => b.current,
302 )!.name;
303
304 await createGitWorktree({
305 cwd: tmp.path,
306 branch: currentBranch,
307 newBranch: "wt-remove",
308 path: wtPath,
309 });
310 expect(existsSync(wtPath)).toBe(true);
311
312 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
313 expect(existsSync(wtPath)).toBe(false);
314 });
315 });
316
317 // ── Full flow: local branch checkout ──
318
319 describe("full flow: local branch checkout", () => {
320 it("init → commit → create branch → checkout → verify current", async () => {
321 await using tmp = await makeTmpDir();
322 await initRepoWithCommit(tmp.path);
323 await createGitBranch({ cwd: tmp.path, branch: "feature-login" });
324 await checkoutGitBranch({ cwd: tmp.path, branch: "feature-login" });
325
326 const result = await listGitBranches({ cwd: tmp.path });
327 const current = result.branches.find((b) => b.current);
328 expect(current!.name).toBe("feature-login");
329 });
330 });
331
332 // ── Full flow: worktree creation from base branch ──
333
334 describe("full flow: worktree creation", () => {
335 it("creates worktree with new branch from current branch", async () => {
336 await using tmp = await makeTmpDir();
337 await initRepoWithCommit(tmp.path);
338
339 const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
340 (b) => b.current,
341 )!.name;
342
343 const wtPath = path.join(tmp.path, "my-worktree");
344 const result = await createGitWorktree({
345 cwd: tmp.path,
346 branch: currentBranch,
347 newBranch: "feature-wt",
348 path: wtPath,
349 });
350
351 // Worktree exists
352 expect(existsSync(result.worktree.path)).toBe(true);
353
354 // Main repo still on original branch
355 const mainBranches = await listGitBranches({ cwd: tmp.path });
356 const mainCurrent = mainBranches.branches.find((b) => b.current);
357 expect(mainCurrent!.name).toBe(currentBranch);
358
359 // Worktree is on the new branch
360 const wtBranch = await git(wtPath, "branch --show-current");
361 expect(wtBranch).toBe("feature-wt");
362
363 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
364 });
365 });
366
367 // ── Full flow: thread switching simulation ──
368
369 describe("full flow: thread switching (checkout toggling)", () => {
370 it("checkout a → checkout b → checkout a → current matches", async () => {
371 await using tmp = await makeTmpDir();
372 await initRepoWithCommit(tmp.path);
373 await createGitBranch({ cwd: tmp.path, branch: "branch-a" });
374 await createGitBranch({ cwd: tmp.path, branch: "branch-b" });
375
376 // Simulate switching to thread A's branch
377 await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" });
378 let branches = await listGitBranches({ cwd: tmp.path });
379 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
380
381 // Simulate switching to thread B's branch
382 await checkoutGitBranch({ cwd: tmp.path, branch: "branch-b" });
383 branches = await listGitBranches({ cwd: tmp.path });
384 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b");
385
386 // Switch back to thread A
387 await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" });
388 branches = await listGitBranches({ cwd: tmp.path });
389 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
390 });
391 });
392
393 // ── Full flow: checkout conflict ──
394
395 describe("full flow: checkout conflict", () => {
396 it("uncommitted changes prevent checkout to a diverged branch", async () => {
397 await using tmp = await makeTmpDir();
398 await initRepoWithCommit(tmp.path);
399 await createGitBranch({ cwd: tmp.path, branch: "diverged" });
400
401 // Make diverged branch have different file content
402 await checkoutGitBranch({ cwd: tmp.path, branch: "diverged" });
403 await writeFile(path.join(tmp.path, "README.md"), "diverged content\n");
404 await git(tmp.path, "add .");
405 await git(tmp.path, "commit -m 'diverge'");
406
407 // Actually, let's just get back to the initial branch explicitly
408 const allBranches = await listGitBranches({ cwd: tmp.path });
409 const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name;
410 await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch });
411
412 // Make local uncommitted changes to the same file
413 await writeFile(path.join(tmp.path, "README.md"), "local uncommitted\n");
414
415 // Attempt checkout should fail
416 await expect(checkoutGitBranch({ cwd: tmp.path, branch: "diverged" })).rejects.toThrow();
417
418 // Current branch should still be the initial one
419 const result = await listGitBranches({ cwd: tmp.path });
420 expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);
421 });
422 });
423});
apps/server/src/git.ts created+207
......@@ -0,0 +1,207 @@
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
17function escapeSingleQuotes(value: string): string {
18 return value.replace(/'/g, "'\\''");
19}
20
21export async function runTerminalCommand(
22 input: TerminalCommandInput,
23): Promise<TerminalCommandResult> {
24 const shellPath =
25 process.platform === "win32"
26 ? (process.env.ComSpec ?? "cmd.exe")
27 : (process.env.SHELL ?? "/bin/sh");
28
29 const args =
30 process.platform === "win32"
31 ? ["/d", "/s", "/c", input.command]
32 : ["-lc", input.command];
33
34 return new Promise((resolve, reject) => {
35 const child = spawn(shellPath, args, {
36 cwd: input.cwd,
37 env: process.env,
38 stdio: ["ignore", "pipe", "pipe"],
39 });
40
41 let stdout = "";
42 let stderr = "";
43 let timedOut = false;
44
45 const timeout = setTimeout(() => {
46 timedOut = true;
47 child.kill("SIGTERM");
48 setTimeout(() => {
49 if (!child.killed) {
50 child.kill("SIGKILL");
51 }
52 }, 1_000).unref();
53 }, input.timeoutMs ?? 30_000);
54
55 child.stdout?.on("data", (chunk: Buffer) => {
56 stdout += chunk.toString();
57 });
58
59 child.stderr?.on("data", (chunk: Buffer) => {
60 stderr += chunk.toString();
61 });
62
63 child.on("error", (error) => {
64 clearTimeout(timeout);
65 reject(error);
66 });
67
68 child.on("close", (code, signal) => {
69 clearTimeout(timeout);
70 resolve({
71 stdout,
72 stderr,
73 code: code ?? null,
74 signal: signal ?? null,
75 timedOut,
76 });
77 });
78 });
79}
80
81export async function listGitBranches(
82 input: GitListBranchesInput,
83): Promise<GitListBranchesResult> {
84 const result = await runTerminalCommand({
85 command: "git branch --no-color",
86 cwd: input.cwd,
87 timeoutMs: 10_000,
88 });
89
90 if (result.code !== 0) {
91 const stderr = result.stderr.trim();
92 if (stderr.includes("not a git repository")) {
93 return { branches: [], isRepo: false };
94 }
95 throw new Error(stderr || "git branch failed");
96 }
97
98 // Resolve the real default branch from the remote
99 const defaultRef = await runTerminalCommand({
100 command: "git symbolic-ref refs/remotes/origin/HEAD",
101 cwd: input.cwd,
102 timeoutMs: 5_000,
103 });
104 const defaultBranch =
105 defaultRef.code === 0
106 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
107 : null;
108
109 const branches = result.stdout
110 .split("\n")
111 .map((line) => line.trim())
112 .filter((line) => line.length > 0)
113 .map((line) => ({
114 name: line.replace(/^[*+]\s+/, ""),
115 current: line.startsWith("* "),
116 isDefault: line.replace(/^[*+]\s+/, "") === defaultBranch,
117 }))
118 .toSorted((a, b) => {
119 if (a.current !== b.current) return a.current ? -1 : 1;
120 if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
121 return a.name.localeCompare(b.name);
122 });
123
124 return { branches, isRepo: true };
125}
126
127export async function createGitWorktree(
128 input: GitCreateWorktreeInput,
129): Promise<GitCreateWorktreeResult> {
130 const sanitizedBranch = input.newBranch.replace(/\//g, "-");
131 const repoName = path.basename(input.cwd);
132 const worktreePath =
133 input.path ??
134 path.join(input.cwd, "..", `${repoName}-worktrees`, sanitizedBranch);
135
136 // Create a new branch from the base branch in a new worktree
137 const result = await runTerminalCommand({
138 command: `git worktree add -b '${escapeSingleQuotes(input.newBranch)}' '${escapeSingleQuotes(worktreePath)}' '${escapeSingleQuotes(input.branch)}'`,
139 cwd: input.cwd,
140 timeoutMs: 30_000,
141 });
142
143 if (result.code !== 0) {
144 throw new Error(result.stderr.trim() || "git worktree add failed");
145 }
146
147 return {
148 worktree: {
149 path: worktreePath,
150 branch: input.newBranch,
151 },
152 };
153}
154
155export async function removeGitWorktree(
156 input: GitRemoveWorktreeInput,
157): Promise<void> {
158 const result = await runTerminalCommand({
159 command: `git worktree remove '${escapeSingleQuotes(input.path)}'`,
160 cwd: input.cwd,
161 timeoutMs: 15_000,
162 });
163
164 if (result.code !== 0) {
165 throw new Error(result.stderr.trim() || "git worktree remove failed");
166 }
167}
168
169export async function createGitBranch(
170 input: GitCreateBranchInput,
171): Promise<void> {
172 const result = await runTerminalCommand({
173 command: `git branch '${escapeSingleQuotes(input.branch)}'`,
174 cwd: input.cwd,
175 timeoutMs: 10_000,
176 });
177
178 if (result.code !== 0) {
179 throw new Error(result.stderr.trim() || "git branch create failed");
180 }
181}
182
183export async function checkoutGitBranch(
184 input: GitCheckoutInput,
185): Promise<void> {
186 const result = await runTerminalCommand({
187 command: `git checkout '${escapeSingleQuotes(input.branch)}'`,
188 cwd: input.cwd,
189 timeoutMs: 10_000,
190 });
191
192 if (result.code !== 0) {
193 throw new Error(result.stderr.trim() || "git checkout failed");
194 }
195}
196
197export async function initGitRepo(input: GitInitInput): Promise<void> {
198 const result = await runTerminalCommand({
199 command: "git init",
200 cwd: input.cwd,
201 timeoutMs: 10_000,
202 });
203
204 if (result.code !== 0) {
205 throw new Error(result.stderr.trim() || "git init failed");
206 }
207}
apps/server/src/wsServer.test.ts+26
......@@ -281,6 +281,32 @@ describe("WebSocket Server", () => {
281281 expect(afterRemove.result).toEqual([]);
282282 });
283283
284 it("supports git methods over websocket", async () => {
285 const repoCwd = makeTempDir("t3code-ws-git-project-");
286
287 server = createTestServer({ cwd: "/test" });
288 await server.start();
289 const addr = server.httpServer.address();
290 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
291
292 const ws = await connectWs(port);
293 connections.push(ws);
294 await waitForMessage(ws);
295
296 const beforeInit = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: repoCwd });
297 expect(beforeInit.error).toBeUndefined();
298 expect(beforeInit.result).toEqual({ branches: [], isRepo: false });
299
300 const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: repoCwd });
301 expect(initResponse.error).toBeUndefined();
302
303 const afterInit = await sendRequest(ws, WS_METHODS.gitListBranches, {
304 cwd: repoCwd,
305 });
306 expect(afterInit.error).toBeUndefined();
307 expect((afterInit.result as { isRepo: boolean }).isRepo).toBe(true);
308 });
309
284310 it("prunes missing projects on startup", async () => {
285311 const stateDir = makeTempDir("t3code-ws-prune-state-");
286312 const existing = makeTempDir("t3code-ws-existing-project-");
apps/server/src/wsServer.ts+26
......@@ -19,6 +19,14 @@ import { WebSocketServer, type WebSocket } from "ws";
1919import { createLogger } from "./logger";
2020import { ProjectRegistry } from "./projectRegistry";
2121import { ProviderManager } from "./providerManager";
22import {
23 checkoutGitBranch,
24 createGitBranch,
25 createGitWorktree,
26 initGitRepo,
27 listGitBranches,
28 removeGitWorktree,
29} from "./git";
2230
2331const MIME_TYPES: Record<string, string> = {
2432 ".html": "text/html; charset=utf-8",
......@@ -304,6 +312,24 @@ export function createServer(options: ServerOptions) {
304312 return undefined;
305313 }
306314
315 case WS_METHODS.gitListBranches:
316 return listGitBranches(request.params as never);
317
318 case WS_METHODS.gitCreateWorktree:
319 return createGitWorktree(request.params as never);
320
321 case WS_METHODS.gitRemoveWorktree:
322 return removeGitWorktree(request.params as never);
323
324 case WS_METHODS.gitCreateBranch:
325 return createGitBranch(request.params as never);
326
327 case WS_METHODS.gitCheckout:
328 return checkoutGitBranch(request.params as never);
329
330 case WS_METHODS.gitInit:
331 return initGitRepo(request.params as never);
332
307333 case WS_METHODS.serverGetConfig:
308334 return { cwd };
309335
apps/server/tsconfig.json+1-1
......@@ -3,7 +3,7 @@
33 "compilerOptions": {
44 "composite": true,
55 "types": ["node"],
6 "lib": ["ES2023"]
6 "lib": ["ES2023", "esnext.disposable"]
77 },
88 "include": ["src", "tsup.config.ts"]
99}
apps/web/src/App.tsx+2
......@@ -82,6 +82,8 @@ function AutoProjectBootstrap() {
8282 events: [],
8383 error: null,
8484 createdAt: new Date().toISOString(),
85 branch: null,
86 worktreePath: null,
8587 },
8688 });
8789 });
apps/web/src/components/BranchToolbar.tsx created+312
......@@ -0,0 +1,312 @@
1import type { GitBranch } from "@t3tools/contracts";
2import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
4import { readNativeApi } from "../session-logic";
5import { useStore } from "../store";
6
7interface BranchToolbarProps {
8 envMode: "local" | "worktree";
9 onEnvModeChange: (mode: "local" | "worktree") => void;
10 envLocked: boolean;
11}
12
13export default function BranchToolbar({
14 envMode,
15 onEnvModeChange,
16 envLocked,
17}: BranchToolbarProps) {
18 const { state, dispatch } = useStore();
19 const api = useMemo(() => readNativeApi(), []);
20
21 const [branches, setBranches] = useState<GitBranch[]>([]);
22 const [isRepo, setIsRepo] = useState(false);
23 const [isLoadingBranches, setIsLoadingBranches] = useState(false);
24 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
25 const [isCreatingBranch, setIsCreatingBranch] = useState(false);
26 const [newBranchName, setNewBranchName] = useState("");
27 const [isInitializingRepo, setIsInitializingRepo] = useState(false);
28 const branchMenuRef = useRef<HTMLDivElement>(null);
29
30 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
31 const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
32 const branchCwd = activeThread?.worktreePath ?? activeProject?.cwd;
33
34 const loadBranches = useCallback(async () => {
35 if (!api || !branchCwd) return;
36 setIsLoadingBranches(true);
37 try {
38 const result = await api.git.listBranches({ cwd: branchCwd });
39 setIsRepo(result.isRepo);
40 setBranches(result.branches);
41 } catch (error) {
42 setIsRepo(false);
43 setBranches([]);
44 if (activeThread) {
45 dispatch({
46 type: "SET_ERROR",
47 threadId: activeThread.id,
48 error:
49 error instanceof Error ? error.message : "Failed to load git branches.",
50 });
51 }
52 } finally {
53 setIsLoadingBranches(false);
54 }
55 }, [activeThread, api, branchCwd, dispatch]);
56
57 useEffect(() => {
58 void loadBranches();
59 }, [loadBranches]);
60
61 // Keep thread branch synced to git current branch for local threads.
62 useEffect(() => {
63 if (!activeThread || activeThread.worktreePath) return;
64 const current = branches.find((branch) => branch.current);
65 if (!current) return;
66 if (current.name === activeThread.branch) return;
67 dispatch({
68 type: "SET_THREAD_BRANCH",
69 threadId: activeThread.id,
70 branch: current.name,
71 worktreePath: null,
72 });
73 }, [activeThread, branches, dispatch]);
74
75 useEffect(() => {
76 if (!isBranchMenuOpen) return;
77 const handleClickOutside = (event: MouseEvent) => {
78 if (!branchMenuRef.current) return;
79 if (
80 event.target instanceof Node &&
81 !branchMenuRef.current.contains(event.target)
82 ) {
83 setIsBranchMenuOpen(false);
84 }
85 };
86 window.addEventListener("mousedown", handleClickOutside);
87 return () => {
88 window.removeEventListener("mousedown", handleClickOutside);
89 };
90 }, [isBranchMenuOpen]);
91
92 const setThreadError = (error: string | null) => {
93 if (!activeThread) return;
94 dispatch({
95 type: "SET_ERROR",
96 threadId: activeThread.id,
97 error,
98 });
99 };
100
101 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
102 if (!activeThread) return;
103 dispatch({
104 type: "SET_THREAD_BRANCH",
105 threadId: activeThread.id,
106 branch,
107 worktreePath,
108 });
109 };
110
111 const initializeRepo = async () => {
112 if (!api || !branchCwd || !activeThread || isInitializingRepo) return;
113 setIsInitializingRepo(true);
114 try {
115 await api.git.init({ cwd: branchCwd });
116 setThreadError(null);
117 await loadBranches();
118 } catch (error) {
119 setThreadError(error instanceof Error ? error.message : "Failed to initialize git repo.");
120 } finally {
121 setIsInitializingRepo(false);
122 }
123 };
124
125 const selectBranch = async (branch: GitBranch) => {
126 if (!api || !activeThread || !branchCwd) return;
127
128 // For new worktree mode, selecting a branch picks the base branch.
129 if (envMode === "worktree" && !envLocked && !activeThread.worktreePath) {
130 setThreadError(null);
131 setThreadBranch(branch.name, null);
132 setIsBranchMenuOpen(false);
133 return;
134 }
135
136 try {
137 await api.git.checkout({
138 cwd: branchCwd,
139 branch: branch.name,
140 });
141 setThreadError(null);
142 setThreadBranch(branch.name, activeThread.worktreePath);
143 setIsBranchMenuOpen(false);
144 await loadBranches();
145 } catch (error) {
146 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");
147 }
148 };
149
150 const createBranch = async () => {
151 const name = newBranchName.trim();
152 if (!api || !activeThread || !branchCwd || !name) return;
153 try {
154 await api.git.createBranch({ cwd: branchCwd, branch: name });
155 await api.git.checkout({ cwd: branchCwd, branch: name });
156 setThreadError(null);
157 setThreadBranch(name, activeThread.worktreePath);
158 setNewBranchName("");
159 setIsCreatingBranch(false);
160 setIsBranchMenuOpen(false);
161 await loadBranches();
162 } catch (error) {
163 setThreadError(error instanceof Error ? error.message : "Failed to create branch.");
164 }
165 };
166
167 if (!activeThread || !activeProject) return null;
168
169 return (
170 <div className="mx-auto flex max-w-3xl items-center justify-between px-5 py-2">
171 <div className="flex items-center gap-2">
172 {envLocked ? (
173 <span className="inline-flex items-center gap-1.5 px-1 text-[12px] text-muted-foreground/55">
174 {activeThread.worktreePath ? "Worktree" : "Local"}
175 </span>
176 ) : (
177 <button
178 type="button"
179 className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[12px] transition-colors duration-150 ${
180 envMode === "worktree"
181 ? "bg-accent text-foreground/80"
182 : "text-muted-foreground/60 hover:bg-accent/50 hover:text-muted-foreground/80"
183 }`}
184 onClick={() => onEnvModeChange(envMode === "local" ? "worktree" : "local")}
185 >
186 {envMode === "worktree" ? "New worktree" : "Local"}
187 </button>
188 )}
189 </div>
190
191 {!isRepo ? (
192 <button
193 type="button"
194 className="inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[12px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-50"
195 disabled={!branchCwd || isInitializingRepo}
196 onClick={() => void initializeRepo()}
197 >
198 {isInitializingRepo ? "Initializing…" : "Initialize git"}
199 </button>
200 ) : (
201 <div className="relative" ref={branchMenuRef}>
202 <button
203 type="button"
204 className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[12px] text-muted-foreground/60 transition-colors duration-150 hover:bg-accent/50 hover:text-muted-foreground/80"
205 onClick={() => setIsBranchMenuOpen((open) => !open)}
206 disabled={isLoadingBranches}
207 >
208 <span className="max-w-[240px] truncate font-mono">
209 {activeThread.branch
210 ? envMode === "worktree" && !envLocked
211 ? `From ${activeThread.branch}`
212 : activeThread.branch
213 : "Select branch"}
214 </span>
215 <svg
216 width="10"
217 height="10"
218 viewBox="0 0 10 10"
219 fill="none"
220 className="opacity-40"
221 aria-hidden="true"
222 >
223 <path
224 d="M2.5 4L5 6.5L7.5 4"
225 stroke="currentColor"
226 strokeWidth="1.2"
227 strokeLinecap="round"
228 strokeLinejoin="round"
229 />
230 </svg>
231 </button>
232 {isBranchMenuOpen && (
233 <div className="absolute bottom-full right-0 z-20 mb-2 w-[320px] rounded-2xl border border-border bg-popover/95 p-2 shadow-[0_16px_40px_rgba(0,0,0,0.55)] backdrop-blur">
234 <p className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/55">
235 Branch
236 </p>
237 <div className="max-h-64 overflow-y-auto">
238 {branches.map((branch) => {
239 const isSelected = branch.name === activeThread.branch;
240 return (
241 <button
242 key={branch.name}
243 type="button"
244 className={`mb-0.5 flex w-full items-center justify-between gap-2 rounded-xl px-2 py-1.5 text-left font-mono text-xs transition-colors duration-150 ${
245 isSelected
246 ? "bg-accent text-foreground"
247 : "text-foreground/90 hover:bg-accent/50"
248 }`}
249 onClick={() => void selectBranch(branch)}
250 >
251 <span className="truncate">{branch.name}</span>
252 {(branch.current || branch.isDefault) && (
253 <span className="shrink-0 text-[10px] text-muted-foreground/45">
254 {branch.current ? "current" : "default"}
255 </span>
256 )}
257 </button>
258 );
259 })}
260 </div>
261 {envMode === "local" && (
262 <>
263 <div className="mx-1 my-1 h-px bg-border" />
264 {isCreatingBranch ? (
265 <form
266 className="flex items-center gap-1 px-1"
267 onSubmit={(event) => {
268 event.preventDefault();
269 void createBranch();
270 }}
271 >
272 <input
273 type="text"
274 className="flex-1 rounded-lg border border-border bg-background px-2 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/30 focus:border-ring focus:outline-none"
275 placeholder="branch-name"
276 value={newBranchName}
277 onChange={(event) => setNewBranchName(event.target.value)}
278 onKeyDown={(event) => {
279 if (event.key === "Escape") {
280 setIsCreatingBranch(false);
281 setNewBranchName("");
282 }
283 }}
284 // biome-ignore lint/a11y/noAutofocus: branch name input should focus when shown
285 autoFocus
286 />
287 <button
288 type="submit"
289 disabled={!newBranchName.trim()}
290 className="rounded-lg px-2 py-1.5 text-xs text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground/80 disabled:opacity-30"
291 >
292 Create
293 </button>
294 </form>
295 ) : (
296 <button
297 type="button"
298 className="w-full rounded-lg px-2 py-1.5 text-left text-xs text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80"
299 onClick={() => setIsCreatingBranch(true)}
300 >
301 + New branch
302 </button>
303 )}
304 </>
305 )}
306 </div>
307 )}
308 </div>
309 )}
310 </div>
311 );
312}
apps/web/src/components/ChatView.tsx+52-1
......@@ -33,6 +33,7 @@ import {
3333 readNativeApi,
3434} from "../session-logic";
3535import { useStore } from "../store";
36import BranchToolbar from "./BranchToolbar";
3637import ChatMarkdown from "./ChatMarkdown";
3738
3839function formatMessageMeta(createdAt: string, duration: string | null): string {
......@@ -148,6 +149,7 @@ export default function ChatView() {
148149 });
149150 const [selectedEffort, setSelectedEffort] =
150151 useState<string>(DEFAULT_REASONING);
152 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
151153 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
152154 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>(
153155 [],
......@@ -265,6 +267,11 @@ export default function ChatView() {
265267 approvalPolicy: "on-request",
266268 sandboxMode: "workspace-write",
267269 } as const);
270 const envLocked = Boolean(
271 activeThread &&
272 (activeThread.messages.length > 0 ||
273 (activeThread.session !== null && activeThread.session.status !== "closed")),
274 );
268275
269276 const handleRuntimeModeChange = async (
270277 mode: "approval-required" | "full-access",
......@@ -309,6 +316,11 @@ export default function ChatView() {
309316 setExpandedWorkGroups({});
310317 }, [activeThread?.id]);
311318
319 useEffect(() => {
320 if (!activeThread) return;
321 setEnvMode(activeThread.worktreePath ? "worktree" : "local");
322 }, [activeThread]);
323
312324 // Auto-resize textarea
313325 useEffect(() => {
314326 const ta = textareaRef.current;
......@@ -450,6 +462,39 @@ export default function ChatView() {
450462 if (!api || !activeThread || isSending || isConnecting) return;
451463 const trimmed = prompt.trim();
452464 if (!trimmed) return;
465 if (!activeProject) return;
466
467 // On first message: lock in branch + create worktree if needed.
468 let sessionCwd: string | undefined;
469 if (
470 activeThread.messages.length === 0 &&
471 activeThread.branch &&
472 envMode === "worktree" &&
473 !activeThread.worktreePath
474 ) {
475 try {
476 const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
477 const result = await api.git.createWorktree({
478 cwd: activeProject.cwd,
479 branch: activeThread.branch,
480 newBranch,
481 });
482 sessionCwd = result.worktree.path;
483 dispatch({
484 type: "SET_THREAD_BRANCH",
485 threadId: activeThread.id,
486 branch: result.worktree.branch,
487 worktreePath: result.worktree.path,
488 });
489 } catch (err) {
490 dispatch({
491 type: "SET_ERROR",
492 threadId: activeThread.id,
493 error: err instanceof Error ? err.message : "Failed to create worktree",
494 });
495 return;
496 }
497 }
453498
454499 // Auto-title from first message
455500 if (activeThread.messages.length === 0) {
......@@ -475,7 +520,7 @@ export default function ChatView() {
475520 const previousMessages = activeThread.messages;
476521 setPrompt("");
477522
478 const sessionInfo = await ensureSession(activeThread.worktreePath ?? undefined);
523 const sessionInfo = await ensureSession(sessionCwd);
479524 if (!sessionInfo) return;
480525
481526 setIsSending(true);
......@@ -910,6 +955,12 @@ export default function ChatView() {
910955 )}
911956 </div>
912957
958 <BranchToolbar
959 envMode={envMode}
960 onEnvModeChange={setEnvMode}
961 envLocked={envLocked}
962 />
963
913964 {/* Input bar */}
914965 <div className="px-5 pb-4 pt-2">
915966 <form onSubmit={onSend} className="mx-auto max-w-3xl">
apps/web/src/components/Sidebar.tsx+2
......@@ -57,6 +57,8 @@ export default function Sidebar() {
5757 events: [],
5858 error: null,
5959 createdAt: new Date().toISOString(),
60 branch: null,
61 worktreePath: null,
6062 },
6163 });
6264 },
apps/web/src/store.test.ts+2
......@@ -39,6 +39,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
3939 events: [],
4040 error: null,
4141 createdAt: "2026-02-09T00:00:00.000Z",
42 branch: null,
43 worktreePath: null,
4244 ...overrides,
4345 };
4446}
apps/web/src/store.ts+16
......@@ -33,6 +33,12 @@ type Action =
3333 | { type: "SET_ERROR"; threadId: string; error: string | null }
3434 | { type: "SET_THREAD_TITLE"; threadId: string; title: string }
3535 | { type: "SET_THREAD_MODEL"; threadId: string; model: string }
36 | {
37 type: "SET_THREAD_BRANCH";
38 threadId: string;
39 branch: string | null;
40 worktreePath: string | null;
41 }
3642 | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode };
3743
3844// ── State ────────────────────────────────────────────────────────────
......@@ -369,6 +375,16 @@ export function reducer(state: AppState, action: Action): AppState {
369375 })),
370376 };
371377
378 case "SET_THREAD_BRANCH":
379 return {
380 ...state,
381 threads: updateThread(state.threads, action.threadId, (t) => ({
382 ...t,
383 branch: action.branch,
384 worktreePath: action.worktreePath,
385 })),
386 };
387
372388 case "SET_RUNTIME_MODE":
373389 return {
374390 ...state,
apps/web/src/wsNativeApi.ts+10
......@@ -95,6 +95,16 @@ export function createWsNativeApi(): NativeApi {
9595 openInEditor: (cwd, editor) =>
9696 transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),
9797 },
98 git: {
99 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
100 createWorktree: (input) =>
101 transport.request(WS_METHODS.gitCreateWorktree, input),
102 removeWorktree: (input) =>
103 transport.request(WS_METHODS.gitRemoveWorktree, input),
104 createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),
105 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),
106 init: (input) => transport.request(WS_METHODS.gitInit, input),
107 },
98108 };
99109
100110 instance = { api, transport };
packages/contracts/src/ws.ts+8
......@@ -19,6 +19,14 @@ export const WS_METHODS = {
1919 // Shell methods
2020 shellOpenInEditor: "shell.openInEditor",
2121
22 // Git methods
23 gitListBranches: "git.listBranches",
24 gitCreateWorktree: "git.createWorktree",
25 gitRemoveWorktree: "git.removeWorktree",
26 gitCreateBranch: "git.createBranch",
27 gitCheckout: "git.checkout",
28 gitInit: "git.init",
29
2230 // Server meta
2331 serverGetConfig: "server.getConfig",
2432} as const;