From 198cc58377fd87ff39b8a37464dd8cb1a77beb0b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Feb 2026 14:15:30 -0800 Subject: [PATCH] Fix envMode reset bug and use shell-free git spawn - ChatView: change useEffect dependency from [activeThread] (new object ref every dispatch) to [activeThread?.id, activeThread?.worktreePath] so envMode is no longer silently reset to "local" on unrelated store dispatches, making the worktree code path reachable. - git.ts: replace shell command strings (runTerminalCommand + single-quote escaping) with a runGit helper that spawns git directly via argv array. Eliminates Windows quoting issues and the command injection surface. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/git.ts | 91 +++++++++++++++++----------- apps/web/src/components/ChatView.tsx | 2 +- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index bdb5d1b608c6bdc69fccbfb7d78a6da1bf3beb26..e6e06cb214ffbd52f994cc9314576c942c67c0d5 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -15,8 +15,46 @@ import type { TerminalCommandResult, } from "@t3tools/contracts"; -function escapeSingleQuotes(value: string): string { - return value.replace(/'/g, "'\\''"); +/** Spawn git directly with an argv array — no shell, no quoting needed. */ +function runGit( + args: string[], + cwd: string, + timeoutMs = 30_000, +): Promise { + 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( @@ -78,11 +116,7 @@ export async function runTerminalCommand( } export async function listGitBranches(input: GitListBranchesInput): Promise { - const result = await runTerminalCommand({ - command: "git branch --no-color", - cwd: input.cwd, - timeoutMs: 10_000, - }); + const result = await runGit(["branch", "--no-color"], input.cwd, 10_000); if (result.code !== 0) { const stderr = result.stderr.trim(); @@ -93,11 +127,11 @@ export async function listGitBranches(input: GitListBranchesInput): Promise { - const result = await runTerminalCommand({ - command: `git worktree remove '${escapeSingleQuotes(input.path)}'`, - cwd: input.cwd, - timeoutMs: 15_000, - }); + 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"); @@ -159,11 +188,7 @@ export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise< } export async function createGitBranch(input: GitCreateBranchInput): Promise { - const result = await runTerminalCommand({ - command: `git branch '${escapeSingleQuotes(input.branch)}'`, - cwd: input.cwd, - timeoutMs: 10_000, - }); + const result = await runGit(["branch", input.branch], input.cwd, 10_000); if (result.code !== 0) { throw new Error(result.stderr.trim() || "git branch create failed"); @@ -171,11 +196,7 @@ export async function createGitBranch(input: GitCreateBranchInput): Promise { - const result = await runTerminalCommand({ - command: `git checkout '${escapeSingleQuotes(input.branch)}'`, - cwd: input.cwd, - timeoutMs: 10_000, - }); + const result = await runGit(["checkout", input.branch], input.cwd, 10_000); if (result.code !== 0) { throw new Error(result.stderr.trim() || "git checkout failed"); @@ -183,11 +204,7 @@ export async function checkoutGitBranch(input: GitCheckoutInput): Promise } export async function initGitRepo(input: GitInitInput): Promise { - const result = await runTerminalCommand({ - command: "git init", - cwd: input.cwd, - timeoutMs: 10_000, - }); + const result = await runGit(["init"], input.cwd, 10_000); if (result.code !== 0) { throw new Error(result.stderr.trim() || "git init failed"); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index edb24386c87709b1a7b6470d3ae45c607cdd1b0f..6a36885f19ea19909223a2e4054d044423e66bfe 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -319,7 +319,7 @@ export default function ChatView() { useEffect(() => { if (!activeThread) return; setEnvMode(activeThread.worktreePath ? "worktree" : "local"); - }, [activeThread]); + }, [activeThread?.id, activeThread?.worktreePath]); // Auto-resize textarea useEffect(() => { -- 2.54.0