History

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.

Julius Marminge committed 9 months ago

Commit 198cc581 parent 6e3726e2 files · +55 −38Browse files

Unified · Split

2 files
  1. apps/server/src/git.ts +54 −37
  2. apps/web/src/components/ChatView.tsx +1 −1
apps/server/src/git.ts+54−37
@@ -15,8 +15,46 @@ import type { · 14 unmodified lines
1515 TerminalCommandResult,
1616 } from "@t3tools/contracts";
1717
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+ });
2058 }
2159
2260 export async function runTerminalCommand(
@@ -78,11 +116,7 @@ export async function runTerminalCommand( · 55 unmodified lines
78116 }
79117
80118 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);
86120
87121 if (result.code !== 0) {
88122 const stderr = result.stderr.trim();
@@ -93,11 +127,11 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL · 4 unmodified lines
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( · 24 unmodified lines
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( · 8 unmodified lines
147180 }
148181
149182 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);
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< · 1 unmodified line
159188 }
160189
161190 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);
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 · 1 unmodified line
171196 }
172197
173198 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);
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> · 1 unmodified line
183204 }
184205
185206 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);
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() { · 318 unmodified lines
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(() => {

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t