- 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.
Unified · Split
| @@ -15,8 +15,46 @@ import type { · 14 unmodified lines | ||
| 15 | 15 | TerminalCommandResult, |
| 16 | 16 | } from "@t3tools/contracts"; |
| 17 | 17 | |
| 18 | −function escapeSingleQuotes(value: string): string { | |
| 19 | − return value.replace(/'/g, "'\\''"); | |
| 18 | +/** Spawn git directly with an argv array — no shell, no quoting needed. */ | |
| 19 | +function runGit( | |
| 20 | + args: string[], | |
| 21 | + cwd: string, | |
| 22 | + timeoutMs = 30_000, | |
| 23 | +): Promise<TerminalCommandResult> { | |
| 24 | + return new Promise((resolve, reject) => { | |
| 25 | + const child = spawn("git", args, { | |
| 26 | + cwd, | |
| 27 | + env: process.env, | |
| 28 | + stdio: ["ignore", "pipe", "pipe"], | |
| 29 | + }); | |
| 30 | + | |
| 31 | + let stdout = ""; | |
| 32 | + let stderr = ""; | |
| 33 | + let timedOut = false; | |
| 34 | + | |
| 35 | + const timeout = setTimeout(() => { | |
| 36 | + timedOut = true; | |
| 37 | + child.kill("SIGTERM"); | |
| 38 | + setTimeout(() => { | |
| 39 | + if (!child.killed) child.kill("SIGKILL"); | |
| 40 | + }, 1_000).unref(); | |
| 41 | + }, timeoutMs); | |
| 42 | + | |
| 43 | + child.stdout?.on("data", (chunk: Buffer) => { | |
| 44 | + stdout += chunk.toString(); | |
| 45 | + }); | |
| 46 | + child.stderr?.on("data", (chunk: Buffer) => { | |
| 47 | + stderr += chunk.toString(); | |
| 48 | + }); | |
| 49 | + child.on("error", (error) => { | |
| 50 | + clearTimeout(timeout); | |
| 51 | + reject(error); | |
| 52 | + }); | |
| 53 | + child.on("close", (code, signal) => { | |
| 54 | + clearTimeout(timeout); | |
| 55 | + resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut }); | |
| 56 | + }); | |
| 57 | + }); | |
| 20 | 58 | } |
| 21 | 59 | |
| 22 | 60 | export async function runTerminalCommand( |
| @@ -78,11 +116,7 @@ export async function runTerminalCommand( · 55 unmodified lines | ||
| 78 | 116 | } |
| 79 | 117 | |
| 80 | 118 | export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { |
| 81 | − const result = await runTerminalCommand({ | |
| 82 | − command: "git branch --no-color", | |
| 83 | − cwd: input.cwd, | |
| 84 | − timeoutMs: 10_000, | |
| 85 | − }); | |
| 119 | + const result = await runGit(["branch", "--no-color"], input.cwd, 10_000); | |
| 86 | 120 | |
| 87 | 121 | if (result.code !== 0) { |
| 88 | 122 | const stderr = result.stderr.trim(); |
| @@ -93,11 +127,11 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL · 4 unmodified lines | ||
| 93 | 127 | } |
| 94 | 128 | |
| 95 | 129 | // Resolve the real default branch from the remote |
| 96 | − const defaultRef = await runTerminalCommand({ | |
| 97 | − command: "git symbolic-ref refs/remotes/origin/HEAD", | |
| 98 | − cwd: input.cwd, | |
| 99 | − timeoutMs: 5_000, | |
| 100 | − }); | |
| 130 | + const defaultRef = await runGit( | |
| 131 | + ["symbolic-ref", "refs/remotes/origin/HEAD"], | |
| 132 | + input.cwd, | |
| 133 | + 5_000, | |
| 134 | + ); | |
| 101 | 135 | const defaultBranch = |
| 102 | 136 | defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null; |
| 103 | 137 | |
| @@ -128,11 +162,10 @@ export async function createGitWorktree( · 24 unmodified lines | ||
| 128 | 162 | input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch); |
| 129 | 163 | |
| 130 | 164 | // Create a new branch from the base branch in a new worktree |
| 131 | − const result = await runTerminalCommand({ | |
| 132 | − command: `git worktree add -b '${escapeSingleQuotes(input.newBranch)}' '${escapeSingleQuotes(worktreePath)}' '${escapeSingleQuotes(input.branch)}'`, | |
| 133 | − cwd: input.cwd, | |
| 134 | − timeoutMs: 30_000, | |
| 135 | − }); | |
| 165 | + const result = await runGit( | |
| 166 | + ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], | |
| 167 | + input.cwd, | |
| 168 | + ); | |
| 136 | 169 | |
| 137 | 170 | if (result.code !== 0) { |
| 138 | 171 | throw new Error(result.stderr.trim() || "git worktree add failed"); |
| @@ -147,11 +180,7 @@ export async function createGitWorktree( · 8 unmodified lines | ||
| 147 | 180 | } |
| 148 | 181 | |
| 149 | 182 | export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> { |
| 150 | − const result = await runTerminalCommand({ | |
| 151 | − command: `git worktree remove '${escapeSingleQuotes(input.path)}'`, | |
| 152 | − cwd: input.cwd, | |
| 153 | − timeoutMs: 15_000, | |
| 154 | − }); | |
| 183 | + const result = await runGit(["worktree", "remove", input.path], input.cwd, 15_000); | |
| 155 | 184 | |
| 156 | 185 | if (result.code !== 0) { |
| 157 | 186 | throw new Error(result.stderr.trim() || "git worktree remove failed"); |
| @@ -159,11 +188,7 @@ export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise< · 1 unmodified line | ||
| 159 | 188 | } |
| 160 | 189 | |
| 161 | 190 | export async function createGitBranch(input: GitCreateBranchInput): Promise<void> { |
| 162 | − const result = await runTerminalCommand({ | |
| 163 | − command: `git branch '${escapeSingleQuotes(input.branch)}'`, | |
| 164 | − cwd: input.cwd, | |
| 165 | − timeoutMs: 10_000, | |
| 166 | − }); | |
| 191 | + const result = await runGit(["branch", input.branch], input.cwd, 10_000); | |
| 167 | 192 | |
| 168 | 193 | if (result.code !== 0) { |
| 169 | 194 | throw new Error(result.stderr.trim() || "git branch create failed"); |
| @@ -171,11 +196,7 @@ export async function createGitBranch(input: GitCreateBranchInput): Promise<void · 1 unmodified line | ||
| 171 | 196 | } |
| 172 | 197 | |
| 173 | 198 | export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> { |
| 174 | − const result = await runTerminalCommand({ | |
| 175 | − command: `git checkout '${escapeSingleQuotes(input.branch)}'`, | |
| 176 | − cwd: input.cwd, | |
| 177 | − timeoutMs: 10_000, | |
| 178 | − }); | |
| 199 | + const result = await runGit(["checkout", input.branch], input.cwd, 10_000); | |
| 179 | 200 | |
| 180 | 201 | if (result.code !== 0) { |
| 181 | 202 | throw new Error(result.stderr.trim() || "git checkout failed"); |
| @@ -183,11 +204,7 @@ export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> · 1 unmodified line | ||
| 183 | 204 | } |
| 184 | 205 | |
| 185 | 206 | export async function initGitRepo(input: GitInitInput): Promise<void> { |
| 186 | − const result = await runTerminalCommand({ | |
| 187 | − command: "git init", | |
| 188 | − cwd: input.cwd, | |
| 189 | − timeoutMs: 10_000, | |
| 190 | − }); | |
| 207 | + const result = await runGit(["init"], input.cwd, 10_000); | |
| 191 | 208 | |
| 192 | 209 | if (result.code !== 0) { |
| 193 | 210 | throw new Error(result.stderr.trim() || "git init failed"); |
| @@ -319,7 +319,7 @@ export default function ChatView() { · 318 unmodified lines | ||
| 319 | 319 | useEffect(() => { |
| 320 | 320 | if (!activeThread) return; |
| 321 | 321 | setEnvMode(activeThread.worktreePath ? "worktree" : "local"); |
| 322 | − }, [activeThread]); | |
| 322 | + }, [activeThread?.id, activeThread?.worktreePath]); | |
| 323 | 323 | |
| 324 | 324 | // Auto-resize textarea |
| 325 | 325 | useEffect(() => { |