authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 14:15:30-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 14:15:30-08:00
log198cc58377fd87ff39b8a37464dd8cb1a77beb0b
tree47ea78d543ae6d6e5a5a5e9fba17fc33dc117a86
parent6e3726ec32bb7d93b6004fb941df20876485032f

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 <noreply@anthropic.com>

2 files changed, 55 insertions(+), 38 deletions(-)

apps/server/src/git.ts+54-37
...@@ -15,8 +15,46 @@ import type {...@@ -15,8 +15,46 @@ import type {
15 TerminalCommandResult,15 TerminalCommandResult,
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
1717
18function escapeSingleQuotes(value: string): string {18/** Spawn git directly with an argv array — no shell, no quoting needed. */
19 return value.replace(/'/g, "'\\''");19function 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}
2159
22export async function runTerminalCommand(60export async function runTerminalCommand(
...@@ -78,11 +116,7 @@ export async function runTerminalCommand(...@@ -78,11 +116,7 @@ export async function runTerminalCommand(
78}116}
79117
80export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {118export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
81 const result = await runTerminalCommand({119 const result = await runGit(["branch", "--no-color"], input.cwd, 10_000);
82 command: "git branch --no-color",
83 cwd: input.cwd,
84 timeoutMs: 10_000,
85 });
86120
87 if (result.code !== 0) {121 if (result.code !== 0) {
88 const stderr = result.stderr.trim();122 const stderr = result.stderr.trim();
...@@ -93,11 +127,11 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL...@@ -93,11 +127,11 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
93 }127 }
94128
95 // Resolve the real default branch from the remote129 // Resolve the real default branch from the remote
96 const defaultRef = await runTerminalCommand({130 const defaultRef = await runGit(
97 command: "git symbolic-ref refs/remotes/origin/HEAD",131 ["symbolic-ref", "refs/remotes/origin/HEAD"],
98 cwd: input.cwd,132 input.cwd,
99 timeoutMs: 5_000,133 5_000,
100 });134 );
101 const defaultBranch =135 const defaultBranch =
102 defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;136 defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
103137
...@@ -128,11 +162,10 @@ export async function createGitWorktree(...@@ -128,11 +162,10 @@ export async function createGitWorktree(
128 input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);162 input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);
129163
130 // Create a new branch from the base branch in a new worktree164 // Create a new branch from the base branch in a new worktree
131 const result = await runTerminalCommand({165 const result = await runGit(
132 command: `git worktree add -b '${escapeSingleQuotes(input.newBranch)}' '${escapeSingleQuotes(worktreePath)}' '${escapeSingleQuotes(input.branch)}'`,166 ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch],
133 cwd: input.cwd,167 input.cwd,
134 timeoutMs: 30_000,168 );
135 });
136169
137 if (result.code !== 0) {170 if (result.code !== 0) {
138 throw new Error(result.stderr.trim() || "git worktree add failed");171 throw new Error(result.stderr.trim() || "git worktree add failed");
...@@ -147,11 +180,7 @@ export async function createGitWorktree(...@@ -147,11 +180,7 @@ export async function createGitWorktree(
147}180}
148181
149export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {182export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {
150 const result = await runTerminalCommand({183 const result = await runGit(["worktree", "remove", input.path], input.cwd, 15_000);
151 command: `git worktree remove '${escapeSingleQuotes(input.path)}'`,
152 cwd: input.cwd,
153 timeoutMs: 15_000,
154 });
155184
156 if (result.code !== 0) {185 if (result.code !== 0) {
157 throw new Error(result.stderr.trim() || "git worktree remove failed");186 throw new Error(result.stderr.trim() || "git worktree remove failed");
...@@ -159,11 +188,7 @@ export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<...@@ -159,11 +188,7 @@ export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<
159}188}
160189
161export async function createGitBranch(input: GitCreateBranchInput): Promise<void> {190export async function createGitBranch(input: GitCreateBranchInput): Promise<void> {
162 const result = await runTerminalCommand({191 const result = await runGit(["branch", input.branch], input.cwd, 10_000);
163 command: `git branch '${escapeSingleQuotes(input.branch)}'`,
164 cwd: input.cwd,
165 timeoutMs: 10_000,
166 });
167192
168 if (result.code !== 0) {193 if (result.code !== 0) {
169 throw new Error(result.stderr.trim() || "git branch create failed");194 throw new Error(result.stderr.trim() || "git branch create failed");
...@@ -171,11 +196,7 @@ export async function createGitBranch(input: GitCreateBranchInput): Promise<void...@@ -171,11 +196,7 @@ export async function createGitBranch(input: GitCreateBranchInput): Promise<void
171}196}
172197
173export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> {198export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> {
174 const result = await runTerminalCommand({199 const result = await runGit(["checkout", input.branch], input.cwd, 10_000);
175 command: `git checkout '${escapeSingleQuotes(input.branch)}'`,
176 cwd: input.cwd,
177 timeoutMs: 10_000,
178 });
179200
180 if (result.code !== 0) {201 if (result.code !== 0) {
181 throw new Error(result.stderr.trim() || "git checkout failed");202 throw new Error(result.stderr.trim() || "git checkout failed");
...@@ -183,11 +204,7 @@ export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void>...@@ -183,11 +204,7 @@ export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void>
183}204}
184205
185export async function initGitRepo(input: GitInitInput): Promise<void> {206export async function initGitRepo(input: GitInitInput): Promise<void> {
186 const result = await runTerminalCommand({207 const result = await runGit(["init"], input.cwd, 10_000);
187 command: "git init",
188 cwd: input.cwd,
189 timeoutMs: 10_000,
190 });
191208
192 if (result.code !== 0) {209 if (result.code !== 0) {
193 throw new Error(result.stderr.trim() || "git init failed");210 throw new Error(result.stderr.trim() || "git init failed");
apps/web/src/components/ChatView.tsx+1-1
...@@ -319,7 +319,7 @@ export default function ChatView() {...@@ -319,7 +319,7 @@ export default function ChatView() {
319 useEffect(() => {319 useEffect(() => {
320 if (!activeThread) return;320 if (!activeThread) return;
321 setEnvMode(activeThread.worktreePath ? "worktree" : "local");321 setEnvMode(activeThread.worktreePath ? "worktree" : "local");
322 }, [activeThread]);322 }, [activeThread?.id, activeThread?.worktreePath]);
323323
324 // Auto-resize textarea324 // Auto-resize textarea
325 useEffect(() => {325 useEffect(() => {