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
15 TerminalCommandResult,15 TerminalCommandResult,
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
17 17
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}
21 59
22export async function runTerminalCommand(60export async function runTerminalCommand(
@@ -78,11 +116,7 @@ export async function runTerminalCommand( · 55 unmodified lines
78}116}
79 117
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 });
86 120
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 · 4 unmodified lines
93 }127 }
94 128
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;
103 137
@@ -128,11 +162,10 @@ export async function createGitWorktree( · 24 unmodified lines
128 input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);162 input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);
129 163
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 });
136 169
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( · 8 unmodified lines
147}180}
148 181
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 });
155 184
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< · 1 unmodified line
159}188}
160 189
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 });
167 192
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 · 1 unmodified line
171}196}
172 197
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 });
179 200
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> · 1 unmodified line
183}204}
184 205
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 });
191 208
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() { · 318 unmodified lines
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]);
323 323
324 // Auto-resize textarea324 // Auto-resize textarea
325 useEffect(() => {325 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