Git integration: branch picker + worktrees#3

merged32
main
juliusmarminge/git-integration
openedFeb 9, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 4d ago
32 unresolved threads Β· Jump to file
Selected thread: apps/renderer/src/components/ChatView.tsx:428 β€” `components/ChatView.tsx:427` Consider setting `isSending = true` at the start of `onSend` (before the worktree creation block) to prevent race conditions from rapid submissions. No longer relevant as of bc70cc4b74e5e0258d35ab90a80aedd3f20f75e2
0 / 54 viewed54 files changedLine totals unavailable

apps/renderer/src/components/ChatView.tsx Β· Earlier revision aa02b113 Β· right 427–428

Original diff
@@ -416,10 +421,37 @@ export default function ChatView() {
 
   const onSend = async (e: FormEvent) => {
     e.preventDefault();
-    if (!api || !activeThread || isSending || isConnecting) return;
+    if (!api || !activeThread || !activeProject || isSending || isConnecting)
+      return;
     const trimmed = prompt.trim();
     if (!trimmed) return;
 

apps/renderer/src/components/ChatView.tsx Β· right 427–428

M
macroscopeappBotFeb 9, 2026

components/ChatView.tsx:427 Consider setting isSending = true at the start of onSend (before the worktree creation block) to prevent race conditions from rapid submissions.

No longer relevant as of bc70cc4b74e5e0258d35ab90a80aedd3f20f75e2

apps/web/src/components/BranchToolbar.tsx Β· Earlier revision 807e3d18 Β· right 75–76

Original diff
@@ -0,0 +1,293 @@
+import type { GitBranch } from "@t3tools/contracts";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo, useRef, useState } from "react";
+
+import { readNativeApi } from "../session-logic";
+import { useStore } from "../store";
+
+interface BranchToolbarProps {
+  envMode: "local" | "worktree";
+  onEnvModeChange: (mode: "local" | "worktree") => void;
+  envLocked: boolean;
+}
+
+export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {
+  const { state, dispatch } = useStore();
+  const api = useMemo(() => readNativeApi(), []);
+  const queryClient = useQueryClient();
+
+  const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
+  const [isCreatingBranch, setIsCreatingBranch] = useState(false);
+  const [newBranchName, setNewBranchName] = useState("");
+  const branchMenuRef = useRef<HTMLDivElement>(null);
+
+  const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
+  const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
+  const activeThreadId = activeThread?.id;
+  const activeThreadBranch = activeThread?.branch ?? null;
+  const activeWorktreePath = activeThread?.worktreePath ?? null;
+  const branchCwd = activeWorktreePath ?? activeProject?.cwd;
+
+  // ── Queries ───────────────────────────────────────────────────────────
+
+  const branchesQuery = useQuery({
+    queryKey: ["git", "branches", branchCwd],
+    queryFn: () => api!.git.listBranches({ cwd: branchCwd! }),
+    enabled: !!api && !!branchCwd,
+  });
+
+  const branches = branchesQuery.data?.branches ?? [];
+  const isRepo = branchesQuery.data?.isRepo ?? false;
+
+  // ── Mutations ─────────────────────────────────────────────────────────
+
+  const checkoutMutation = useMutation({
+    mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }),
+    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
+    onError: (error) =>
+      setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."),
+  });
+
+  const createBranchMutation = useMutation({
+    mutationFn: (branch: string) =>
+      api!.git
+        .createBranch({ cwd: branchCwd!, branch })
+        .then(() => api!.git.checkout({ cwd: branchCwd!, branch })),
+    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
+    onError: (error) =>
+      setThreadError(error instanceof Error ? error.message : "Failed to create branch."),
+  });
+
+  const initMutation = useMutation({
+    mutationFn: () => api!.git.init({ cwd: branchCwd! }),
+    onSuccess: () => {
+      setThreadError(null);
+      queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] });
+    },
+    onError: (error) =>
+      setThreadError(error instanceof Error ? error.message : "Failed to initialize git repo."),
+  });
+
+  // ── Effects ───────────────────────────────────────────────────────────
+
+  // Keep thread branch synced to git current branch for local threads.
+  const queryBranches = branchesQuery.data?.branches;
+  useEffect(() => {
+    if (!activeThreadId || activeWorktreePath) return;

apps/web/src/components/BranchToolbar.tsx Β· right 75–76

M
macroscopeappBotFeb 11, 2026

🟑 Medium

components/BranchToolbar.tsx:75 The useEffect at line 75 reverts the thread branch to match Git HEAD when activeWorktreePath is null. However, the new worktree mode (lines 118-123) also has null activeWorktreePath but intentionally sets a different branch without checkout. Consider adding envMode !== "worktree" to the effect's early return to prevent it from overwriting the user's base branch selection.

diff
1- useEffect(() => {
2- if (!activeThreadId || activeWorktreePath) return;
3+ useEffect(() => {
4+ if (!activeThreadId || activeWorktreePath || envMode === "worktree") return;

πŸš€ Want me to fix this? Reply ex: "fix it for me".

πŸ€– Prompt for AI
text
1In file apps/web/src/components/BranchToolbar.tsx around lines 75-76:
2
3The `useEffect` at line 75 reverts the thread branch to match Git HEAD when `activeWorktreePath` is null. However, the new worktree mode (lines 118-123) also has null `activeWorktreePath` but intentionally sets a different branch without checkout. Consider adding `envMode !== "worktree"` to the effect's early return to prevent it from overwriting the user's base branch selection.

apps/server/src/git.ts Β· Earlier revision 6465e69f Β· right 151

Original diff
@@ -0,0 +1,195 @@
+import { spawn } from "node:child_process";
+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";
+
+function escapeSingleQuotes(value: string): string {
+  return value.replace(/'/g, "'\\''");
+}
+
+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 runTerminalCommand({
+    command: "git branch --no-color",
+    cwd: input.cwd,
+    timeoutMs: 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 = await runTerminalCommand({
+    command: "git symbolic-ref refs/remotes/origin/HEAD",
+    cwd: input.cwd,
+    timeoutMs: 5_000,
+  });
+  const defaultBranch =
+    defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
+
+  const branches = result.stdout
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.length > 0)
+    .map((line) => ({
+      name: line.replace(/^[*+]\s+/, ""),
+      current: line.startsWith("* "),
+      isDefault: line.replace(/^[*+]\s+/, "") === defaultBranch,
+    }))
+    .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 runTerminalCommand({
+    command: `git worktree add -b '${escapeSingleQuotes(input.newBranch)}' '${escapeSingleQuotes(worktreePath)}' '${escapeSingleQuotes(input.branch)}'`,
+    cwd: input.cwd,
+    timeoutMs: 30_000,
+  });
+
+  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 runTerminalCommand({
+    command: `git worktree remove '${escapeSingleQuotes(input.path)}'`,
M
macroscopeappBotFeb 10, 2026

src/git.ts:151 On Windows, cmd.exe doesn't handle single-quoted arguments correctly. Consider using double quotes on Windows or escaping appropriately for cross-platform compatibility.

βœ… Addressed in 807e3d18dec858c9694a595deaea4f9ac588db5f

apps/server/src/git.ts Β· Earlier revision da9421af Β· right 45

Original diff
@@ -0,0 +1,129 @@
+import path from "node:path";
+
+import type {
+  GitCheckoutInput,
+  GitCreateBranchInput,
+  GitCreateWorktreeInput,
+  GitCreateWorktreeResult,
+  GitInitInput,
+  GitListBranchesInput,
+  GitListBranchesResult,
+  GitRemoveWorktreeInput,
+} from "@acme/contracts";
+import { runTerminalCommand } from "./terminal";
+
+export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
+  const result = await runTerminalCommand({
+    command: "git branch --no-color",
+    cwd: input.cwd,
+    timeoutMs: 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 = await runTerminalCommand({
+    command: "git symbolic-ref refs/remotes/origin/HEAD",
+    cwd: input.cwd,
+    timeoutMs: 5_000,
+  });
+  const defaultBranch =
+    defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
+
+  const branches = result.stdout
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.length > 0)
+    .map((line) => ({
+      name: line.replace(/^[*+]\s+/, ""),
+      current: line.startsWith("* "),
M
macroscopeappBotFeb 9, 2026

🟑 Medium

src/git.ts:45 The child.killed flag becomes true immediately after child.kill("SIGTERM") is called, so the SIGKILL fallback is unreachable. Consider tracking process exit separately (e.g., with a flag set in the close event) instead of relying on child.killed.

πŸš€ Want me to fix this? Reply ex: "fix it for me".

apps/renderer/src/components/ChatView.tsx Β· Earlier revision eefd4e62 Β· right 162

Original diff
@@ -154,7 +159,7 @@ export default function ChatView() {
     try {
       const session = await api.providers.startSession({
         provider: "codex",
-        cwd: activeProject.cwd || undefined,
+        cwd: activeThread.worktreePath ?? activeProject.cwd,
M
macroscopeappBotFeb 9, 2026

components/ChatView.tsx:162 After dispatching SET_THREAD_BRANCH, ensureSession still reads the stale activeThread from the closure, so worktreePath is null and the session starts in the wrong directory. Consider passing the new worktreePath directly to ensureSession (or to startSession) instead of relying on the updated state.

βœ… Addressed in 79fdda9deea9abd21c3036f6a1d1f5004db4a8b2

apps/desktop/src/terminal.ts Β· Earlier revision 1d68c4ab Β· right 37

Original diff
@@ -0,0 +1,60 @@
+import { TerminalCommandInput, TerminalCommandResult } from "@acme/contracts";
+import { spawn } from "node:child_process";
+
+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();
M
macroscopeappBotFeb 9, 2026

src/terminal.ts:37 Consider adding a maximum buffer size for stdout and stderr to prevent OOM if commands produce excessive output. Truncate or reject once a limit is reached.

No longer relevant as of bc70cc4b74e5e0258d35ab90a80aedd3f20f75e2

turbo.json Β· Earlier revision d6437a7e Β· right 20–24

Original diff
@@ -18,9 +18,9 @@
       "outputs": ["dist/**", "dist-electron/**"]
     },
     "dev": {
-      "dependsOn": ["@t3tools/contracts#build"],
+      "dependsOn": ["^dev"],
       "cache": false,
-      "persistent": true
+      "persistent": false
     },

turbo.json Β· right 20–24

M
macroscopeappBotFeb 11, 2026

turbo.json:20 The dev task with "dependsOn": ["^dev"] and "persistent": false will hang indefinitely. Since vite dev servers never complete, dependent tasks wait forever. Consider reverting to the previous approach: depend on build tasks that complete (e.g., @t3tools/contracts#build) and set persistent: true.

No longer relevant as of 7e5cd01d26b21093947cb9b988f5963b8ed6ce26

apps/desktop/package.json Β· Earlier revision 6e3726ec Β· right 22–24

Original diff
@@ -18,11 +18,10 @@
   },
   "devDependencies": {
     "@types/node": "^22.10.2",
-    "concurrently": "^9.1.2",
     "electronmon": "^2.0.2",
-    "tsup": "^8.3.5",
+    "tsdown": "^0.20.3",
     "typescript": "^5.7.3",
-    "vitest": "^3.0.0",
+    "vitest": "^4.0.0",

apps/desktop/package.json Β· right 22–24

C
coderabbitaiBotFeb 11, 2026

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

shell
1#!/bin/bash
2
3# Check latest versions from npm registry
4echo "=== tsdown latest version ==="
5npm view tsdown@latest version 2>/dev/null || echo "Failed to fetch"
6
7echo ""
8echo "=== vitest latest version ==="
9npm view vitest@latest version 2>/dev/null || echo "Failed to fetch"
10
11echo ""
12echo "=== tsdown 0.20.3 details ==="
13npm view tsdown@0.20.3 2>/dev/null | head -30 || echo "Failed to fetch"
14
15echo ""
16echo "=== vitest 4.0.0 details ==="
17npm view vitest@4.0.0 2>/dev/null | head -30 || echo "Failed to fetch"

Repository: pingdotgg/codething-mvp

Length of output: 1591


🏁 Script executed:

shell
1#!/bin/bash
2
3# Check the full package.json context in apps/desktop
4echo "=== apps/desktop/package.json content ==="
5if [ -f "apps/desktop/package.json" ]; then
6 cat apps/desktop/package.json
7else
8 echo "File not found"
9fi

Repository: pingdotgg/codething-mvp

Length of output: 821


🏁 Script executed:

shell
1#!/bin/bash
2
3# Search for vitest and tsdown configuration in the repository
4echo "=== vitest configuration files ==="
5fd -e "vitest.config" -e "vitest.config.ts" -e "vitest.config.js" 2>/dev/null
6
7echo ""
8echo "=== tsdown usage in the codebase ==="
9rg "tsdown" --type json --type ts --type tsx -l 2>/dev/null | head -20
10
11echo ""
12echo "=== vitest usage pattern ==="
13rg "vitest" --type json --type ts --type tsx -l 2>/dev/null | head -20

Repository: pingdotgg/codething-mvp

Length of output: 169


Consider updating vitest to 4.0.18 and clarify tsdown stability.

tsdown@0.20.3 is already the latest stable release and does not represent a major update. However, vitest@4.0.0 is outdatedβ€”the current stable patch is 4.0.18. Both versions are compatible with TypeScript 5.7.3, but updating vitest to the latest patch is recommended for bug fixes and stability improvements. Note that the test script currently uses --passWithNoTests, suggesting minimal active test coverage; consider whether vitest configuration and test suite expansion are needed for this Electron desktop application.

πŸ€– Prompt for AI Agents
1In `@apps/desktop/package.json` around lines 22 - 24, Update the package.json dev
2dependency for "vitest" from "^4.0.0" to "^4.0.18" to pick up recent patch
3fixes, and optionally add a comment or package.json note near "tsdown" to
4clarify it's already at the latest stable (0.20.3) if you want to signal
5stability; also review the test script that passes "--passWithNoTests" (in
6package.json scripts) and decide whether to remove or tighten that flag after
7updating vitest to encourage real test coverage.

.plans/git-flows-integration-tests.md Β· Earlier revision 6e3726ec Β· right 93–99

Original diff
@@ -0,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-integration-tests.md Β· right 93–99

C
coderabbitaiBotFeb 11, 2026

⚠️ Potential issue | 🟑 Minor

Use bunx instead of npx in the verification snippet.
This keeps docs consistent with the Bun-only tooling standard.

✏️ Proposed doc fix
diff
1-# Or just the git test file
2-npx vitest run apps/desktop/src/git.test.ts
3+# Or just the git test file
4+bunx vitest run apps/desktop/src/git.test.ts

Based on learnings: Use Bun as the package manager. Use bunx <BIN> when executing scripts from npm packages not installed as a project dependency.

πŸ€– Prompt for AI Agents
1In @.plans/git-flows-integration-tests.md around lines 93 - 99, The
2documentation uses "npx vitest run apps/desktop/src/git.test.ts" which
3contradicts the Bun-centric tooling elsewhere; update the verification snippet
4to use Bun's runner by replacing the "npx" invocation with "bunx" (so the second
5command uses bunx vitest run apps/desktop/src/git.test.ts) and keep the existing
6"cd apps/desktop && bun run test" line unchanged; ensure both examples
7consistently recommend Bun tooling.

apps/server/turbo.jsonc Β· Earlier revision 807e3d18 Β· right 5–10

Original diff
@@ -0,0 +1,11 @@
+{
+  "$schema": "https://turbo.build/schema.json",
+  "extends": ["//"],
+  "tasks": {
+    "start": {
+      "dependsOn": ["build"],
+      "cache": false,
+      "persistent": true,
+    },
+  },

apps/server/turbo.jsonc Β· right 5–10

C
coderabbitaiBotFeb 11, 2026

⚠️ Potential issue | 🟠 Major

Remove trailing commas to fix JSONC parse errors.

Biome reports parse errors at the closing braces; the trailing commas before } are the likely cause and will break lint/CI.

βœ… Suggested fix
diff
1 "tasks": {
2 "start": {
3 "dependsOn": ["build"],
4 "cache": false,
5- "persistent": true,
6- },
7- },
8+ "persistent": true
9+ }
10+ }
11 }
πŸ“ Committable suggestion

‼️ IMPORTANT Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

suggestion
1 "start": {
2 "dependsOn": ["build"],
3 "cache": false,
4 "persistent": true
5 }
6 }
🧰 Tools
πŸͺ› Biome (2.3.14)

[error] 9-9: Expected a property but instead found '}'.

Expected a property here.

(parse)


[error] 10-10: Expected a property but instead found '}'.

Expected a property here.

(parse)

πŸ€– Prompt for AI Agents
1In `@apps/server/turbo.jsonc` around lines 5 - 10, The JSONC has trailing commas
2after the "persistent" and closing object entries which causes parse errors;
3edit the "start" task object in turbo.jsonc (the "start" key and its properties
4"dependsOn", "cache", "persistent") and remove the trailing commas after
5"persistent": true and after the inner object so the JSONC object closes without
6extra commas, then re-run lint/CI.

apps/desktop/src/main.ts Β· Earlier revision 8ee3bf5c Β· right 333

Original diff
@@ -250,6 +289,95 @@ async function runTerminalCommand(
   });
 }
 
+async function listGitBranches(
+  input: GitListBranchesInput,
+): Promise<GitListBranchesResult> {
+  const result = await runTerminalCommand({
+    command: "git branch --no-color",
+    cwd: input.cwd,
+    timeoutMs: 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");
+  }
+
+  const defaultBranches = new Set(["main", "master"]);
+
+  const branches = result.stdout
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.length > 0)
+    .map((line) => ({
+      name: line.replace(/^[*+]\s+/, ""),
+      current: line.startsWith("* "),
+    }))
+    .sort((a, b) => {
+      if (a.current !== b.current) return a.current ? -1 : 1;
+      const aDefault = defaultBranches.has(a.name);
+      const bDefault = defaultBranches.has(b.name);
+      if (aDefault !== bDefault) return aDefault ? -1 : 1;
+      return a.name.localeCompare(b.name);
+    });
+
+  return { branches, isRepo: true };
+}
+
+async function createGitWorktree(
+  input: GitCreateWorktreeInput,
+): Promise<GitCreateWorktreeResult> {
+  const sanitizedBranch = input.branch.replace(/\//g, "-");
M
macroscopeappBotFeb 9, 2026

src/main.ts:333 Replacing / with - can cause collisions (e.g., feature/test and feature-test both become feature-test). Consider using a more unique transformation or appending a hash to avoid conflicts.

No longer relevant as of 79fdda9deea9abd21c3036f6a1d1f5004db4a8b2

apps/server/src/git.ts Β· Earlier revision 198cc583 Β· right 191–191

Original diff
@@ -0,0 +1,212 @@
+import { spawn } from "node:child_process";
+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 = await runGit(
+    ["symbolic-ref", "refs/remotes/origin/HEAD"],
+    input.cwd,
+    5_000,
+  );
+  const defaultBranch =
+    defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
+
+  const branches = result.stdout
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.length > 0)
+    .map((line) => ({
+      name: line.replace(/^[*+]\s+/, ""),
+      current: line.startsWith("* "),
+      isDefault: line.replace(/^[*+]\s+/, "") === defaultBranch,
+    }))
+    .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);

apps/server/src/git.ts Β· right 191–191

M
macroscopeappBotFeb 11, 2026

🟒 Low

src/git.ts:191 Suggestion: user-supplied branch names can be misparsed as options or pathspecs. Add -- before branch arguments across git calls (e.g., branch, checkout), or prefer git switch for switching branches to avoid ambiguity.

suggestion
1 const result = await runGit(["branch", "--", input.branch], input.cwd, 10_000);

πŸš€ Want me to fix this? Reply ex: "fix it for me".

πŸ€– Prompt for AI
text
1In file apps/server/src/git.ts around line 191:
2
3Suggestion: user-supplied branch names can be misparsed as options or pathspecs. Add `--` before branch arguments across `git` calls (e.g., `branch`, `checkout`), or prefer `git switch` for switching branches to avoid ambiguity.

apps/server/src/git.ts Β· Earlier revision 6465e69f Β· right 55

Original diff
@@ -0,0 +1,195 @@
+import { spawn } from "node:child_process";
+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";
+
+function escapeSingleQuotes(value: string): string {
+  return value.replace(/'/g, "'\\''");
+}
+
+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();
M
macroscopeappBotFeb 10, 2026

🟒 Low

src/git.ts:55 Multi-byte UTF-8 characters split across chunks will corrupt to οΏ½. Consider using StringDecoder from node:string_decoder to properly handle partial characters.

πŸš€ Want me to fix this? Reply ex: "fix it for me".

πŸ€– Prompt for AI
text
1In file apps/server/src/git.ts around line 55:
2
3Multi-byte UTF-8 characters split across chunks will corrupt to `οΏ½`. Consider using `StringDecoder` from `node:string_decoder` to properly handle partial characters.

apps/renderer/src/components/BranchToolbar.tsx Β· Earlier revision adf6a264 Β· right 150

Original diff
@@ -0,0 +1,294 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo, useRef, useState } from "react";
+
+import { readNativeApi } from "../session-logic";
+import { useStore } from "../store";
+import { listBranchesQuery } from "../hooks/useGitState";
+
+interface BranchToolbarProps {
+  envMode: "local" | "worktree";
+  onEnvModeChange: (mode: "local" | "worktree") => void;
+  envLocked: boolean;
+}
+
+export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {
+  const { state, dispatch } = useStore();
+  const api = useMemo(() => readNativeApi(), []);
+  const queryClient = useQueryClient();
+
+  const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
+  const [isCreatingBranch, setIsCreatingBranch] = useState(false);
+  const [newBranchName, setNewBranchName] = useState("");
+  const branchMenuRef = useRef<HTMLDivElement>(null);
+
+  const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
+  const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
+
+  const branchCwd = activeThread?.worktreePath ?? activeProject?.cwd;
+  const { data: branchData } = useQuery(listBranchesQuery(branchCwd));
+  const isRepo = branchData?.isRepo ?? false;
+  const branches = branchData?.branches ?? [];
+
+  // Keep thread branch in sync with git for local (non-worktree) threads
+  useEffect(() => {
+    if (!activeThread) return;
+    const current = branches.find((b) => b.current);
+    if (!current) return;
+    // Worktree threads have a fixed branch β€” don't override
+    if (activeThread.worktreePath) return;
+    // Already in sync
+    if (activeThread.branch === current.name) return;
+    dispatch({
+      type: "SET_THREAD_BRANCH",
+      threadId: activeThread.id,
+      branch: current.name,
+      worktreePath: activeThread.worktreePath,
+    });
+  }, [activeThread, branches, dispatch]);
+
+  useEffect(() => {
+    if (!isBranchMenuOpen) return;
+
+    const handleClickOutside = (event: MouseEvent) => {
+      if (!branchMenuRef.current) return;
+      if (event.target instanceof Node && !branchMenuRef.current.contains(event.target)) {
+        setIsBranchMenuOpen(false);
+      }
+    };
+
+    window.addEventListener("mousedown", handleClickOutside);
+    return () => {
+      window.removeEventListener("mousedown", handleClickOutside);
+    };
+  }, [isBranchMenuOpen]);
+
+  if (!isRepo || !activeThread) return null;
+
+  return (
+    <div className="mx-auto flex max-w-3xl items-center justify-between px-1 pt-2">
+      {/* Env mode (left) */}
+      <div className="flex items-center gap-2">
+        {envLocked ? (
+          <span className="inline-flex items-center gap-1.5 px-1 text-[12px] text-muted-foreground/40">
+            {activeThread.worktreePath ? "Worktree" : "Local"}
+          </span>
+        ) : (
+          <button
+            type="button"
+            className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[12px] transition-colors duration-150 ${
+              envMode === "worktree"
+                ? "bg-accent text-foreground/80"
+                : "text-muted-foreground/50 hover:bg-accent/50 hover:text-muted-foreground/70"
+            }`}
+            onClick={() => onEnvModeChange(envMode === "local" ? "worktree" : "local")}
+          >
+            {envMode === "worktree" ? "New worktree" : "Local"}
+          </button>
+        )}
+      </div>
+
+      {/* Branch picker (right) */}
+      {branches.length > 0 && (
+        <div className="relative" ref={branchMenuRef}>
+          <button
+            type="button"
+            className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[12px] text-muted-foreground/50 transition-colors duration-150 hover:bg-accent/50 hover:text-muted-foreground/70"
+            onClick={() => setIsBranchMenuOpen((o) => !o)}
+          >
+            <svg
+              width="12"
+              height="12"
+              viewBox="0 0 16 16"
+              fill="currentColor"
+              className="opacity-60"
+              aria-hidden="true"
+            >
+              <path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Z" />
+            </svg>
+            <span className="max-w-[200px] truncate font-mono">
+              {activeThread.branch
+                ? envMode === "worktree" && !envLocked
+                  ? `From ${activeThread.branch}`
+                  : activeThread.branch
+                : "select branch"}
+            </span>
+            <svg
+              width="10"
+              height="10"
+              viewBox="0 0 10 10"
+              fill="none"
+              className="opacity-40"
+              aria-hidden="true"
+            >
+              <path
+                d="M2.5 4L5 6.5L7.5 4"
+                stroke="currentColor"
+                strokeWidth="1.2"
+                strokeLinecap="round"
+                strokeLinejoin="round"
+              />
+            </svg>
+          </button>
+          {isBranchMenuOpen && (
+            <div className="absolute bottom-full right-0 z-20 mb-2 w-[280px] rounded-2xl border border-border bg-popover/95 p-2 shadow-[0_16px_40px_rgba(0,0,0,0.55)] backdrop-blur">
+              <p className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/50">
+                Branch
+              </p>
+              <div className="max-h-64 overflow-y-auto">
+                {branches.map((b) => {
+                  const isSelected = b.name === activeThread.branch;
+                  return (
+                    <button
+                      key={b.name}
+                      type="button"
+                      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 ${
+                        isSelected
+                          ? "bg-accent text-foreground"
+                          : "text-foreground/90 hover:bg-accent/50"
+                      }`}
+                      onClick={async () => {
+                        if (api && activeProject) {
M
macroscopeappBotFeb 9, 2026

components/BranchToolbar.tsx:150 The SET_THREAD_BRANCH dispatch (lines 169-179) runs even when the git checkout is skipped due to !api || !activeProject. Consider moving these dispatch calls inside the conditional block so UI state only updates after a successful checkout.

No longer relevant as of bc70cc4b74e5e0258d35ab90a80aedd3f20f75e2

apps/desktop/turbo.jsonc Β· Earlier revision 807e3d18 Β· right 5–23

Original diff
@@ -0,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/desktop/turbo.jsonc Β· right 5–23

C
coderabbitaiBotFeb 11, 2026

⚠️ Potential issue | 🟠 Major

Remove trailing commas to fix JSONC parse errors.

Biome reports parse errors at the closing braces; the trailing commas before } are the likely cause and will break lint/CI.

βœ… Suggested fix
diff
1 "tasks": {
2 "build": {
3 "dependsOn": ["^build"],
4- "outputs": ["dist-electron/**"],
5+ "outputs": ["dist-electron/**"]
6 },
7 "dev": {
8 "dependsOn": ["t3#build"],
9- "persistent": true,
10+ "persistent": true
11 },
12 "start": {
13 "dependsOn": ["build", "@t3tools/web#build", "t3#build"],
14 "cache": false,
15- "persistent": true,
16+ "persistent": true
17 },
18 "smoke-test": {
19 "dependsOn": ["build", "@t3tools/web#build", "t3#build"],
20 "cache": false,
21- "outputs": [],
22- },
23- },
24+ "outputs": []
25+ }
26+ }
27 }
πŸ“ Committable suggestion

‼️ IMPORTANT Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

suggestion
1 "build": {
2 "dependsOn": ["^build"],
3 "outputs": ["dist-electron/**"]
4 },
5 "dev": {
6 "dependsOn": ["t3#build"],
7 "persistent": true
8 },
9 "start": {
10 "dependsOn": ["build", "@t3tools/web#build", "t3#build"],
11 "cache": false,
12 "persistent": true
13 },
14 "smoke-test": {
15 "dependsOn": ["build", "@t3tools/web#build", "t3#build"],
16 "cache": false,
17 "outputs": []
18 }
19 }
🧰 Tools
πŸͺ› Biome (2.3.14)

[error] 8-8: Expected a property but instead found '}'.

Expected a property here.

(parse)


[error] 12-12: Expected a property but instead found '}'.

Expected a property here.

(parse)


[error] 17-17: Expected a property but instead found '}'.

Expected a property here.

(parse)


[error] 22-22: Expected a property but instead found '}'.

Expected a property here.

(parse)


[error] 23-23: Expected a property but instead found '}'.

Expected a property here.

(parse)

πŸ€– Prompt for AI Agents
1In `@apps/desktop/turbo.jsonc` around lines 5 - 23, The JSONC contains trailing
2commas after object entries which cause parse errors; remove the trailing commas
3in the "build", "dev", "start", and "smoke-test" task objects (specifically the
4commas following the "outputs", "persistent", "persistent"/"cache", and
5"outputs" lines) so each object ends with a closing brace without a trailing
6comma and the file parses correctly.

apps/desktop/src/main.ts Β· Earlier revision 8ee3bf5c Β· right 359

Original diff
@@ -250,6 +289,95 @@ async function runTerminalCommand(
   });
 }
 
+async function listGitBranches(
+  input: GitListBranchesInput,
+): Promise<GitListBranchesResult> {
+  const result = await runTerminalCommand({
+    command: "git branch --no-color",
+    cwd: input.cwd,
+    timeoutMs: 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");
+  }
+
+  const defaultBranches = new Set(["main", "master"]);
+
+  const branches = result.stdout
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.length > 0)
+    .map((line) => ({
+      name: line.replace(/^[*+]\s+/, ""),
+      current: line.startsWith("* "),
+    }))
+    .sort((a, b) => {
+      if (a.current !== b.current) return a.current ? -1 : 1;
+      const aDefault = defaultBranches.has(a.name);
+      const bDefault = defaultBranches.has(b.name);
+      if (aDefault !== bDefault) return aDefault ? -1 : 1;
+      return a.name.localeCompare(b.name);
+    });
+
+  return { branches, isRepo: true };
+}
+
+async function createGitWorktree(
+  input: GitCreateWorktreeInput,
+): Promise<GitCreateWorktreeResult> {
+  const sanitizedBranch = input.branch.replace(/\//g, "-");
+  const repoName = path.basename(input.cwd);
+  const worktreePath =
+    input.path ??
+    path.join(input.cwd, "..", `${repoName}-worktrees`, sanitizedBranch);
+
+  const result = await runTerminalCommand({
+    command: `git worktree add '${worktreePath.replace(/'/g, "'\\''")}' '${input.branch.replace(/'/g, "'\\''")}'`,
+    cwd: input.cwd,
+    timeoutMs: 30_000,
+  });
+
+  if (result.code !== 0) {
+    throw new Error(result.stderr.trim() || "git worktree add failed");
+  }
+
+  return {
+    worktree: {
+      path: worktreePath,
+      branch: input.branch,
+    },
+  };
+}
+
+async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {
+  const result = await runTerminalCommand({
+    command: `git worktree remove '${input.path.replace(/'/g, "'\\''")}'`,
M
macroscopeappBotFeb 9, 2026

src/main.ts:359 Shell quoting uses POSIX single quotes, but runTerminalCommand invokes cmd.exe on Windows, so paths with spaces will break. Consider platform-aware quoting/escaping (e.g., double quotes on Windows) or a cross‑platform command builder.

No longer relevant as of 79fdda9deea9abd21c3036f6a1d1f5004db4a8b2

vitest.config.ts Β· Earlier revision 6e3726ec Β· right 4–13

Original diff
@@ -1,3 +1,13 @@
+import * as path from "node:path";
 import { defineConfig } from "vitest/config";
 
-export default defineConfig({});
+export default defineConfig({
+  resolve: {
+    alias: [
+      {
+        find: /^@t3tools\/contracts$/,
+        replacement: path.resolve(import.meta.dirname, "./packages/contracts/src/index.ts"),
+      },
+    ],
+  },
+});

vitest.config.ts Β· right 4–13

G
greptile-appsBotFeb 11, 2026

Vitest run broken This config aliases @t3tools/contracts to the TypeScript source entry (packages/contracts/src/index.ts). With the PR’s build changes, tests now fail at runtime with TypeError: undefined is not an object (evaluating '__vite_ssr_import_0__.z.object'), which indicates the Zod import in those aliased sources isn’t being resolved correctly under Vitest’s SSR transform.

Since bun run test is part of the repo workflow, this needs to be fixed before merge (either by adjusting the alias/optimizeDeps/ssr settings or changing how contracts are imported in tests).

apps/web/src/components/BranchToolbar.tsx Β· Earlier revision 8324949b Β· right 51–59

Original diff
@@ -0,0 +1,306 @@
+import type { GitBranch } from "@t3tools/contracts";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo, useRef, useState } from "react";
+
+import { readNativeApi } from "../session-logic";
+import { useStore } from "../store";
+
+interface BranchToolbarProps {
+  envMode: "local" | "worktree";
+  onEnvModeChange: (mode: "local" | "worktree") => void;
+  envLocked: boolean;
+}
+
+export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {
+  const { state, dispatch } = useStore();
+  const api = useMemo(() => readNativeApi(), []);
+  const queryClient = useQueryClient();
+
+  const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
+  const [isCreatingBranch, setIsCreatingBranch] = useState(false);
+  const [newBranchName, setNewBranchName] = useState("");
+  const branchMenuRef = useRef<HTMLDivElement>(null);
+
+  const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
+  const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
+  const activeThreadId = activeThread?.id;
+  const activeThreadBranch = activeThread?.branch ?? null;
+  const activeWorktreePath = activeThread?.worktreePath ?? null;
+  const branchCwd = activeWorktreePath ?? activeProject?.cwd;
+
+  // ── Queries ───────────────────────────────────────────────────────────
+
+  const branchesQuery = useQuery({
+    queryKey: ["git", "branches", branchCwd],
+    queryFn: () => api!.git.listBranches({ cwd: branchCwd! }),
+    enabled: !!api && !!branchCwd,
+  });
+
+  const branches = branchesQuery.data?.branches ?? [];
+  const isRepo = branchesQuery.data?.isRepo ?? false;
+
+  // ── Mutations ─────────────────────────────────────────────────────────
+
+  const checkoutMutation = useMutation({
+    mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }),
+    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
+    onError: (error) =>
+      setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."),
+  });
+
+  const createBranchMutation = useMutation({
+    mutationFn: (branch: string) =>
+      api!.git
+        .createBranch({ cwd: branchCwd!, branch })
+        .then(() => api!.git.checkout({ cwd: branchCwd!, branch })),
+    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
+    onError: (error) =>
+      setThreadError(error instanceof Error ? error.message : "Failed to create branch."),
+  });

apps/web/src/components/BranchToolbar.tsx Β· right 51–59

C
coderabbitaiBotFeb 11, 2026

⚠️ Potential issue | 🟑 Minor

Chained mutation may leave inconsistent state on partial failure.

If createBranch succeeds but checkout fails (e.g., due to uncommitted changes), the error message incorrectly says "Failed to create branch" even though the branch was created. Additionally, since onSuccess won't fire, the branch list won't refresh, hiding the newly created branch from the user.

Consider separating into two distinct operations or providing accurate error messaging for partial success scenarios.

πŸ”§ Suggested fix: separate operations or improve error handling
diff
1 const createBranchMutation = useMutation({
2- mutationFn: (branch: string) =>
3- api!.git
4- .createBranch({ cwd: branchCwd!, branch })
5- .then(() => api!.git.checkout({ cwd: branchCwd!, branch })),
6- onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
7- onError: (error) =>
8- setThreadError(error instanceof Error ? error.message : "Failed to create branch."),
9+ mutationFn: async (branch: string) => {
10+ await api!.git.createBranch({ cwd: branchCwd!, branch });
11+ try {
12+ await api!.git.checkout({ cwd: branchCwd!, branch });
13+ } catch (checkoutError) {
14+ // Branch was created but checkout failed - still invalidate to show new branch
15+ await queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] });
16+ throw new Error(
17+ `Branch created but checkout failed: ${checkoutError instanceof Error ? checkoutError.message : "Unknown error"}`
18+ );
19+ }
20+ },
21+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
22+ onError: (error) =>
23+ setThreadError(error instanceof Error ? error.message : "Failed to create branch."),
24 });
πŸ“ Committable suggestion

‼️ IMPORTANT Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

suggestion
1 const createBranchMutation = useMutation({
2 mutationFn: async (branch: string) => {
3 await api!.git.createBranch({ cwd: branchCwd!, branch });
4 try {
5 await api!.git.checkout({ cwd: branchCwd!, branch });
6 } catch (checkoutError) {
7 // Branch was created but checkout failed - still invalidate to show new branch
8 await queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] });
9 throw new Error(
10 `Branch created but checkout failed: ${checkoutError instanceof Error ? checkoutError.message : "Unknown error"}`
11 );
12 }
13 },
14 onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
15 onError: (error) =>
16 setThreadError(error instanceof Error ? error.message : "Failed to create branch."),
17 });
πŸ€– Prompt for AI Agents
1In `@apps/web/src/components/BranchToolbar.tsx` around lines 51 - 59, The chained
2mutation in createBranchMutation (mutationFn calling api.git.createBranch then
3api.git.checkout) can yield partial success where createBranch succeeds but
4checkout fails, causing an incorrect "Failed to create branch" error and no
5cache refresh; change the flow so createBranch is a standalone operation that
6calls queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd]
7}) on its onSuccess, then perform checkout as a separate step (either a
8follow-up mutation or a try/catch after createBranch) and handle checkout errors
9distinctly by calling setThreadError with a message like "Branch created but
10checkout failed: <checkout error>". Make sure to reference the existing
11createBranchMutation, api!.git.createBranch, api!.git.checkout, onSuccess,
12onError, queryClient.invalidateQueries, and setThreadError when making these
13changes.

apps/web/package.json Β· Earlier revision 6e3726ec Β· right 28–32

Original diff
@@ -25,10 +25,10 @@
     "@tailwindcss/vite": "^4.0.0",
     "@types/react": "^19.0.0",
     "@types/react-dom": "^19.0.0",
-    "@vitejs/plugin-react": "^4.3.4",
+    "@vitejs/plugin-react": "^5.1.4",
     "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
     "tailwindcss": "^4.0.0",
     "typescript": "^5.7.3",
-    "vite": "^6.0.5"
+    "vite": "^8.0.0-beta.12"

apps/web/package.json Β· right 28–32

C
coderabbitaiBotFeb 11, 2026

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Vite 8 stable release date 2026

πŸ’‘ Result:

As of February 11, 2026, Vite 8 does not have a stable (non-beta) release date announced or published yetβ€”the project is still shipping 8.0.0 beta pre-releases. The Vite 8 beta was announced on December 3, 2025 and GitHub shows ongoing v8.0.0-beta.x releases (e.g., v8.0.0-beta.13 on February 5, 2026). [1][2]

Sources:
[1] https://vite.dev/blog/announcing-vite8-beta
[2] https://github.com/vitejs/vite/releases


Replace Vite 8 beta with a stable release version.

Vite 8 remains in beta as of February 2026 with no announced stable release date. Using pre-release versions in production code introduces significant risk of breaking changes and instability. Update to Vite 7.x or wait for Vite 8 stable release before merging.

πŸ€– Prompt for AI Agents
1In `@apps/web/package.json` around lines 28 - 32, The package.json currently pins
2"vite" to a beta pre-release ("^8.0.0-beta.12"); change the "vite" dependency
3entry to a stable 7.x release (for example "^7.21.0" or the latest 7.x), update
4the lockfile by running your package manager (npm/yarn/pnpm install), and run
5the dev/build/test commands to verify nothing breaks; edit the "vite" key in
6package.json to replace the beta version, commit the lockfile changes, and
7ensure CI passes (reference: the "vite" dependency entry in package.json).

apps/server/src/git.ts Β· Earlier revision 6e3726ec Β· right 122–183

Original diff
@@ -0,0 +1,195 @@
+import { spawn } from "node:child_process";
+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";
+
+function escapeSingleQuotes(value: string): string {
+  return value.replace(/'/g, "'\\''");
+}
+
+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 runTerminalCommand({
+    command: "git branch --no-color",
+    cwd: input.cwd,
+    timeoutMs: 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 = await runTerminalCommand({
+    command: "git symbolic-ref refs/remotes/origin/HEAD",
+    cwd: input.cwd,
+    timeoutMs: 5_000,
+  });
+  const defaultBranch =
+    defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
+
+  const branches = result.stdout
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.length > 0)
+    .map((line) => ({
+      name: line.replace(/^[*+]\s+/, ""),
+      current: line.startsWith("* "),
+      isDefault: line.replace(/^[*+]\s+/, "") === defaultBranch,
+    }))
+    .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 runTerminalCommand({
+    command: `git worktree add -b '${escapeSingleQuotes(input.newBranch)}' '${escapeSingleQuotes(worktreePath)}' '${escapeSingleQuotes(input.branch)}'`,
+    cwd: input.cwd,
+    timeoutMs: 30_000,
+  });
+
+  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 runTerminalCommand({
+    command: `git worktree remove '${escapeSingleQuotes(input.path)}'`,
+    cwd: input.cwd,
+    timeoutMs: 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 runTerminalCommand({
+    command: `git branch '${escapeSingleQuotes(input.branch)}'`,
+    cwd: input.cwd,
+    timeoutMs: 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 runTerminalCommand({
+    command: `git checkout '${escapeSingleQuotes(input.branch)}'`,
+    cwd: input.cwd,
+    timeoutMs: 10_000,
+  });
+
+  if (result.code !== 0) {
+    throw new Error(result.stderr.trim() || "git checkout failed");
+  }
+}

apps/server/src/git.ts Β· right 122–183

C
coderabbitaiBotFeb 11, 2026

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

nodejs child_process shell true windows cmd.exe quoting

πŸ’‘ Result:

On Windows, child_process.spawn/exec with { shell: true } runs your command through the system shell (process.env.ComSpec, typically cmd.exe). Node expects that shell to understand Unix -c or Windows cmd.exe /d /s /c-style invocation. [1]

Why quoting β€œbreaks” with shell: true on Windows

You’re now dealing with two parsers:

  1. Node serializes your command/args into a single command line for the shell. On Windows, Node has special behavior around argument quoting; windowsVerbatimArguments controls whether Node does any quoting/escaping, and it’s set to true automatically when the shell is CMD. [1]

  2. cmd.exe then applies its own quote rules to the string after /c (or /k). With /s, cmd uses special rules that strip the first/last quote around the command string while leaving the rest unchanged. [2] Those rules are quirky enough that the common workaround is the β€œdouble-quote wrapper” pattern: cmd /s /c ""C:\Path With Spaces\app.exe" "arg with spaces"". (Examples and rule details are documented widely.) [3]

Practical guidance

  • Prefer shell: false (the default) and pass an args array whenever you can:

    js
    1spawn('git', ['commit', '-m', 'message with spaces'], { shell: false });

    This avoids cmd.exe quoting entirely.

  • If you must use CMD via { shell: true }, build a single CMD command string and use the outer-double-quote form so paths-with-spaces work:

    js
    1spawn('cmd.exe', ['/d', '/s', '/c', '""C:\\Program Files\\Tool\\tool.exe" "arg 1" "arg&2""']);

    If an argument contains CMD metacharacters like &|<>^(), you generally must escape them for CMD (often with ^) because shell: true means they’re meaningful to the shell.

Sources: Node.js child_process docs (shell + windowsVerbatimArguments). [1] Microsoft cmd docs for /c and /s quote-stripping behavior. [2] CMD quoting behavior details and examples (including the cmd /c ""..."" pattern). [3]


Shell-quoted git commands will break on Windows cmd.exe.

Lines 132, 151, 163, and 175 wrap git arguments in single quotes; cmd.exe does not recognize single quotes as delimiters, so paths and branch names will be passed to git with quotes included as literal characters. This breaks on Windows, especially with spaces in paths or branch names.

To fix, spawn git directly with an arguments array instead of a shell command string:

js
1spawn('git', ['worktree', 'add', '-b', input.newBranch, worktreePath, input.branch], { shell: false })

This avoids cmd.exe's quoting rules entirely and works consistently across Windows, macOS, and Linux.

πŸ€– Prompt for AI Agents
1In `@apps/server/src/git.ts` around lines 122 - 183, The git commands in
2createGitWorktree, removeGitWorktree, createGitBranch, and checkoutGitBranch are
3built as single shell strings with single quotes (e.g., using
4escapeSingleQuotes) which breaks on Windows cmd.exe; change each to spawn git
5directly with an args array instead of a shell command string (e.g., call
6runTerminalCommand or child_process.spawn with ['git', 'worktree', 'add', '-b',
7input.newBranch, worktreePath, input.branch'] and shell: false), remove the
8single-quote escaping (no escapeSingleQuotes wrapping), keep cwd and timeoutMs,
9and ensure failures still throw the same errors based on
10result.code/result.stderr.

βœ… Addressed in commit 198cc58

apps/web/turbo.jsonc Β· Earlier revision 807e3d18 Β· right 5–8

Original diff
@@ -0,0 +1,9 @@
+{
+  "$schema": "https://turbo.build/schema.json",
+  "extends": ["//"],
+  "tasks": {
+    "dev": {
+      "persistent": true,
+    },
+  },

apps/web/turbo.jsonc Β· right 5–8

C
coderabbitaiBotFeb 11, 2026

⚠️ Potential issue | 🟠 Major

Remove trailing commas to fix JSONC parse errors.

Biome reports parse errors at the closing braces; the trailing commas before } are the likely cause and will break lint/CI.

βœ… Suggested fix
diff
1 "tasks": {
2 "dev": {
3- "persistent": true,
4- },
5- },
6+ "persistent": true
7+ }
8+ }
9 }
πŸ“ Committable suggestion

‼️ IMPORTANT Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

suggestion
1 "dev": {
2 "persistent": true
3 }
4 }
🧰 Tools
πŸͺ› Biome (2.3.14)

[error] 7-7: Expected a property but instead found '}'.

Expected a property here.

(parse)


[error] 8-8: Expected a property but instead found '}'.

Expected a property here.

(parse)

πŸ€– Prompt for AI Agents
1In `@apps/web/turbo.jsonc` around lines 5 - 8, The JSONC parse errors are caused
2by trailing commas in the "dev" object; remove the trailing comma after
3"persistent": true and any trailing comma before the closing brace of the outer
4object so the "dev" object and enclosing object are valid JSONC (i.e., edit the
5"dev" block to eliminate the comma after the last property and the comma before
6the closing brace).

apps/web/vite.config.ts Β· Earlier revision 8324949b Β· right 5

Original diff
@@ -2,6 +2,8 @@ import tailwindcss from "@tailwindcss/vite";
 import react from "@vitejs/plugin-react";
 import { defineConfig } from "vite";
 
+const port = Number(process.env.PORT ?? 5173);
C
coderabbitaiBotFeb 11, 2026

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

shell
1#!/bin/bash
2# Search for PORT and ELECTRON_RENDERER_PORT usage patterns
3
4echo "=== Web app vite.config.ts port usage ==="
5grep -n "PORT\|port" apps/web/vite.config.ts | head -20
6
7echo ""
8echo "=== Desktop dev script ELECTRON_RENDERER_PORT usage ==="
9grep -n "ELECTRON_RENDERER_PORT\|port" apps/desktop/scripts/dev-electron.mjs | head -20
10
11echo ""
12echo "=== Search all usages of PORT env var ==="
13rg -n "process\.env\.PORT" --type ts --type js -A 2 -B 2
14
15echo ""
16echo "=== Search all usages of ELECTRON_RENDERER_PORT ==="
17rg -n "ELECTRON_RENDERER_PORT" --type ts --type js --type mjs -A 2 -B 2
18
19echo ""
20echo "=== Check for any .env files or examples ==="
21fd "\.env" . -type f

Repository: pingdotgg/codething-mvp

Length of output: 1296


🏁 Script executed:

shell
1#!/bin/bash
2# Check for coordinated dev scripts and documentation
3
4echo "=== Root package.json dev scripts ==="
5cat package.json | grep -A 20 '"scripts"' | head -30
6
7echo ""
8echo "=== Check for development documentation ==="
9find . -maxdepth 2 -name "*.md" -type f | xargs grep -l "PORT\|port\|dev" 2>/dev/null | head -5
10
11echo ""
12echo "=== List all dev-related scripts in monorepo ==="
13fd "package.json" | xargs grep -l "\"dev\"" 2>/dev/null
14
15echo ""
16echo "=== Check apps/web package.json ==="
17cat apps/web/package.json | grep -A 5 '"scripts"' 2>/dev/null || echo "Not found"
18
19echo ""
20echo "=== Check apps/desktop package.json ==="
21cat apps/desktop/package.json | grep -A 5 '"scripts"' 2>/dev/null || echo "Not found"

Repository: pingdotgg/codething-mvp

Length of output: 2056


Align port environment variable names between web and desktop dev scripts.

The web app uses PORT while the desktop dev script uses ELECTRON_RENDERER_PORT. Both default to 5173, so they match in typical usage, but if someone sets only one to a custom value, the Electron app will fail to connect to the dev server. Either use a single env var name across both or document the relationship clearly.

πŸ€– Prompt for AI Agents
1In `@apps/web/vite.config.ts` at line 5, The dev port is read from
2process.env.PORT (const port) while the desktop dev script expects
3ELECTRON_RENDERER_PORT, causing mismatches; update the port resolution in the
4vite config (where const port is defined) to prefer ELECTRON_RENDERER_PORT first
5and then fall back to PORT and then 5173 (or switch both web and desktop scripts
6to a single agreed env name), and ensure any related npm scripts or docs
7reference the chosen env variable so Electron can always connect to the web dev
8server.

apps/web/src/components/BranchToolbar.tsx Β· Earlier revision 807e3d18 Β· right 57–58

Original diff
@@ -0,0 +1,293 @@
+import type { GitBranch } from "@t3tools/contracts";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo, useRef, useState } from "react";
+
+import { readNativeApi } from "../session-logic";
+import { useStore } from "../store";
+
+interface BranchToolbarProps {
+  envMode: "local" | "worktree";
+  onEnvModeChange: (mode: "local" | "worktree") => void;
+  envLocked: boolean;
+}
+
+export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {
+  const { state, dispatch } = useStore();
+  const api = useMemo(() => readNativeApi(), []);
+  const queryClient = useQueryClient();
+
+  const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
+  const [isCreatingBranch, setIsCreatingBranch] = useState(false);
+  const [newBranchName, setNewBranchName] = useState("");
+  const branchMenuRef = useRef<HTMLDivElement>(null);
+
+  const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
+  const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
+  const activeThreadId = activeThread?.id;
+  const activeThreadBranch = activeThread?.branch ?? null;
+  const activeWorktreePath = activeThread?.worktreePath ?? null;
+  const branchCwd = activeWorktreePath ?? activeProject?.cwd;
+
+  // ── Queries ───────────────────────────────────────────────────────────
+
+  const branchesQuery = useQuery({
+    queryKey: ["git", "branches", branchCwd],
+    queryFn: () => api!.git.listBranches({ cwd: branchCwd! }),
+    enabled: !!api && !!branchCwd,
+  });
+
+  const branches = branchesQuery.data?.branches ?? [];
+  const isRepo = branchesQuery.data?.isRepo ?? false;
+
+  // ── Mutations ─────────────────────────────────────────────────────────
+
+  const checkoutMutation = useMutation({
+    mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }),
+    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
+    onError: (error) =>
+      setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."),
+  });
+
+  const createBranchMutation = useMutation({
+    mutationFn: (branch: string) =>
+      api!.git
+        .createBranch({ cwd: branchCwd!, branch })
+        .then(() => api!.git.checkout({ cwd: branchCwd!, branch })),
+    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
+    onError: (error) =>
+      setThreadError(error instanceof Error ? error.message : "Failed to create branch."),

apps/web/src/components/BranchToolbar.tsx Β· right 57–58

M
macroscopeappBotFeb 11, 2026

🟒 Low

components/BranchToolbar.tsx:57 The chained createBranch().then(checkout) in createBranchMutation can leave git state out of sync with UI if checkout fails after branch creation succeeds. Consider separating these into distinct operations or refreshing branch state in onError to stay in sync.

diff
1- onError: (error) =>
2- setThreadError(error instanceof Error ? error.message : "Failed to create branch."),
3+ onError: (error) => {
4+ queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] });
5+ setThreadError(error instanceof Error ? error.message : "Failed to create branch.");
6+ },

πŸš€ Want me to fix this? Reply ex: "fix it for me".

πŸ€– Prompt for AI
text
1In file apps/web/src/components/BranchToolbar.tsx around lines 57-58:
2
3The chained `createBranch().then(checkout)` in `createBranchMutation` can leave git state out of sync with UI if checkout fails after branch creation succeeds. Consider separating these into distinct operations or refreshing branch state in `onError` to stay in sync.

apps/server/src/git.ts Β· Earlier revision da9421af Β· right 45

Original diff
@@ -0,0 +1,129 @@
+import path from "node:path";
+
+import type {
+  GitCheckoutInput,
+  GitCreateBranchInput,
+  GitCreateWorktreeInput,
+  GitCreateWorktreeResult,
+  GitInitInput,
+  GitListBranchesInput,
+  GitListBranchesResult,
+  GitRemoveWorktreeInput,
+} from "@acme/contracts";
+import { runTerminalCommand } from "./terminal";
+
+export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
+  const result = await runTerminalCommand({
+    command: "git branch --no-color",
+    cwd: input.cwd,
+    timeoutMs: 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 = await runTerminalCommand({
+    command: "git symbolic-ref refs/remotes/origin/HEAD",
+    cwd: input.cwd,
+    timeoutMs: 5_000,
+  });
+  const defaultBranch =
+    defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
+
+  const branches = result.stdout
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.length > 0)
+    .map((line) => ({
+      name: line.replace(/^[*+]\s+/, ""),
+      current: line.startsWith("* "),
M
macroscopeappBotFeb 9, 2026

src/git.ts:45 The child.killed flag becomes true immediately after child.kill("SIGTERM") is called, so the SIGKILL fallback is unreachable. Consider tracking process exit separately (e.g., with a flag set in the close event) instead of relying on child.killed.

No longer relevant as of 1d68c4abfadb8ae1dfb50e667622df56872ce227

apps/web/src/components/BranchToolbar.tsx Β· Earlier revision 6e3726ec Β· right 61–73

Original diff
@@ -0,0 +1,312 @@
+import type { GitBranch } from "@t3tools/contracts";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { readNativeApi } from "../session-logic";
+import { useStore } from "../store";
+
+interface BranchToolbarProps {
+  envMode: "local" | "worktree";
+  onEnvModeChange: (mode: "local" | "worktree") => void;
+  envLocked: boolean;
+}
+
+export default function BranchToolbar({
+  envMode,
+  onEnvModeChange,
+  envLocked,
+}: BranchToolbarProps) {
+  const { state, dispatch } = useStore();
+  const api = useMemo(() => readNativeApi(), []);
+
+  const [branches, setBranches] = useState<GitBranch[]>([]);
+  const [isRepo, setIsRepo] = useState(false);
+  const [isLoadingBranches, setIsLoadingBranches] = useState(false);
+  const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
+  const [isCreatingBranch, setIsCreatingBranch] = useState(false);
+  const [newBranchName, setNewBranchName] = useState("");
+  const [isInitializingRepo, setIsInitializingRepo] = useState(false);
+  const branchMenuRef = useRef<HTMLDivElement>(null);
+
+  const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
+  const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
+  const branchCwd = activeThread?.worktreePath ?? activeProject?.cwd;
+
+  const loadBranches = useCallback(async () => {
+    if (!api || !branchCwd) return;
+    setIsLoadingBranches(true);
+    try {
+      const result = await api.git.listBranches({ cwd: branchCwd });
+      setIsRepo(result.isRepo);
+      setBranches(result.branches);
+    } catch (error) {
+      setIsRepo(false);
+      setBranches([]);
+      if (activeThread) {
+        dispatch({
+          type: "SET_ERROR",
+          threadId: activeThread.id,
+          error:
+            error instanceof Error ? error.message : "Failed to load git branches.",
+        });
+      }
+    } finally {
+      setIsLoadingBranches(false);
+    }
+  }, [activeThread, api, branchCwd, dispatch]);
+
+  useEffect(() => {
+    void loadBranches();
+  }, [loadBranches]);
+
+  // Keep thread branch synced to git current branch for local threads.
+  useEffect(() => {
+    if (!activeThread || activeThread.worktreePath) return;
+    const current = branches.find((branch) => branch.current);
+    if (!current) return;
+    if (current.name === activeThread.branch) return;
+    dispatch({
+      type: "SET_THREAD_BRANCH",
+      threadId: activeThread.id,
+      branch: current.name,
+      worktreePath: null,
+    });
+  }, [activeThread, branches, dispatch]);

apps/web/src/components/BranchToolbar.tsx Β· right 61–73

C
coderabbitaiBotFeb 11, 2026

⚠️ Potential issue | 🟠 Major

Worktree base-branch selection gets overwritten by auto-sync.
In worktree mode (before a worktree exists), the sync effect resets activeThread.branch to the current checked-out branch, which defeats selecting a different base branch for the worktree. Guard the sync to run only for local mode (or only when activeThread.branch is null).

πŸ”§ Suggested fix
diff
1 useEffect(() => {
2 if (!activeThread || activeThread.worktreePath) return;
3+ if (envMode !== "local") return;
4 const current = branches.find((branch) => branch.current);
5 if (!current) return;
6 if (current.name === activeThread.branch) return;
7 dispatch({
8 type: "SET_THREAD_BRANCH",
9 threadId: activeThread.id,
10 branch: current.name,
11 worktreePath: null,
12 });
13- }, [activeThread, branches, dispatch]);
14+ }, [activeThread, branches, dispatch, envMode]);
πŸ€– Prompt for AI Agents
1In `@apps/web/src/components/BranchToolbar.tsx` around lines 61 - 73, The
2auto-sync useEffect in BranchToolbar (the effect that checks branches.find and
3dispatches SET_THREAD_BRANCH) is overwriting a user-selected worktree base
4branch; change the guard so the effect only runs for true local threads (e.g.,
5when activeThread exists AND activeThread.worktreePath is falsy AND the thread
6is in local mode) or only when activeThread.branch is null/undefined, then
7proceed to find the current branch and dispatch SET_THREAD_BRANCH; update the
8useEffect condition that currently checks (!activeThread ||
9activeThread.worktreePath) to include the extra guard (or replace it with a
10check for activeThread.branch == null) so selecting a different base branch for
11a pending worktree is not clobbered.

βœ… Addressed in commit 807e3d1