| author | |
| committer | |
| log | c13e9f89a977518a07426a68df48603127fc2ec7 |
| tree | 881ae3fd5649587a84e0f254eda0c46fbce36437 |
| parent | d1ecacda43718b0a96e9fca1cd7d30f82a64a574 |
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 @@ |
| 1 | import { existsSync } from "node:fs"; | |
| 2 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | import { describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { | |
| 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). */ | |
| 20 | async 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. */ | |
| 33 | async 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. */ | |
| 44 | async 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 | ||
| 55 | describe("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 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | import 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 | ||
| 17 | function escapeSingleQuotes(value: string): string { | |
| 18 | return value.replace(/'/g, "'\\''"); | |
| 19 | } | |
| 20 | ||
| 21 | export 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 | ||
| 81 | export 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 | ||
| 127 | export 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 | ||
| 155 | export 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 | ||
| 169 | export 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 | ||
| 183 | export 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 | ||
| 197 | export 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", () => { |
| 281 | 281 | expect(afterRemove.result).toEqual([]); |
| 282 | 282 | }); |
| 283 | 283 | |
| 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 | ||
| 284 | 310 | it("prunes missing projects on startup", async () => { |
| 285 | 311 | const stateDir = makeTempDir("t3code-ws-prune-state-"); |
| 286 | 312 | const existing = makeTempDir("t3code-ws-existing-project-"); |
apps/server/src/wsServer.ts+26| ... | ... | @@ -19,6 +19,14 @@ import { WebSocketServer, type WebSocket } from "ws"; |
| 19 | 19 | import { createLogger } from "./logger"; |
| 20 | 20 | import { ProjectRegistry } from "./projectRegistry"; |
| 21 | 21 | import { ProviderManager } from "./providerManager"; |
| 22 | import { | |
| 23 | checkoutGitBranch, | |
| 24 | createGitBranch, | |
| 25 | createGitWorktree, | |
| 26 | initGitRepo, | |
| 27 | listGitBranches, | |
| 28 | removeGitWorktree, | |
| 29 | } from "./git"; | |
| 22 | 30 | |
| 23 | 31 | const MIME_TYPES: Record<string, string> = { |
| 24 | 32 | ".html": "text/html; charset=utf-8", |
| ... | ... | @@ -304,6 +312,24 @@ export function createServer(options: ServerOptions) { |
| 304 | 312 | return undefined; |
| 305 | 313 | } |
| 306 | 314 | |
| 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 | ||
| 307 | 333 | case WS_METHODS.serverGetConfig: |
| 308 | 334 | return { cwd }; |
| 309 | 335 |
apps/server/tsconfig.json+1-1| ... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | "compilerOptions": { |
| 4 | 4 | "composite": true, |
| 5 | 5 | "types": ["node"], |
| 6 | "lib": ["ES2023"] | |
| 6 | "lib": ["ES2023", "esnext.disposable"] | |
| 7 | 7 | }, |
| 8 | 8 | "include": ["src", "tsup.config.ts"] |
| 9 | 9 | } |
apps/web/src/App.tsx+2| ... | ... | @@ -82,6 +82,8 @@ function AutoProjectBootstrap() { |
| 82 | 82 | events: [], |
| 83 | 83 | error: null, |
| 84 | 84 | createdAt: new Date().toISOString(), |
| 85 | branch: null, | |
| 86 | worktreePath: null, | |
| 85 | 87 | }, |
| 86 | 88 | }); |
| 87 | 89 | }); |
apps/web/src/components/BranchToolbar.tsx created+312| ... | ... | @@ -0,0 +1,312 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | |
| 2 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | ||
| 4 | import { readNativeApi } from "../session-logic"; | |
| 5 | import { useStore } from "../store"; | |
| 6 | ||
| 7 | interface BranchToolbarProps { | |
| 8 | envMode: "local" | "worktree"; | |
| 9 | onEnvModeChange: (mode: "local" | "worktree") => void; | |
| 10 | envLocked: boolean; | |
| 11 | } | |
| 12 | ||
| 13 | export 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 { |
| 33 | 33 | readNativeApi, |
| 34 | 34 | } from "../session-logic"; |
| 35 | 35 | import { useStore } from "../store"; |
| 36 | import BranchToolbar from "./BranchToolbar"; | |
| 36 | 37 | import ChatMarkdown from "./ChatMarkdown"; |
| 37 | 38 | |
| 38 | 39 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| ... | ... | @@ -148,6 +149,7 @@ export default function ChatView() { |
| 148 | 149 | }); |
| 149 | 150 | const [selectedEffort, setSelectedEffort] = |
| 150 | 151 | useState<string>(DEFAULT_REASONING); |
| 152 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); | |
| 151 | 153 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| 152 | 154 | const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>( |
| 153 | 155 | [], |
| ... | ... | @@ -265,6 +267,11 @@ export default function ChatView() { |
| 265 | 267 | approvalPolicy: "on-request", |
| 266 | 268 | sandboxMode: "workspace-write", |
| 267 | 269 | } as const); |
| 270 | const envLocked = Boolean( | |
| 271 | activeThread && | |
| 272 | (activeThread.messages.length > 0 || | |
| 273 | (activeThread.session !== null && activeThread.session.status !== "closed")), | |
| 274 | ); | |
| 268 | 275 | |
| 269 | 276 | const handleRuntimeModeChange = async ( |
| 270 | 277 | mode: "approval-required" | "full-access", |
| ... | ... | @@ -309,6 +316,11 @@ export default function ChatView() { |
| 309 | 316 | setExpandedWorkGroups({}); |
| 310 | 317 | }, [activeThread?.id]); |
| 311 | 318 | |
| 319 | useEffect(() => { | |
| 320 | if (!activeThread) return; | |
| 321 | setEnvMode(activeThread.worktreePath ? "worktree" : "local"); | |
| 322 | }, [activeThread]); | |
| 323 | ||
| 312 | 324 | // Auto-resize textarea |
| 313 | 325 | useEffect(() => { |
| 314 | 326 | const ta = textareaRef.current; |
| ... | ... | @@ -450,6 +462,39 @@ export default function ChatView() { |
| 450 | 462 | if (!api || !activeThread || isSending || isConnecting) return; |
| 451 | 463 | const trimmed = prompt.trim(); |
| 452 | 464 | 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 | } | |
| 453 | 498 | |
| 454 | 499 | // Auto-title from first message |
| 455 | 500 | if (activeThread.messages.length === 0) { |
| ... | ... | @@ -475,7 +520,7 @@ export default function ChatView() { |
| 475 | 520 | const previousMessages = activeThread.messages; |
| 476 | 521 | setPrompt(""); |
| 477 | 522 | |
| 478 | const sessionInfo = await ensureSession(activeThread.worktreePath ?? undefined); | |
| 523 | const sessionInfo = await ensureSession(sessionCwd); | |
| 479 | 524 | if (!sessionInfo) return; |
| 480 | 525 | |
| 481 | 526 | setIsSending(true); |
| ... | ... | @@ -910,6 +955,12 @@ export default function ChatView() { |
| 910 | 955 | )} |
| 911 | 956 | </div> |
| 912 | 957 | |
| 958 | <BranchToolbar | |
| 959 | envMode={envMode} | |
| 960 | onEnvModeChange={setEnvMode} | |
| 961 | envLocked={envLocked} | |
| 962 | /> | |
| 963 | ||
| 913 | 964 | {/* Input bar */} |
| 914 | 965 | <div className="px-5 pb-4 pt-2"> |
| 915 | 966 | <form onSubmit={onSend} className="mx-auto max-w-3xl"> |
apps/web/src/components/Sidebar.tsx+2| ... | ... | @@ -57,6 +57,8 @@ export default function Sidebar() { |
| 57 | 57 | events: [], |
| 58 | 58 | error: null, |
| 59 | 59 | createdAt: new Date().toISOString(), |
| 60 | branch: null, | |
| 61 | worktreePath: null, | |
| 60 | 62 | }, |
| 61 | 63 | }); |
| 62 | 64 | }, |
apps/web/src/store.test.ts+2| ... | ... | @@ -39,6 +39,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 39 | 39 | events: [], |
| 40 | 40 | error: null, |
| 41 | 41 | createdAt: "2026-02-09T00:00:00.000Z", |
| 42 | branch: null, | |
| 43 | worktreePath: null, | |
| 42 | 44 | ...overrides, |
| 43 | 45 | }; |
| 44 | 46 | } |
apps/web/src/store.ts+16| ... | ... | @@ -33,6 +33,12 @@ type Action = |
| 33 | 33 | | { type: "SET_ERROR"; threadId: string; error: string | null } |
| 34 | 34 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } |
| 35 | 35 | | { 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 | } | |
| 36 | 42 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; |
| 37 | 43 | |
| 38 | 44 | // ── State ──────────────────────────────────────────────────────────── |
| ... | ... | @@ -369,6 +375,16 @@ export function reducer(state: AppState, action: Action): AppState { |
| 369 | 375 | })), |
| 370 | 376 | }; |
| 371 | 377 | |
| 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 | ||
| 372 | 388 | case "SET_RUNTIME_MODE": |
| 373 | 389 | return { |
| 374 | 390 | ...state, |
apps/web/src/wsNativeApi.ts+10| ... | ... | @@ -95,6 +95,16 @@ export function createWsNativeApi(): NativeApi { |
| 95 | 95 | openInEditor: (cwd, editor) => |
| 96 | 96 | transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), |
| 97 | 97 | }, |
| 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 | }, | |
| 98 | 108 | }; |
| 99 | 109 | |
| 100 | 110 | instance = { api, transport }; |
packages/contracts/src/ws.ts+8| ... | ... | @@ -19,6 +19,14 @@ export const WS_METHODS = { |
| 19 | 19 | // Shell methods |
| 20 | 20 | shellOpenInEditor: "shell.openInEditor", |
| 21 | 21 | |
| 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 | ||
| 22 | 30 | // Server meta |
| 23 | 31 | serverGetConfig: "server.getConfig", |
| 24 | 32 | } as const; |