Git integration: branch picker + worktrees#3
32 unresolved threads Β· Jump to file
- apps/renderer/src/components/ChatView.tsx:427β428 Β· `components/ChatView.tsx:427` Consider setting `isSending = true` at the start of `onSend` (before t
- apps/web/src/components/BranchToolbar.tsx:75β76 Β· π‘ **Medium** `components/BranchToolbar.tsx:75` The `useEffect` at line 75 reverts the thread branc
- apps/server/src/git.ts:151 Β· `src/git.ts:151` On Windows, `cmd.exe` doesn't handle single-quoted arguments correctly. Consider us
- apps/web/src/components/BranchToolbar.tsx:40β42 Β· **Medium: `isRepo` logic is inverted from the stated intent** The comment says "Default to true whi
- apps/server/src/git.ts:45 Β· π‘ **Medium** `src/git.ts:45` The `child.killed` flag becomes `true` immediately after `child.kill(
- apps/renderer/src/components/ChatView.tsx:162 Β· `components/ChatView.tsx:162` After dispatching `SET_THREAD_BRANCH`, `ensureSession` still reads the
- apps/desktop/src/terminal.ts:37 Β· `src/terminal.ts:37` Consider adding a maximum buffer size for `stdout` and `stderr` to prevent OOM
- turbo.json:20β24 Β· `turbo.json:20` The `dev` task with `"dependsOn": ["^dev"]` and `"persistent": false` will hang inde
- apps/desktop/package.json:22β24 Β· _β οΈ Potential issue_ | _π‘ Minor_ <details> <summary>π§© Analysis chain</summary> π Script execute
- .plans/git-flows-integration-tests.md:93β99 Β· _β οΈ Potential issue_ | _π‘ Minor_ **Use `bunx` instead of `npx` in the verification snippet.** Th
- apps/server/src/wsServer.ts:315β331 Β· **High: Schemas defined but never enforced** The zod schemas in `packages/contracts/src/git.ts` (`g
- apps/server/turbo.jsonc:5β10 Β· _β οΈ Potential issue_ | _π Major_ **Remove trailing commas to fix JSONC parse errors.** Biome repo
- apps/desktop/src/main.ts:333 Β· `src/main.ts:333` Replacing `/` with `-` can cause collisions (e.g., `feature/test` and `feature-tes
- apps/server/src/git.ts:191β191 Β· π’ **Low** `src/git.ts:191` Suggestion: user-supplied branch names can be misparsed as options or p
- apps/web/src/components/ChatView.tsx:448β470 Β· **Medium: Orphaned worktree on downstream failure** The worktree is created and dispatched to the s
- apps/server/src/git.ts:55 Β· π’ **Low** `src/git.ts:55` Multi-byte UTF-8 characters split across chunks will corrupt to `οΏ½`. Con
- apps/renderer/src/components/BranchToolbar.tsx:150 Β· `components/BranchToolbar.tsx:150` The `SET_THREAD_BRANCH` dispatch (lines 169-179) runs even when t
- apps/desktop/turbo.jsonc:5β23 Β· _β οΈ Potential issue_ | _π Major_ **Remove trailing commas to fix JSONC parse errors.** Biome repo
- apps/server/src/git.ts:177β179 Β· **Medium: Worktree path collision on identical repo basenames** `path.basename(input.cwd)` extracts
- apps/desktop/src/main.ts:359 Β· `src/main.ts:359` Shell quoting uses POSIX single quotes, but `runTerminalCommand` invokes `cmd.exe`
- vitest.config.ts:4β13 Β· **Vitest run broken** This config aliases `@t3tools/contracts` to the TypeScript source entry (`pack
- apps/web/src/components/BranchToolbar.tsx:51β59 Β· _β οΈ Potential issue_ | _π‘ Minor_ **Chained mutation may leave inconsistent state on partial failur
- apps/web/src/components/BranchToolbar.tsx:46β64 Β· **Low: `branchCwd!` non-null assertions in mutations** The query is gated with `enabled: !!api && !
- apps/web/package.json:28β32 Β· _β οΈ Potential issue_ | _π Major_ <details> <summary>π§© Analysis chain</summary> π Web query: `V
- apps/web/src/components/ChatView.tsx:449 Β· **Low: Hardcoded `codething/` prefix** This is the legacy project name. It should be a named consta
- apps/server/src/git.ts:122β183 Β· _β οΈ Potential issue_ | _π Major_ <details> <summary>π§© Analysis chain</summary> π Web query: `n
- apps/web/turbo.jsonc:5β8 Β· _β οΈ Potential issue_ | _π Major_ **Remove trailing commas to fix JSONC parse errors.** Biome repo
- apps/web/vite.config.ts:5 Β· _β οΈ Potential issue_ | _π‘ Minor_ <details> <summary>π§© Analysis chain</summary> π Script execute
- apps/web/src/components/BranchToolbar.tsx:57β58 Β· π’ **Low** `components/BranchToolbar.tsx:57` The chained `createBranch().then(checkout)` in `create
- apps/server/src/git.ts:45 Β· `src/git.ts:45` The `child.killed` flag becomes `true` immediately after `child.kill("SIGTERM")` is
- apps/server/src/git.ts:57 Β· **Low: `runTerminalCommand` left in `git.ts`** This general-purpose shell executor (`spawn` with `-
- apps/web/src/components/BranchToolbar.tsx:61β73 Β· _β οΈ Potential issue_ | _π Major_ **Worktree base-branch selection gets overwritten by auto-sync.**
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
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
π‘ 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.
| 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
| 1 | In file apps/web/src/components/BranchToolbar.tsx around lines 75-76: |
| 2 | |
| 3 | 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. |
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)}'`,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("* "),π‘ 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,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();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
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
β οΈ Potential issue | π‘ Minor
π§© Analysis chain
π Script executed:
| 1 | #!/bin/bash |
| 2 | |
| 3 | # Check latest versions from npm registry |
| 4 | echo "=== tsdown latest version ===" |
| 5 | npm view tsdown@latest version 2>/dev/null || echo "Failed to fetch" |
| 6 | |
| 7 | echo "" |
| 8 | echo "=== vitest latest version ===" |
| 9 | npm view vitest@latest version 2>/dev/null || echo "Failed to fetch" |
| 10 | |
| 11 | echo "" |
| 12 | echo "=== tsdown 0.20.3 details ===" |
| 13 | npm view tsdown@0.20.3 2>/dev/null | head -30 || echo "Failed to fetch" |
| 14 | |
| 15 | echo "" |
| 16 | echo "=== vitest 4.0.0 details ===" |
| 17 | npm view vitest@4.0.0 2>/dev/null | head -30 || echo "Failed to fetch" |
Repository: pingdotgg/codething-mvp
Length of output: 1591
π Script executed:
| 1 | #!/bin/bash |
| 2 | |
| 3 | # Check the full package.json context in apps/desktop |
| 4 | echo "=== apps/desktop/package.json content ===" |
| 5 | if [ -f "apps/desktop/package.json" ]; then |
| 6 | cat apps/desktop/package.json |
| 7 | else |
| 8 | echo "File not found" |
| 9 | fi |
Repository: pingdotgg/codething-mvp
Length of output: 821
π Script executed:
| 1 | #!/bin/bash |
| 2 | |
| 3 | # Search for vitest and tsdown configuration in the repository |
| 4 | echo "=== vitest configuration files ===" |
| 5 | fd -e "vitest.config" -e "vitest.config.ts" -e "vitest.config.js" 2>/dev/null |
| 6 | |
| 7 | echo "" |
| 8 | echo "=== tsdown usage in the codebase ===" |
| 9 | rg "tsdown" --type json --type ts --type tsx -l 2>/dev/null | head -20 |
| 10 | |
| 11 | echo "" |
| 12 | echo "=== vitest usage pattern ===" |
| 13 | rg "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
| 1 | In `@apps/desktop/package.json` around lines 22 - 24, Update the package.json dev |
| 2 | dependency for "vitest" from "^4.0.0" to "^4.0.18" to pick up recent patch |
| 3 | fixes, and optionally add a comment or package.json note near "tsdown" to |
| 4 | clarify it's already at the latest stable (0.20.3) if you want to signal |
| 5 | stability; also review the test script that passes "--passWithNoTests" (in |
| 6 | package.json scripts) and decide whether to remove or tighten that flag after |
| 7 | updating 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
β οΈ 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
| 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
| 1 | In @.plans/git-flows-integration-tests.md around lines 93 - 99, The |
| 2 | documentation uses "npx vitest run apps/desktop/src/git.test.ts" which |
| 3 | contradicts the Bun-centric tooling elsewhere; update the verification snippet |
| 4 | to use Bun's runner by replacing the "npx" invocation with "bunx" (so the second |
| 5 | command 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 |
| 7 | consistently 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
β οΈ 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
| 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.
| 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
| 1 | In `@apps/server/turbo.jsonc` around lines 5 - 10, The JSONC has trailing commas |
| 2 | after the "persistent" and closing object entries which causes parse errors; |
| 3 | edit 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 |
| 6 | extra 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, "-");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
π’ 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.
| 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
| 1 | In file apps/server/src/git.ts around line 191: |
| 2 | |
| 3 | 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. |
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();π’ 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
| 1 | In file apps/server/src/git.ts around line 55: |
| 2 | |
| 3 | Multi-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) {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
β οΈ 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
| 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.
| 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
| 1 | In `@apps/desktop/turbo.jsonc` around lines 5 - 23, The JSONC contains trailing |
| 2 | commas after object entries which cause parse errors; remove the trailing commas |
| 3 | in the "build", "dev", "start", and "smoke-test" task objects (specifically the |
| 4 | commas following the "outputs", "persistent", "persistent"/"cache", and |
| 5 | "outputs" lines) so each object ends with a closing brace without a trailing |
| 6 | comma 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, "'\\''")}'`,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
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
β οΈ 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
| 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.
| 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
| 1 | In `@apps/web/src/components/BranchToolbar.tsx` around lines 51 - 59, The chained |
| 2 | mutation in createBranchMutation (mutationFn calling api.git.createBranch then |
| 3 | api.git.checkout) can yield partial success where createBranch succeeds but |
| 4 | checkout fails, causing an incorrect "Failed to create branch" error and no |
| 5 | cache refresh; change the flow so createBranch is a standalone operation that |
| 6 | calls queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] |
| 7 | }) on its onSuccess, then perform checkout as a separate step (either a |
| 8 | follow-up mutation or a try/catch after createBranch) and handle checkout errors |
| 9 | distinctly by calling setThreadError with a message like "Branch created but |
| 10 | checkout failed: <checkout error>". Make sure to reference the existing |
| 11 | createBranchMutation, api!.git.createBranch, api!.git.checkout, onSuccess, |
| 12 | onError, queryClient.invalidateQueries, and setThreadError when making these |
| 13 | changes. |
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
β οΈ 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
| 1 | In `@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 |
| 3 | entry to a stable 7.x release (for example "^7.21.0" or the latest 7.x), update |
| 4 | the lockfile by running your package manager (npm/yarn/pnpm install), and run |
| 5 | the dev/build/test commands to verify nothing breaks; edit the "vite" key in |
| 6 | package.json to replace the beta version, commit the lockfile changes, and |
| 7 | ensure 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
β οΈ 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:
Node serializes your command/args into a single command line for the shell. On Windows, Node has special behavior around argument quoting;
windowsVerbatimArgumentscontrols whether Node does any quoting/escaping, and itβs set totrueautomatically when the shell is CMD. [1]cmd.exethen applies its own quote rules to the string after/c(or/k). With/s,cmduses 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:js1 spawn('git', ['commit', '-m', 'message with spaces'], { shell: false }); This avoids
cmd.exequoting 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:js1 spawn('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^) becauseshell: truemeans 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:
| 1 | spawn('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
| 1 | In `@apps/server/src/git.ts` around lines 122 - 183, The git commands in |
| 2 | createGitWorktree, removeGitWorktree, createGitBranch, and checkoutGitBranch are |
| 3 | built as single shell strings with single quotes (e.g., using |
| 4 | escapeSingleQuotes) which breaks on Windows cmd.exe; change each to spawn git |
| 5 | directly with an args array instead of a shell command string (e.g., call |
| 6 | runTerminalCommand or child_process.spawn with ['git', 'worktree', 'add', '-b', |
| 7 | input.newBranch, worktreePath, input.branch'] and shell: false), remove the |
| 8 | single-quote escaping (no escapeSingleQuotes wrapping), keep cwd and timeoutMs, |
| 9 | and ensure failures still throw the same errors based on |
| 10 | result.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
β οΈ 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
| 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.
| 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
| 1 | In `@apps/web/turbo.jsonc` around lines 5 - 8, The JSONC parse errors are caused |
| 2 | by 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 |
| 4 | object 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 |
| 6 | the 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);β οΈ Potential issue | π‘ Minor
π§© Analysis chain
π Script executed:
| 1 | #!/bin/bash |
| 2 | # Search for PORT and ELECTRON_RENDERER_PORT usage patterns |
| 3 | |
| 4 | echo "=== Web app vite.config.ts port usage ===" |
| 5 | grep -n "PORT\|port" apps/web/vite.config.ts | head -20 |
| 6 | |
| 7 | echo "" |
| 8 | echo "=== Desktop dev script ELECTRON_RENDERER_PORT usage ===" |
| 9 | grep -n "ELECTRON_RENDERER_PORT\|port" apps/desktop/scripts/dev-electron.mjs | head -20 |
| 10 | |
| 11 | echo "" |
| 12 | echo "=== Search all usages of PORT env var ===" |
| 13 | rg -n "process\.env\.PORT" --type ts --type js -A 2 -B 2 |
| 14 | |
| 15 | echo "" |
| 16 | echo "=== Search all usages of ELECTRON_RENDERER_PORT ===" |
| 17 | rg -n "ELECTRON_RENDERER_PORT" --type ts --type js --type mjs -A 2 -B 2 |
| 18 | |
| 19 | echo "" |
| 20 | echo "=== Check for any .env files or examples ===" |
| 21 | fd "\.env" . -type f |
Repository: pingdotgg/codething-mvp
Length of output: 1296
π Script executed:
| 1 | #!/bin/bash |
| 2 | # Check for coordinated dev scripts and documentation |
| 3 | |
| 4 | echo "=== Root package.json dev scripts ===" |
| 5 | cat package.json | grep -A 20 '"scripts"' | head -30 |
| 6 | |
| 7 | echo "" |
| 8 | echo "=== Check for development documentation ===" |
| 9 | find . -maxdepth 2 -name "*.md" -type f | xargs grep -l "PORT\|port\|dev" 2>/dev/null | head -5 |
| 10 | |
| 11 | echo "" |
| 12 | echo "=== List all dev-related scripts in monorepo ===" |
| 13 | fd "package.json" | xargs grep -l "\"dev\"" 2>/dev/null |
| 14 | |
| 15 | echo "" |
| 16 | echo "=== Check apps/web package.json ===" |
| 17 | cat apps/web/package.json | grep -A 5 '"scripts"' 2>/dev/null || echo "Not found" |
| 18 | |
| 19 | echo "" |
| 20 | echo "=== Check apps/desktop package.json ===" |
| 21 | cat 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
| 1 | In `@apps/web/vite.config.ts` at line 5, The dev port is read from |
| 2 | process.env.PORT (const port) while the desktop dev script expects |
| 3 | ELECTRON_RENDERER_PORT, causing mismatches; update the port resolution in the |
| 4 | vite config (where const port is defined) to prefer ELECTRON_RENDERER_PORT first |
| 5 | and then fall back to PORT and then 5173 (or switch both web and desktop scripts |
| 6 | to a single agreed env name), and ensure any related npm scripts or docs |
| 7 | reference the chosen env variable so Electron can always connect to the web dev |
| 8 | server. |
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
π’ 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.
| 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
| 1 | In file apps/web/src/components/BranchToolbar.tsx around lines 57-58: |
| 2 | |
| 3 | 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. |
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("* "),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
β οΈ 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
| 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
| 1 | In `@apps/web/src/components/BranchToolbar.tsx` around lines 61 - 73, The |
| 2 | auto-sync useEffect in BranchToolbar (the effect that checks branches.find and |
| 3 | dispatches SET_THREAD_BRANCH) is overwriting a user-selected worktree base |
| 4 | branch; change the guard so the effect only runs for true local threads (e.g., |
| 5 | when activeThread exists AND activeThread.worktreePath is falsy AND the thread |
| 6 | is in local mode) or only when activeThread.branch is null/undefined, then |
| 7 | proceed to find the current branch and dispatch SET_THREAD_BRANCH; update the |
| 8 | useEffect condition that currently checks (!activeThread || |
| 9 | activeThread.worktreePath) to include the extra guard (or replace it with a |
| 10 | check for activeThread.branch == null) so selecting a different base branch for |
| 11 | a pending worktree is not clobbered. |
β Addressed in commit 807e3d1