| ... | @@ -15,8 +15,46 @@ import type { | ... | @@ -15,8 +15,46 @@ import type { |
| 15 | TerminalCommandResult, | 15 | TerminalCommandResult, |
| 16 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 17 | | 17 | |
| 18 | function escapeSingleQuotes(value: string): string { | 18 | /** Spawn git directly with an argv array — no shell, no quoting needed. */ |
| 19 | return value.replace(/'/g, "'\\''"); | 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 | export async function runTerminalCommand( | 60 | export async function runTerminalCommand( |
| ... | @@ -78,11 +116,7 @@ export async function runTerminalCommand( | ... | @@ -78,11 +116,7 @@ export async function runTerminalCommand( |
| 78 | } | 116 | } |
| 79 | | 117 | |
| 80 | export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { | 118 | export 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 | ... | @@ -93,11 +127,11 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL |
| 93 | } | 127 | } |
| 94 | | 128 | |
| 95 | // Resolve the real default branch from the remote | 129 | // 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( | ... | @@ -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); |
| 129 | | 163 | |
| 130 | // Create a new branch from the base branch in a new worktree | 164 | // 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( | ... | @@ -147,11 +180,7 @@ export async function createGitWorktree( |
| 147 | } | 180 | } |
| 148 | | 181 | |
| 149 | export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> { | 182 | export 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< | ... | @@ -159,11 +188,7 @@ export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise< |
| 159 | } | 188 | } |
| 160 | | 189 | |
| 161 | export async function createGitBranch(input: GitCreateBranchInput): Promise<void> { | 190 | export 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 | ... | @@ -171,11 +196,7 @@ export async function createGitBranch(input: GitCreateBranchInput): Promise<void |
| 171 | } | 196 | } |
| 172 | | 197 | |
| 173 | export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> { | 198 | export 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> | ... | @@ -183,11 +204,7 @@ export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> |
| 183 | } | 204 | } |
| 184 | | 205 | |
| 185 | export async function initGitRepo(input: GitInitInput): Promise<void> { | 206 | export 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"); |