# Files changed · PR \#3 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/3/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[Git integration: branch picker \+ worktrees](https://git.cafe/pingdotgg/t3code/pull/3.md)

Version: 16

Head commit: 94420192359d68c03241f03f47cf8d670abafa15

Base commit: 169d06f05b543433278a123b3512fddad05f9f2a

Comparison base: 169d06f05b543433278a123b3512fddad05f9f2a

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## \.plans/branch-environment-picker-in-chatview-input.md

Status: added; +74; −0

````diff
@@ -1,0 +1,74 @@
+# Branch/Environment Picker in ChatView Input
+
+## Summary
+
+Add a secondary toolbar below the ChatView input area (similar to Codex UI) that lets users select the target branch and environment mode (Local vs New worktree) before sending their first message.
+
+## UX
+
+- A toolbar appears **below** the input form (always visible when it's a git repo)
+- Two controls:
+  1. **Environment mode** (left side): toggles between "Local" and "New worktree" — **locked after first message** (no longer clickable, just shows current mode as label)
+  2. **Branch picker** (right side): dropdown showing local branches — **always changeable**, even after messages are sent
+- If not a git repo, the toolbar is hidden entirely (thread uses project cwd as-is)
+
+## Changes
+
+### 0. Install `@tanstack/react-query` in `apps/renderer`
+
+Add dependency + wrap app in `QueryClientProvider`.
+
+### 1. `apps/renderer/src/store.ts` — MODIFY
+
+Add a new action to the reducer:
+
+```ts
+| { type: "SET_THREAD_BRANCH"; threadId: string; branch: string | null; worktreePath: string | null }
+```
+
+Reducer case updates `branch` and `worktreePath` on the thread.
+
+### 2. `apps/renderer/src/components/ChatView.tsx` — MODIFY
+
+**Fetch branches** via `useQuery`:
+
+```ts
+const branchQuery = useQuery({
+  queryKey: ["git-branches", activeProject?.cwd],
+  queryFn: () => api.git.listBranches({ cwd: activeProject!.cwd }),
+  enabled: !!activeProject,
+});
+```
+
+**Local state:**
+
+- `envMode: "local" | "worktree"` — environment mode (local component state)
+
+**UI:** Below the `<form>`, render a toolbar bar (hidden if `!branchQuery.data?.isRepo`):
+
+- Left side: env mode button ("Local" / "New worktree") — disabled after first message (locked in)
+- Right side: branch dropdown from `branchQuery.data.branches`
+- Both styled like existing model picker (small text, chevron, dropdown menus)
+
+**Behavior:**
+
+- Branch picker is always active — changing branch dispatches `SET_THREAD_BRANCH` immediately
+- Env mode is only clickable when `activeThread.messages.length === 0`. After first message, it becomes a static label showing the locked-in mode
+- On first send (`onSend`): if `envMode === "worktree"` and a branch is selected, call `api.git.createWorktree` before starting the session, then dispatch `SET_THREAD_BRANCH` with the worktreePath
+- `ensureSession` already uses `activeThread.worktreePath ?? activeProject.cwd`
+
+### Files to modify
+
+1. `apps/renderer/package.json` — add `@tanstack/react-query`
+2. `apps/renderer/src/main.tsx` (or App entry) — wrap in `QueryClientProvider`
+3. `apps/renderer/src/store.ts` — add `SET_THREAD_BRANCH` action
+4. `apps/renderer/src/components/ChatView.tsx` — branch/env picker UI with `useQuery`
+
+## Verification
+
+1. `turbo build` — compiles
+2. Create a new thread → branch bar appears below input with "Local" + current branch
+3. Change branch in dropdown → branch updates on thread
+4. Toggle "New worktree" → send message → worktree created, session uses worktree cwd
+5. After first message: env mode label locks to "Worktree" (not clickable), branch picker still works
+6. Non-git project → no branch bar shown
````

## \.plans/git-flows-integration-tests.md

Status: added; +99; −0

````diff
@@ -1,0 +1,99 @@
+# Git Flows Integration Tests
+
+## Overview
+
+Real integration tests that run actual git commands against temporary repos. No mocking.
+
+## Step 1: Extract git functions into `apps/desktop/src/git.ts`
+
+The git functions (`listGitBranches`, `createGitWorktree`, `removeGitWorktree`, `createGitBranch`, `checkoutGitBranch`, `initGitRepo`) and their helper `runTerminalCommand` are currently private in `main.ts`. Extract them into a new `apps/desktop/src/git.ts` module with named exports.
+
+`main.ts` will import and re-use them — no behavior change, just moving code.
+
+**Files modified:**
+
+- `apps/desktop/src/git.ts` — new file with all git functions exported
+- `apps/desktop/src/main.ts` — import from `./git` instead of defining inline
+
+## Step 2: Create `apps/desktop/src/git.test.ts`
+
+Integration tests using real temp git repos. Each test group creates a fresh temp directory with `git init`, makes commits, creates branches as needed, and cleans up after.
+
+### Setup/teardown pattern
+
+```ts
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+  listGitBranches,
+  createGitBranch,
+  checkoutGitBranch,
+  createGitWorktree,
+  removeGitWorktree,
+  initGitRepo,
+} from "./git";
+
+// Helper: run a raw git command in a dir (for test setup, not under test)
+// Helper: create an initial commit (git needs at least one commit for branches)
+```
+
+### Test groups
+
+**1. initGitRepo**
+
+- Creates a valid git repo in a temp dir
+- listGitBranches reports `isRepo: true` after init
+
+**2. listGitBranches**
+
+- Returns `isRepo: false` for non-git directory
+- Returns the current branch with `current: true`
+- Sorts current branch first
+- Lists multiple branches after creating them
+- `isDefault` is false when no remote (no origin/HEAD)
+
+**3. checkoutGitBranch**
+
+- Checks out an existing branch (current flag moves)
+- Throws when branch doesn't exist
+- Throws when checkout would overwrite uncommitted changes (dirty working tree)
+
+**4. createGitBranch**
+
+- Creates a new branch (appears in listGitBranches)
+- Throws when branch already exists
+
+**5. createGitWorktree + removeGitWorktree**
+
+- Creates a worktree directory at the expected path
+- Worktree has the correct branch checked out
+- Throws when branch is already checked out in another worktree
+- removeGitWorktree cleans up the worktree
+
+**6. Full flow: local branch checkout**
+
+- init → commit → create branch → checkout → verify current
+
+**7. Full flow: worktree creation from selected branch**
+
+- init → commit → create branch → create worktree → verify worktree dir exists and has correct branch
+
+**8. Full flow: thread switching simulation**
+
+- init → commit → create branch-a, branch-b → checkout a → checkout b → checkout a → verify current matches
+
+**9. Full flow: checkout conflict**
+
+- init → commit → create branch → modify file (unstaged) → checkout other branch → expect error
+
+## Verification
+
+```bash
+# Run the git integration tests
+cd apps/desktop && bun run test
+
+# Or just the git test file
+npx vitest run apps/desktop/src/git.test.ts
+```
````

## \.plans/git-flows-test-plan.md

Status: added; +103; −0

````diff
@@ -1,0 +1,103 @@
+# Git Flows Test Plan
+
+## Overview
+
+Add tests for git branch/worktree flows. Two files:
+
+1. **Extend** `apps/renderer/src/store.test.ts` — reducer tests for `SET_THREAD_BRANCH`
+2. **Create** `apps/renderer/src/git-flows.test.ts` — flow logic tests
+
+All tests are pure Vitest unit tests (no React rendering). They test the reducer directly and simulate handler logic via sequential reducer dispatches + mocked API calls.
+
+## File 1: `apps/renderer/src/store.test.ts` (extend)
+
+Add `describe("SET_THREAD_BRANCH reducer")` with 6 tests:
+
+- Sets branch + worktreePath atomically
+- Clears both to null
+- Updates branch while preserving worktreePath
+- Does not affect other threads (multi-thread state)
+- No-op for nonexistent thread id
+- Does not mutate messages, error, or session fields
+
+Uses existing `makeThread`, `makeState` factories.
+
+## File 2: `apps/renderer/src/git-flows.test.ts` (new)
+
+### Factories
+
+- `makeThread()`, `makeState()`, `makeSession()` — same pattern as store.test.ts
+- `makeBranch()` — creates `GitBranch` objects
+- `makeMessage()` — creates `ChatMessage` objects
+- `makeGitApi()` — returns `{ checkout, createWorktree, createBranch, listBranches }` with `vi.fn()` mocks
+
+### Test groups (~30 tests total)
+
+**1. Local branch checkout flow** (2 tests)
+
+- Successful checkout → SET_THREAD_BRANCH updates branch
+- Checkout failure → SET_ERROR, branch unchanged
+
+**2. Thread branch conflict on send** (3 tests)
+
+- Two threads maintain independent branch state after SET_ACTIVE_THREAD
+- Branch state preserved through multiple thread switches + updates
+- Checkout failure on thread switch sets error only on target thread
+
+**3. Worktree creation on send** (5 tests)
+
+- First message in worktree mode → createWorktree → SET_THREAD_BRANCH with worktreePath
+- No worktree when messages already exist
+- No worktree in local envMode
+- No worktree when worktreePath already set
+- createWorktree failure → SET_ERROR, send aborted, no messages pushed
+
+**4. Env mode locking** (4 tests)
+
+- envLocked=false when no messages
+- envLocked=true with messages
+- Transitions false→true after PUSH_USER_MESSAGE
+- Remains true after SET_ERROR and UPDATE_SESSION
+
+**5. Auto-fill current branch** (3 tests)
+
+- Dispatches SET_THREAD_BRANCH when thread has no branch and current branch exists
+- Does not overwrite existing branch
+- No-op when no branch is marked current
+
+**6. Default branch detection** (2 tests)
+
+- isDefault flag on branch objects
+- current and isDefault can be on different branches
+
+**7. Branch creation + checkout** (3 tests)
+
+- Successful create + checkout updates branch
+- createBranch failure → error, branch unchanged
+- checkout failure after successful create → error, branch unchanged
+
+**8. Session CWD resolution** (3 tests)
+
+- Uses worktreePath when available
+- cwdOverride takes precedence over worktreePath
+- Falls back to project cwd when no worktree
+
+**9. Error handling patterns** (4 tests)
+
+- SET_ERROR sets error on correct thread
+- SET_ERROR with null clears error
+- Error on one thread doesn't affect others
+- Error cleared before successful branch operations
+
+## Verification
+
+```bash
+# Run all renderer tests
+cd apps/renderer && bun run test
+
+# Run just the new test file
+npx vitest run apps/renderer/src/git-flows.test.ts
+
+# Run just the store tests
+npx vitest run apps/renderer/src/store.test.ts
+```
````

## \.plans/git-integration-branch-picker-worktrees.md

Status: added; +115; −0

````diff
@@ -1,0 +1,115 @@
+# Git Integration: Branch Picker + Worktrees
+
+## Summary
+
+Add git integration to let users start new threads from a specific branch, optionally creating a git worktree for isolated agent work.
+
+## UX Flow
+
+- **Left click** "+ New thread" → immediately creates a thread (current behavior, unchanged)
+- **Right click** "+ New thread" → opens a context menu with git options:
+  - List of local branches → clicking one creates a thread on that branch (uses project cwd)
+  - Each branch has a "worktree" sub-option → creates a worktree, then creates thread with worktree as cwd
+- When thread has a worktree, the agent session uses the worktree path as its cwd
+- If git fails (not a repo), context menu shows "Not a git repository" disabled item
+
+## Changes
+
+### 1. `packages/contracts/src/git.ts` — CREATE
+
+New Zod schemas and types:
+
+- `gitListBranchesInputSchema` — `{ cwd: string }`
+- `gitCreateWorktreeInputSchema` — `{ cwd: string, branch: string, path?: string }`
+- `gitRemoveWorktreeInputSchema` — `{ cwd: string, path: string }`
+- `gitBranchSchema` — `{ name: string, current: boolean }`
+- Result types for each
+
+### 2. `packages/contracts/src/ipc.ts` — MODIFY
+
+- Add 3 IPC channels: `git:list-branches`, `git:create-worktree`, `git:remove-worktree`
+- Add `git` namespace to `NativeApi` with `listBranches`, `createWorktree`, `removeWorktree`
+
+### 3. `packages/contracts/src/index.ts` — MODIFY
+
+- Add `export * from "./git"`
+
+### 4. `apps/desktop/src/main.ts` — MODIFY
+
+Add 3 IPC handlers + helper functions:
+
+- `listGitBranches()` — runs `git branch --no-color`, parses output into `{ name, current }[]`
+- `createGitWorktree()` — runs `git worktree add <path> <branch>`, defaults path to `../{repo}-worktrees/{branch}`
+- `removeGitWorktree()` — runs `git worktree remove <path>`
+
+Reuses existing `runTerminalCommand()`.
+
+### 5. `apps/desktop/src/preload.ts` — MODIFY
+
+Add `git` namespace with 3 `ipcRenderer.invoke` calls.
+
+### 6. `apps/renderer/src/types.ts` — MODIFY
+
+Add to `Thread`:
+
+```
+branch: string | null
+worktreePath: string | null
+```
+
+### 7. `apps/renderer/src/persistenceSchema.ts` — MODIFY
+
+- Add optional `branch`/`worktreePath` to persisted thread schema (`.nullable().optional()` for backwards compat)
+- Add V3 schema, update union
+- Update `hydrateThread` to default new fields to `null`
+- Update `toPersistedState` to serialize new fields
+
+### 8. `apps/renderer/src/store.ts` — MODIFY
+
+- Update persisted state key to v3, keep v2 as legacy fallback
+
+### 9. `apps/renderer/src/components/Sidebar.tsx` — MODIFY (main UI work)
+
+- Keep existing left-click `handleNewThread` unchanged (immediate thread creation)
+- Add `onContextMenu` handler to "+ New thread" buttons (both global and per-project)
+- On right-click: fetch branches via `api.git.listBranches`, show a custom context menu
+- Context menu items: branch names, each with a nested option to create with worktree
+- Clicking a branch → creates thread with `branch` set, title = branch name
+- Clicking "with worktree" → calls `api.git.createWorktree` first, then creates thread with `worktreePath`
+- Show branch badge on thread list items
+- If not a git repo, show "Not a git repository" as disabled menu item
+
+Context menu component: a positioned `<div>` with `position: fixed` anchored to the click position, dismissed on click-outside or Escape. Follows the existing dropdown pattern from ChatView's model picker.
+
+### 10. `apps/renderer/src/components/ChatView.tsx` — MODIFY
+
+- Line 157: use `activeThread.worktreePath ?? activeProject.cwd` as session cwd
+- Show branch/worktree badge in header bar
+
+## Implementation Order
+
+1. `packages/contracts/src/git.ts` (new schemas)
+2. `packages/contracts/src/ipc.ts` + `index.ts` (wire up channels)
+3. `apps/desktop/src/main.ts` (git command handlers)
+4. `apps/desktop/src/preload.ts` (bridge methods)
+5. `apps/renderer/src/types.ts` (Thread type update)
+6. `apps/renderer/src/persistenceSchema.ts` + `store.ts` (persistence migration)
+7. `apps/renderer/src/components/Sidebar.tsx` (branch picker UI)
+8. `apps/renderer/src/components/ChatView.tsx` (worktree cwd + badge)
+
+## Edge Cases
+
+- **Not a git repo**: `git branch` fails → context menu shows "Not a git repository" disabled item
+- **Branch has slashes**: `feature/foo` → worktree dir becomes `feature-foo`
+- **Worktree exists**: git error surfaces to user via inline error message in context menu
+- **No persistence breakage**: `.nullable().optional()` fields parse fine with old data
+
+## Verification
+
+1. `turbo build` — confirm contracts/desktop/renderer all compile
+2. Launch app, add a project pointing to a git repo
+3. Click "+ New thread" → verify branch list loads
+4. Select a branch, click Start → thread created with branch in title
+5. Enable worktree checkbox, pick branch, Start → verify worktree directory created on disk
+6. Send a message in worktree thread → verify agent runs in worktree cwd
+7. Add a non-git project → verify graceful error, can still create thread
````

## AGENTS.md

Status: modified; +7; −0

```diff
@@ -1,11 +1,13 @@
 # CLAUDE.md
 
 ## Project Snapshot
+
 T3 Code is a minimal web GUI for using code agents like Codex and Claude Code (coming soon).
 
 This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged.
 
 ## Core Priorities
+
 1. Performance first.
 2. Reliability first.
 3. Keep behavior predictable under load and during failures (session restarts, reconnects, partial streams).
@@ -13,23 +15,28 @@
 If a tradeoff is required, choose correctness and robustness over short-term convenience.
 
 ## Package Roles
+
 - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.
 - `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket.
 - `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types.
 
 ## Codex App Server (Important)
+
 T3 Code is currently Codex-first. The server starts `codex app-server` (JSON-RPC over stdio) per provider session, then streams structured events to the browser through WebSocket push messages.
 
 How we use it in this codebase:
+
 - Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`.
 - Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`.
 - WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`.
 - Web app consumes provider event streams via WebSocket push on channel `providers.event`.
 
 Docs:
+
 - Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server
 
 ## Reference Repos
+
 - Open-source Codex repo: https://github.com/openai/codex
 - Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor
 
```

## README.md

Status: modified; +2; −2

```diff
@@ -59,8 +59,8 @@
 
 ## Scripts
 
-- `bun run dev` — Starts contracts, server, and web dev tasks via Turborepo's parallel task runner.
-- `bun run dev:server` — Starts just the WebSocket server (uses tsx for TS execution).
+- `bun run dev` — Starts contracts, server, and web in `turbo watch` mode.
+- `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution).
 - `bun run dev:web` — Starts just the Vite dev server for the web app.
 - `bun run start` — Runs the production server (serves built web app as static files).
 - `bun run build` — Builds contracts, web app, and server through Turbo.
```

## apps/desktop/package.json

Status: modified; +5; −6

```diff
@@ -4,10 +4,10 @@
   "private": true,
   "main": "dist-electron/main.js",
   "scripts": {
-    "dev": "bun run --cwd ../server build && concurrently -k -n BUNDLE,WEB,ELECTRON \"bun run dev:bundle\" \"bun run --cwd ../web dev\" \"bun run dev:electron\"",
+    "dev": "bun run --parallel dev:bundle dev:electron",
-    "dev:bundle": "tsup --watch",
+    "dev:bundle": "tsdown --watch",
     "dev:electron": "bun run scripts/dev-electron.mjs",
+    "build": "tsdown",
-    "build": "tsup",
     "start": "electron dist-electron/main.js",
     "typecheck": "tsc --noEmit",
     "test": "vitest run --passWithNoTests",
@@ -18,11 +18,10 @@
   },
   "devDependencies": {
     "@types/node": "^22.10.2",
-    "concurrently": "^9.1.2",
     "electronmon": "^2.0.2",
+    "tsdown": "^0.20.3",
-    "tsup": "^8.3.5",
     "typescript": "^5.7.3",
-    "vitest": "^3.0.0",
+    "vitest": "^4.0.0",
     "wait-on": "^8.0.2"
   }
 }
```

## apps/desktop/scripts/dev-electron.mjs

Status: modified; +1; −1

```diff
@@ -10,7 +10,7 @@
     `tcp:${port}`,
     "file:dist-electron/main.js",
     "file:dist-electron/preload.js",
-    "file:../server/dist/index.js",
+    "file:../server/dist/index.mjs",
   ],
 });
 
```

## apps/desktop/scripts/smoke-test.mjs

Status: modified; +1; −5

```diff
@@ -1,16 +1,12 @@
-import { execSync, spawn } from "node:child_process";
+import { spawn } from "node:child_process";
 import { dirname, resolve } from "node:path";
 import { fileURLToPath } from "node:url";
 
 const __dirname = dirname(fileURLToPath(import.meta.url));
 const desktopDir = resolve(__dirname, "..");
-const rootDir = resolve(__dirname, "../../..");
 const electronBin = resolve(desktopDir, "node_modules/.bin/electron");
 const mainJs = resolve(desktopDir, "dist-electron/main.js");
 
-console.log("Building contracts + web + server + desktop...");
-execSync("bun run build", { cwd: rootDir, stdio: "inherit" });
-
 console.log("\nLaunching Electron smoke test...");
 
 const child = spawn(electronBin, [mainJs], {
```

## apps/desktop/src/main.ts

Status: modified; +2; −1

```diff
@@ -14,7 +14,7 @@
 const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
 const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
 const ROOT_DIR = path.resolve(__dirname, "../../..");
-const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js");
+const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");
 const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
 const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");
 const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
@@ -186,6 +186,7 @@
 
   if (isDevelopment) {
     void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
+    window.webContents.openDevTools({ mode: "detach" });
   } else {
     if (!fs.existsSync(WEB_ENTRY)) {
       throw new Error(`Web bundle missing at ${WEB_ENTRY}`);
```

## apps/desktop/tsconfig.json

Status: modified; +2; −2

```diff
@@ -3,7 +3,7 @@
   "compilerOptions": {
     "composite": true,
     "types": ["node", "electron"],
-    "lib": ["ES2023", "DOM"]
+    "lib": ["ES2023", "DOM", "esnext.disposable"]
   },
-  "include": ["src", "tsup.config.ts"]
+  "include": ["src", "tsdown.config.ts"]
 }
```

## apps/desktop/tsdown.config.ts

Status: added; +21; −0

```diff
@@ -1,0 +1,21 @@
+import { defineConfig } from "tsdown";
+
+const shared = {
+  format: "cjs" as const,
+  outDir: "dist-electron",
+  sourcemap: true,
+  outExtensions: () => ({ js: ".js" }),
+};
+
+export default defineConfig([
+  {
+    ...shared,
+    entry: ["src/main.ts"],
+    clean: true,
+    noExternal: ["@t3tools/contracts"],
+  },
+  {
+    ...shared,
+    entry: ["src/preload.ts"],
+  },
+]);
```

## apps/desktop/tsup.config.ts

Status: deleted; +0; −10

```diff
@@ -1,10 +1,0 @@
-import { defineConfig } from "tsup";
-
-export default defineConfig({
-  entry: ["src/main.ts", "src/preload.ts"],
-  format: "cjs",
-  outDir: "dist-electron",
-  sourcemap: true,
-  clean: true,
-  noExternal: ["@t3tools/contracts"],
-});
```

## apps/desktop/turbo.jsonc

Status: added; +24; −0

```diff
@@ -1,0 +1,24 @@
+{
+  "$schema": "https://turbo.build/schema.json",
+  "extends": ["//"],
+  "tasks": {
+    "build": {
+      "dependsOn": ["^build"],
+      "outputs": ["dist-electron/**"],
+    },
+    "dev": {
+      "dependsOn": ["t3#build"],
+      "persistent": true,
+    },
+    "start": {
+      "dependsOn": ["build", "@t3tools/web#build", "t3#build"],
+      "cache": false,
+      "persistent": true,
+    },
+    "smoke-test": {
+      "dependsOn": ["build", "@t3tools/web#build", "t3#build"],
+      "cache": false,
+      "outputs": [],
+    },
+  },
+}
```

## apps/server/package.json

Status: modified; +6; −9

```diff
@@ -2,19 +2,17 @@
   "name": "t3",
   "version": "0.1.0",
   "bin": {
-    "t3": "./dist/index.js"
+    "t3": "./dist/index.mjs"
   },
   "files": [
     "dist"
   ],
   "type": "module",
-  "main": "./dist/index.js",
+  "main": "./dist/index.mjs",
   "scripts": {
-    "dev": "VITE_DEV_SERVER_URL=http://localhost:5173 tsx src/index.ts",
+    "dev": "VITE_DEV_SERVER_URL=http://localhost:5173 bun run src/index.ts",
+    "build": "tsdown && node scripts/bundle-client.mjs",
+    "start": "node dist/index.mjs",
-    "dev:desktop": "T3CODE_MODE=desktop T3CODE_NO_BROWSER=1 tsx src/index.ts",
-    "build": "tsup && node scripts/bundle-client.mjs",
-    "start": "node dist/index.js",
-    "start:desktop": "T3CODE_MODE=desktop T3CODE_NO_BROWSER=1 node dist/index.js",
     "typecheck": "tsc --noEmit",
     "test": "vitest run"
   },
@@ -27,8 +25,7 @@
     "@t3tools/web": "workspace:*",
     "@types/node": "^22.10.2",
     "@types/ws": "^8.5.13",
-    "tsup": "^8.3.5",
+    "tsdown": "^0.20.3",
-    "tsx": "^4.19.0",
     "typescript": "^5.7.3"
   }
 }
```

## apps/server/src/codexAppServerManager.test.ts

Status: modified; +2; −6

```diff
@@ -57,17 +57,13 @@
 describe("isRecoverableThreadResumeError", () => {
   it("matches not-found resume errors", () => {
     expect(
-      isRecoverableThreadResumeError(
-        new Error("thread/resume failed: thread not found"),
-      ),
+      isRecoverableThreadResumeError(new Error("thread/resume failed: thread not found")),
     ).toBe(true);
   });
 
   it("ignores non-resume errors", () => {
     expect(
-      isRecoverableThreadResumeError(
-        new Error("thread/start failed: permission denied"),
-      ),
+      isRecoverableThreadResumeError(new Error("thread/start failed: permission denied")),
     ).toBe(false);
   });
 
```

## apps/server/src/codexAppServerManager.ts

Status: modified; +11; −33

```diff
@@ -26,9 +26,7 @@
 interface PendingApprovalRequest {
   requestId: string;
   jsonRpcId: string | number;
-  method:
-    | "item/commandExecution/requestApproval"
-    | "item/fileChange/requestApproval";
+  method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval";
   requestKind: ProviderRequestKind;
   threadId?: string;
   turnId?: string;
@@ -122,16 +120,12 @@
 }
 
 export function isRecoverableThreadResumeError(error: unknown): boolean {
+  const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
-  const message = (
-    error instanceof Error ? error.message : String(error)
-  ).toLowerCase();
   if (!message.includes("thread/resume")) {
     return false;
   }
 
-  return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) =>
-    message.includes(snippet),
-  );
+  return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
 }
 
 export interface CodexAppServerManagerEvents {
@@ -209,14 +203,10 @@
       if (input.resumeThreadId) {
         try {
           threadOpenMethod = "thread/resume";
-          threadOpenResponse = await this.sendRequest(
-            context,
+          threadOpenResponse = await this.sendRequest(context, "thread/resume", {
+            ...sessionOverrides,
-            "thread/resume",
-            {
-              ...sessionOverrides,
-              threadId: input.resumeThreadId,
-            },
-          );
+            threadId: input.resumeThreadId,
+          });
         } catch (error) {
           if (!isRecoverableThreadResumeError(error)) {
             throw error;
@@ -228,19 +218,11 @@
             "session/threadResumeFallback",
             `Could not resume thread ${input.resumeThreadId}; started a new thread instead.`,
           );
-          threadOpenResponse = await this.sendRequest(
-            context,
+          threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);
-            "thread/start",
-            threadStartParams,
-          );
         }
       } else {
         threadOpenMethod = "thread/start";
-        threadOpenResponse = await this.sendRequest(
-          context,
-          "thread/start",
-          threadStartParams,
+        threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);
-        );
       }
 
       const threadOpenRecord = this.readObject(threadOpenResponse);
@@ -248,9 +230,7 @@
         this.readString(this.readObject(threadOpenRecord, "thread"), "id") ??
         this.readString(threadOpenRecord, "threadId");
       if (!threadId) {
-        throw new Error(
+        throw new Error(`${threadOpenMethod} response did not include a thread id.`);
-          `${threadOpenMethod} response did not include a thread id.`,
-        );
       }
 
       this.updateSession(context, {
@@ -732,9 +712,7 @@
     };
   }
 
+  private requestKindForMethod(method: string): ProviderRequestKind | undefined {
-  private requestKindForMethod(
-    method: string,
-  ): ProviderRequestKind | undefined {
     if (method === "item/commandExecution/requestApproval") {
       return "command";
     }
```

## apps/server/src/git.test.ts

Status: added; +423; −0

```diff
@@ -1,0 +1,423 @@
+import { existsSync } from "node:fs";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+import {
+  checkoutGitBranch,
+  createGitBranch,
+  createGitWorktree,
+  initGitRepo,
+  listGitBranches,
+  removeGitWorktree,
+  runTerminalCommand,
+} from "./git";
+
+// ── Helpers ──
+
+/** Run a raw git command for test setup (not under test). */
+async function git(cwd: string, command: string): Promise<string> {
+  const result = await runTerminalCommand({
+    command: `git ${command}`,
+    cwd,
+    timeoutMs: 10_000,
+  });
+  if (result.code !== 0) {
+    throw new Error(`git ${command} failed: ${result.stderr}`);
+  }
+  return result.stdout.trim();
+}
+
+/** Create a disposable temp directory that cleans up automatically. */
+async function makeTmpDir() {
+  const dir = await mkdtemp(path.join(tmpdir(), "git-test-"));
+  return {
+    path: dir,
+    [Symbol.asyncDispose]: async () => {
+      await rm(dir, { recursive: true, force: true });
+    },
+  };
+}
+
+/** Create a repo with an initial commit so branches work. */
+async function initRepoWithCommit(cwd: string): Promise<void> {
+  await initGitRepo({ cwd });
+  await git(cwd, "config user.email 'test@test.com'");
+  await git(cwd, "config user.name 'Test'");
+  await writeFile(path.join(cwd, "README.md"), "# test\n");
+  await git(cwd, "add .");
+  await git(cwd, "commit -m 'initial commit'");
+}
+
+// ── Tests ──
+
+describe("git integration", () => {
+  // ── initGitRepo ──
+
+  describe("initGitRepo", () => {
+    it("creates a valid git repo", async () => {
+      await using tmp = await makeTmpDir();
+      await initGitRepo({ cwd: tmp.path });
+      expect(existsSync(path.join(tmp.path, ".git"))).toBe(true);
+    });
+
+    it("listGitBranches reports isRepo: true after init + commit", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      const result = await listGitBranches({ cwd: tmp.path });
+      expect(result.isRepo).toBe(true);
+      expect(result.branches.length).toBeGreaterThanOrEqual(1);
+    });
+  });
+
+  // ── listGitBranches ──
+
+  describe("listGitBranches", () => {
+    it("returns isRepo: false for non-git directory", async () => {
+      await using tmp = await makeTmpDir();
+      const result = await listGitBranches({ cwd: tmp.path });
+      expect(result.isRepo).toBe(false);
+      expect(result.branches).toEqual([]);
+    });
+
+    it("returns the current branch with current: true", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      const result = await listGitBranches({ cwd: tmp.path });
+      const current = result.branches.find((b) => b.current);
+      expect(current).toBeDefined();
+      expect(current!.current).toBe(true);
+    });
+
+    it("sorts current branch first", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      await createGitBranch({ cwd: tmp.path, branch: "aaa-first-alpha" });
+      await createGitBranch({ cwd: tmp.path, branch: "zzz-last" });
+
+      const result = await listGitBranches({ cwd: tmp.path });
+      expect(result.branches[0]!.current).toBe(true);
+    });
+
+    it("lists multiple branches after creating them", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      await createGitBranch({ cwd: tmp.path, branch: "feature-a" });
+      await createGitBranch({ cwd: tmp.path, branch: "feature-b" });
+
+      const result = await listGitBranches({ cwd: tmp.path });
+      const names = result.branches.map((b) => b.name);
+      expect(names).toContain("feature-a");
+      expect(names).toContain("feature-b");
+    });
+
+    it("isDefault is false when no remote exists", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      const result = await listGitBranches({ cwd: tmp.path });
+      expect(result.branches.every((b) => b.isDefault === false)).toBe(true);
+    });
+  });
+
+  // ── checkoutGitBranch ──
+
+  describe("checkoutGitBranch", () => {
+    it("checks out an existing branch", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      await createGitBranch({ cwd: tmp.path, branch: "feature" });
+
+      await checkoutGitBranch({ cwd: tmp.path, branch: "feature" });
+
+      const result = await listGitBranches({ cwd: tmp.path });
+      const current = result.branches.find((b) => b.current);
+      expect(current!.name).toBe("feature");
+    });
+
+    it("throws when branch does not exist", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      await expect(checkoutGitBranch({ cwd: tmp.path, branch: "nonexistent" })).rejects.toThrow();
+    });
+
+    it("throws when checkout would overwrite uncommitted changes", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      await createGitBranch({ cwd: tmp.path, branch: "other" });
+
+      // Create a conflicting change: modify README on current branch
+      await writeFile(path.join(tmp.path, "README.md"), "modified\n");
+      await git(tmp.path, "add README.md");
+
+      // First, checkout other branch cleanly
+      await git(tmp.path, "stash");
+      await checkoutGitBranch({ cwd: tmp.path, branch: "other" });
+      await writeFile(path.join(tmp.path, "README.md"), "other content\n");
+      await git(tmp.path, "add .");
+      await git(tmp.path, "commit -m 'other change'");
+
+      // Go back to default branch
+      const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
+        (b) => !b.current,
+      )!.name;
+      await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch });
+
+      // Make uncommitted changes to the same file
+      await writeFile(path.join(tmp.path, "README.md"), "conflicting local\n");
+
+      // Checkout should fail due to uncommitted changes
+      await expect(checkoutGitBranch({ cwd: tmp.path, branch: "other" })).rejects.toThrow();
+    });
+  });
+
+  // ── createGitBranch ──
+
+  describe("createGitBranch", () => {
+    it("creates a new branch visible in listGitBranches", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      await createGitBranch({ cwd: tmp.path, branch: "new-feature" });
+
+      const result = await listGitBranches({ cwd: tmp.path });
+      expect(result.branches.some((b) => b.name === "new-feature")).toBe(true);
+    });
+
+    it("throws when branch already exists", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      await createGitBranch({ cwd: tmp.path, branch: "dupe" });
+      await expect(createGitBranch({ cwd: tmp.path, branch: "dupe" })).rejects.toThrow();
+    });
+  });
+
+  // ── createGitWorktree + removeGitWorktree ──
+
+  describe("createGitWorktree", () => {
+    it("creates a worktree with a new branch from the base branch", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+
+      const wtPath = path.join(tmp.path, "worktree-out");
+      const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
+        (b) => b.current,
+      )!.name;
+
+      const result = await createGitWorktree({
+        cwd: tmp.path,
+        branch: currentBranch,
+        newBranch: "wt-branch",
+        path: wtPath,
+      });
+
+      expect(result.worktree.path).toBe(wtPath);
+      expect(result.worktree.branch).toBe("wt-branch");
+      expect(existsSync(wtPath)).toBe(true);
+      expect(existsSync(path.join(wtPath, "README.md"))).toBe(true);
+
+      // Clean up worktree before tmp dir disposal
+      await removeGitWorktree({ cwd: tmp.path, path: wtPath });
+    });
+
+    it("worktree has the new branch checked out", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+
+      const wtPath = path.join(tmp.path, "wt-check-dir");
+      const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
+        (b) => b.current,
+      )!.name;
+
+      await createGitWorktree({
+        cwd: tmp.path,
+        branch: currentBranch,
+        newBranch: "wt-check",
+        path: wtPath,
+      });
+
+      // Verify the worktree is on the new branch
+      const branchOutput = await git(wtPath, "branch --show-current");
+      expect(branchOutput).toBe("wt-check");
+
+      await removeGitWorktree({ cwd: tmp.path, path: wtPath });
+    });
+
+    it("throws when new branch name already exists", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      await createGitBranch({ cwd: tmp.path, branch: "existing" });
+
+      const wtPath = path.join(tmp.path, "wt-conflict");
+      const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
+        (b) => b.current,
+      )!.name;
+
+      await expect(
+        createGitWorktree({
+          cwd: tmp.path,
+          branch: currentBranch,
+          newBranch: "existing",
+          path: wtPath,
+        }),
+      ).rejects.toThrow();
+    });
+
+    it("listGitBranches from worktree cwd reports worktree branch as current", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+
+      const wtPath = path.join(tmp.path, "wt-list-dir");
+      const mainBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
+        (b) => b.current,
+      )!.name;
+
+      await createGitWorktree({
+        cwd: tmp.path,
+        branch: mainBranch,
+        newBranch: "wt-list",
+        path: wtPath,
+      });
+
+      // listGitBranches from the worktree should show wt-list as current
+      const wtBranches = await listGitBranches({ cwd: wtPath });
+      expect(wtBranches.isRepo).toBe(true);
+      const wtCurrent = wtBranches.branches.find((b) => b.current);
+      expect(wtCurrent!.name).toBe("wt-list");
+
+      // Main repo should still show the original branch as current
+      const mainBranches = await listGitBranches({ cwd: tmp.path });
+      const mainCurrent = mainBranches.branches.find((b) => b.current);
+      expect(mainCurrent!.name).toBe(mainBranch);
+
+      await removeGitWorktree({ cwd: tmp.path, path: wtPath });
+    });
+
+    it("removeGitWorktree cleans up the worktree", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+
+      const wtPath = path.join(tmp.path, "wt-remove-dir");
+      const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
+        (b) => b.current,
+      )!.name;
+
+      await createGitWorktree({
+        cwd: tmp.path,
+        branch: currentBranch,
+        newBranch: "wt-remove",
+        path: wtPath,
+      });
+      expect(existsSync(wtPath)).toBe(true);
+
+      await removeGitWorktree({ cwd: tmp.path, path: wtPath });
+      expect(existsSync(wtPath)).toBe(false);
+    });
+  });
+
+  // ── Full flow: local branch checkout ──
+
+  describe("full flow: local branch checkout", () => {
+    it("init → commit → create branch → checkout → verify current", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      await createGitBranch({ cwd: tmp.path, branch: "feature-login" });
+      await checkoutGitBranch({ cwd: tmp.path, branch: "feature-login" });
+
+      const result = await listGitBranches({ cwd: tmp.path });
+      const current = result.branches.find((b) => b.current);
+      expect(current!.name).toBe("feature-login");
+    });
+  });
+
+  // ── Full flow: worktree creation from base branch ──
+
+  describe("full flow: worktree creation", () => {
+    it("creates worktree with new branch from current branch", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+
+      const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
+        (b) => b.current,
+      )!.name;
+
+      const wtPath = path.join(tmp.path, "my-worktree");
+      const result = await createGitWorktree({
+        cwd: tmp.path,
+        branch: currentBranch,
+        newBranch: "feature-wt",
+        path: wtPath,
+      });
+
+      // Worktree exists
+      expect(existsSync(result.worktree.path)).toBe(true);
+
+      // Main repo still on original branch
+      const mainBranches = await listGitBranches({ cwd: tmp.path });
+      const mainCurrent = mainBranches.branches.find((b) => b.current);
+      expect(mainCurrent!.name).toBe(currentBranch);
+
+      // Worktree is on the new branch
+      const wtBranch = await git(wtPath, "branch --show-current");
+      expect(wtBranch).toBe("feature-wt");
+
+      await removeGitWorktree({ cwd: tmp.path, path: wtPath });
+    });
+  });
+
+  // ── Full flow: thread switching simulation ──
+
+  describe("full flow: thread switching (checkout toggling)", () => {
+    it("checkout a → checkout b → checkout a → current matches", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      await createGitBranch({ cwd: tmp.path, branch: "branch-a" });
+      await createGitBranch({ cwd: tmp.path, branch: "branch-b" });
+
+      // Simulate switching to thread A's branch
+      await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" });
+      let branches = await listGitBranches({ cwd: tmp.path });
+      expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
+
+      // Simulate switching to thread B's branch
+      await checkoutGitBranch({ cwd: tmp.path, branch: "branch-b" });
+      branches = await listGitBranches({ cwd: tmp.path });
+      expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b");
+
+      // Switch back to thread A
+      await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" });
+      branches = await listGitBranches({ cwd: tmp.path });
+      expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
+    });
+  });
+
+  // ── Full flow: checkout conflict ──
+
+  describe("full flow: checkout conflict", () => {
+    it("uncommitted changes prevent checkout to a diverged branch", async () => {
+      await using tmp = await makeTmpDir();
+      await initRepoWithCommit(tmp.path);
+      await createGitBranch({ cwd: tmp.path, branch: "diverged" });
+
+      // Make diverged branch have different file content
+      await checkoutGitBranch({ cwd: tmp.path, branch: "diverged" });
+      await writeFile(path.join(tmp.path, "README.md"), "diverged content\n");
+      await git(tmp.path, "add .");
+      await git(tmp.path, "commit -m 'diverge'");
+
+      // Actually, let's just get back to the initial branch explicitly
+      const allBranches = await listGitBranches({ cwd: tmp.path });
+      const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name;
+      await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch });
+
+      // Make local uncommitted changes to the same file
+      await writeFile(path.join(tmp.path, "README.md"), "local uncommitted\n");
+
+      // Attempt checkout should fail
+      await expect(checkoutGitBranch({ cwd: tmp.path, branch: "diverged" })).rejects.toThrow();
+
+      // Current branch should still be the initial one
+      const result = await listGitBranches({ cwd: tmp.path });
+      expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);
+    });
+  });
+});
```

## apps/server/src/git.ts

Status: added; +229; −0

```diff
@@ -1,0 +1,229 @@
+import { spawn } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import type {
+  GitCheckoutInput,
+  GitCreateBranchInput,
+  GitCreateWorktreeInput,
+  GitCreateWorktreeResult,
+  GitInitInput,
+  GitListBranchesInput,
+  GitListBranchesResult,
+  GitRemoveWorktreeInput,
+  TerminalCommandInput,
+  TerminalCommandResult,
+} from "@t3tools/contracts";
+
+/** Spawn git directly with an argv array — no shell, no quoting needed. */
+function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {
+  return new Promise((resolve, reject) => {
+    const child = spawn("git", args, {
+      cwd,
+      env: process.env,
+      stdio: ["ignore", "pipe", "pipe"],
+    });
+
+    let stdout = "";
+    let stderr = "";
+    let timedOut = false;
+
+    const timeout = setTimeout(() => {
+      timedOut = true;
+      child.kill("SIGTERM");
+      setTimeout(() => {
+        if (!child.killed) child.kill("SIGKILL");
+      }, 1_000).unref();
+    }, timeoutMs);
+
+    child.stdout?.on("data", (chunk: Buffer) => {
+      stdout += chunk.toString();
+    });
+    child.stderr?.on("data", (chunk: Buffer) => {
+      stderr += chunk.toString();
+    });
+    child.on("error", (error) => {
+      clearTimeout(timeout);
+      reject(error);
+    });
+    child.on("close", (code, signal) => {
+      clearTimeout(timeout);
+      resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut });
+    });
+  });
+}
+
+export async function runTerminalCommand(
+  input: TerminalCommandInput,
+): Promise<TerminalCommandResult> {
+  const shellPath =
+    process.platform === "win32"
+      ? (process.env.ComSpec ?? "cmd.exe")
+      : (process.env.SHELL ?? "/bin/sh");
+
+  const args =
+    process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command];
+
+  return new Promise((resolve, reject) => {
+    const child = spawn(shellPath, args, {
+      cwd: input.cwd,
+      env: process.env,
+      stdio: ["ignore", "pipe", "pipe"],
+    });
+
+    let stdout = "";
+    let stderr = "";
+    let timedOut = false;
+
+    const timeout = setTimeout(() => {
+      timedOut = true;
+      child.kill("SIGTERM");
+      setTimeout(() => {
+        if (!child.killed) {
+          child.kill("SIGKILL");
+        }
+      }, 1_000).unref();
+    }, input.timeoutMs ?? 30_000);
+
+    child.stdout?.on("data", (chunk: Buffer) => {
+      stdout += chunk.toString();
+    });
+
+    child.stderr?.on("data", (chunk: Buffer) => {
+      stderr += chunk.toString();
+    });
+
+    child.on("error", (error) => {
+      clearTimeout(timeout);
+      reject(error);
+    });
+
+    child.on("close", (code, signal) => {
+      clearTimeout(timeout);
+      resolve({
+        stdout,
+        stderr,
+        code: code ?? null,
+        signal: signal ?? null,
+        timedOut,
+      });
+    });
+  });
+}
+
+export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
+  const result = await runGit(["branch", "--no-color"], input.cwd, 10_000);
+
+  if (result.code !== 0) {
+    const stderr = result.stderr.trim();
+    if (stderr.includes("not a git repository")) {
+      return { branches: [], isRepo: false };
+    }
+    throw new Error(stderr || "git branch failed");
+  }
+
+  // Resolve the real default branch from the remote
+  const [defaultRef, worktreeList] = await Promise.all([
+    runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000),
+    runGit(["worktree", "list", "--porcelain"], input.cwd, 5_000),
+  ]);
+  const defaultBranch =
+    defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
+
+  // Build branch-name → worktree-path map from porcelain output.
+  // Only include worktrees whose directories still exist on disk (skip prunable/stale ones).
+  const worktreeMap = new Map<string, string>();
+  if (worktreeList.code === 0) {
+    let currentPath: string | null = null;
+    for (const line of worktreeList.stdout.split("\n")) {
+      if (line.startsWith("worktree ")) {
+        currentPath = line.slice("worktree ".length);
+        if (!fs.existsSync(currentPath)) currentPath = null;
+      } else if (line.startsWith("branch refs/heads/") && currentPath) {
+        worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
+      } else if (line === "") {
+        currentPath = null;
+      }
+    }
+  }
+
+  const branches = result.stdout
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.length > 0)
+    .map((line) => {
+      const name = line.replace(/^[*+]\s+/, "");
+      return {
+        name,
+        current: line.startsWith("* "),
+        isDefault: name === defaultBranch,
+        worktreePath: worktreeMap.get(name) ?? null,
+      };
+    })
+    .toSorted((a, b) => {
+      if (a.current !== b.current) return a.current ? -1 : 1;
+      if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
+      return a.name.localeCompare(b.name);
+    });
+
+  return { branches, isRepo: true };
+}
+
+export async function createGitWorktree(
+  input: GitCreateWorktreeInput,
+): Promise<GitCreateWorktreeResult> {
+  const sanitizedBranch = input.newBranch.replace(/\//g, "-");
+  const repoName = path.basename(input.cwd);
+  const worktreePath =
+    input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);
+
+  // Create a new branch from the base branch in a new worktree
+  const result = await runGit(
+    ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch],
+    input.cwd,
+  );
+
+  if (result.code !== 0) {
+    throw new Error(result.stderr.trim() || "git worktree add failed");
+  }
+
+  return {
+    worktree: {
+      path: worktreePath,
+      branch: input.newBranch,
+    },
+  };
+}
+
+export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {
+  const result = await runGit(["worktree", "remove", input.path], input.cwd, 15_000);
+
+  if (result.code !== 0) {
+    throw new Error(result.stderr.trim() || "git worktree remove failed");
+  }
+}
+
+export async function createGitBranch(input: GitCreateBranchInput): Promise<void> {
+  const result = await runGit(["branch", input.branch], input.cwd, 10_000);
+
+  if (result.code !== 0) {
+    throw new Error(result.stderr.trim() || "git branch create failed");
+  }
+}
+
+export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> {
+  const result = await runGit(["checkout", input.branch], input.cwd, 10_000);
+
+  if (result.code !== 0) {
+    throw new Error(result.stderr.trim() || "git checkout failed");
+  }
+}
+
+export async function initGitRepo(input: GitInitInput): Promise<void> {
+  const result = await runGit(["init"], input.cwd, 10_000);
+
+  if (result.code !== 0) {
+    throw new Error(result.stderr.trim() || "git init failed");
+  }
+}
```

## apps/server/src/providerManager.test.ts

Status: modified; +1; −4

```diff
@@ -16,10 +16,7 @@
         method: string;
         threadId: string;
       }) => void;
-      threadLogStreams: Map<
+      threadLogStreams: Map<string, { writableEnded: boolean; destroyed: boolean }>;
-        string,
-        { writableEnded: boolean; destroyed: boolean }
-      >;
     };
     internals.onCodexEvent({
       id: "evt-1",
```

[Next page](https://git.cafe/pingdotgg/t3code/pull/3/diffs?after=ImFwcHMvc2VydmVyL3NyYy9wcm92aWRlck1hbmFnZXIudGVzdC50cyI&expectedVersion=16&format=markdown)
