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 {
1515 TerminalCommandResult,
1616} from "@t3tools/contracts";
1717
18function escapeSingleQuotes(value: string): string {
19 return value.replace(/'/g, "'\\''");
18/** Spawn git directly with an argv array — no shell, no quoting needed. */
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 });
2058}
2159
2260export async function runTerminalCommand(
......@@ -78,11 +116,7 @@ export async function runTerminalCommand(
78116}
79117
80118export 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);
86120
87121 if (result.code !== 0) {
88122 const stderr = result.stderr.trim();
......@@ -93,11 +127,11 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
93127 }
94128
95129 // 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 );
101135 const defaultBranch =
102136 defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
103137
......@@ -128,11 +162,10 @@ export async function createGitWorktree(
128162 input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);
129163
130164 // 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 );
136169
137170 if (result.code !== 0) {
138171 throw new Error(result.stderr.trim() || "git worktree add failed");
......@@ -147,11 +180,7 @@ export async function createGitWorktree(
147180}
148181
149182export 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);
155184
156185 if (result.code !== 0) {
157186 throw new Error(result.stderr.trim() || "git worktree remove failed");
......@@ -159,11 +188,7 @@ export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<
159188}
160189
161190export 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);
167192
168193 if (result.code !== 0) {
169194 throw new Error(result.stderr.trim() || "git branch create failed");
......@@ -171,11 +196,7 @@ export async function createGitBranch(input: GitCreateBranchInput): Promise<void
171196}
172197
173198export 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);
179200
180201 if (result.code !== 0) {
181202 throw new Error(result.stderr.trim() || "git checkout failed");
......@@ -183,11 +204,7 @@ export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void>
183204}
184205
185206export 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);
191208
192209 if (result.code !== 0) {
193210 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() {
319319 useEffect(() => {
320320 if (!activeThread) return;
321321 setEnvMode(activeThread.worktreePath ? "worktree" : "local");
322 }, [activeThread]);
322 }, [activeThread?.id, activeThread?.worktreePath]);
323323
324324 // Auto-resize textarea
325325 useEffect(() => {