@cyanheads/git-mcp-server 2.1.4 → 2.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { z } from "zod";
4
4
  // Import utils from barrel (logger from ../utils/internal/logger.js)
@@ -7,7 +7,7 @@ import { logger } from "../../../utils/index.js";
7
7
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
8
8
  // Import utils from barrel (sanitization from ../utils/security/sanitization.js)
9
9
  import { sanitization } from "../../../utils/index.js";
10
- const execAsync = promisify(exec);
10
+ const execFileAsync = promisify(execFile);
11
11
  // Define the input schema for the git_add tool using Zod
12
12
  export const GitAddInputSchema = z.object({
13
13
  path: z
@@ -76,52 +76,25 @@ export async function addGitFiles(input, context) {
76
76
  }
77
77
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
78
78
  }
79
- // Prepare the files argument for the command, ensuring proper quoting
80
- let filesArg;
81
- const filesToStage = input.files; // Keep original for reporting
82
- try {
83
- if (Array.isArray(filesToStage)) {
84
- if (filesToStage.length === 0) {
85
- logger.warning("Empty array provided for files, defaulting to staging all changes.", { ...context, operation });
86
- filesArg = "."; // Default to staging all if array is empty
87
- }
88
- else {
89
- // Quote each file path individually
90
- filesArg = filesToStage
91
- .map((file) => {
92
- const sanitizedFile = file.startsWith("-") ? `./${file}` : file; // Prefix with './' if it starts with a dash
93
- return `"${sanitizedFile.replace(/"/g, '\\"')}"`; // Escape quotes within path
94
- })
95
- .join(" ");
96
- }
97
- }
98
- else {
99
- // Single string case
100
- const sanitizedFile = filesToStage.startsWith("-")
101
- ? `./${filesToStage}`
102
- : filesToStage; // Prefix with './' if it starts with a dash
103
- filesArg = `"${sanitizedFile.replace(/"/g, '\\"')}"`;
104
- }
79
+ const filesToStage = Array.isArray(input.files)
80
+ ? input.files
81
+ : [input.files];
82
+ if (filesToStage.length === 0) {
83
+ filesToStage.push("."); // Default to staging all if array is empty
105
84
  }
106
- catch (err) {
107
- logger.error("File path validation/quoting failed", {
85
+ try {
86
+ const args = ["-C", targetPath, "add", "--"];
87
+ filesToStage.forEach((file) => {
88
+ // Sanitize each file path. Although execFile is safer,
89
+ // this prevents arguments like "-v" from being treated as flags by git.
90
+ const sanitizedFile = file.startsWith("-") ? `./${file}` : file;
91
+ args.push(sanitizedFile);
92
+ });
93
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
108
94
  ...context,
109
95
  operation,
110
- files: filesToStage,
111
- error: err,
112
96
  });
113
- throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid file path/pattern provided: ${err instanceof Error ? err.message : String(err)}`, { context, operation, originalError: err });
114
- }
115
- // This check should ideally not be needed now due to the logic above
116
- if (!filesArg) {
117
- logger.error("Internal error: filesArg is unexpectedly empty after processing.", { ...context, operation });
118
- throw new McpError(BaseErrorCode.INTERNAL_ERROR, "Internal error preparing git add command.", { context, operation });
119
- }
120
- try {
121
- // Use the resolved targetPath
122
- const command = `git -C "${targetPath}" add -- ${filesArg}`;
123
- logger.debug(`Executing command: ${command}`, { ...context, operation });
124
- const { stdout, stderr } = await execAsync(command);
97
+ const { stdout, stderr } = await execFileAsync("git", args);
125
98
  if (stderr) {
126
99
  // Log stderr as warning, as 'git add' can produce warnings but still succeed.
127
100
  logger.warning(`Git add command produced stderr`, {
@@ -162,7 +135,7 @@ export async function addGitFiles(input, context) {
162
135
  }
163
136
  if (errorMessage.toLowerCase().includes("did not match any files")) {
164
137
  // Still throw an error, but return structured info in the catch block of the registration
165
- throw new McpError(BaseErrorCode.NOT_FOUND, `Specified files/patterns did not match any files in ${targetPath}: ${filesArg}`, { context, operation, originalError: error, filesStaged: filesToStage });
138
+ throw new McpError(BaseErrorCode.NOT_FOUND, `Specified files/patterns did not match any files in ${targetPath}: ${filesToStage.join(", ")}`, { context, operation, originalError: error, filesStaged: filesToStage });
166
139
  }
167
140
  // Throw generic error for other cases
168
141
  throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to stage files for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error, filesStaged: filesToStage });
@@ -1,4 +1,4 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { z } from "zod";
4
4
  // Import utils from barrel (logger from ../utils/internal/logger.js)
@@ -7,7 +7,7 @@ import { logger } from "../../../utils/index.js";
7
7
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
8
8
  // Import utils from barrel (sanitization from ../utils/security/sanitization.js)
9
9
  import { sanitization } from "../../../utils/index.js";
10
- const execAsync = promisify(exec);
10
+ const execFileAsync = promisify(execFile);
11
11
  // Define the BASE input schema for the git_branch tool using Zod
12
12
  export const GitBranchBaseSchema = z.object({
13
13
  path: z
@@ -115,21 +115,23 @@ export async function gitBranchLogic(input, context) {
115
115
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
116
116
  }
117
117
  try {
118
- let command;
118
+ let args;
119
119
  let result;
120
120
  switch (input.mode) {
121
121
  case "list":
122
- command = `git -C "${targetPath}" branch --list --no-color`; // Start with basic list
123
- if (input.all)
124
- command += " -a"; // Add -a if requested
125
- else if (input.remote)
126
- command += " -r"; // Add -r if requested (exclusive with -a)
127
- command += " --verbose"; // Add verbose for commit info
128
- logger.debug(`Executing command: ${command}`, {
122
+ args = ["-C", targetPath, "branch", "--list", "--no-color"]; // Start with basic list
123
+ if (input.all) {
124
+ args.push("-a"); // Add -a if requested
125
+ }
126
+ else if (input.remote) {
127
+ args.push("-r"); // Add -r if requested (exclusive with -a)
128
+ }
129
+ args.push("--verbose"); // Add verbose for commit info
130
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
129
131
  ...context,
130
132
  operation,
131
133
  });
132
- const { stdout: listStdout } = await execAsync(command);
134
+ const { stdout: listStdout } = await execFileAsync("git", args);
133
135
  const branches = listStdout
134
136
  .trim()
135
137
  .split("\n")
@@ -157,17 +159,19 @@ export async function gitBranchLogic(input, context) {
157
159
  break;
158
160
  case "create":
159
161
  // branchName is validated by Zod refine
160
- command = `git -C "${targetPath}" branch `;
161
- if (input.force)
162
- command += "-f ";
163
- command += `"${input.branchName}"`; // branchName is guaranteed by refine
164
- if (input.startPoint)
165
- command += ` "${input.startPoint}"`;
166
- logger.debug(`Executing command: ${command}`, {
162
+ args = ["-C", targetPath, "branch"];
163
+ if (input.force) {
164
+ args.push("-f");
165
+ }
166
+ args.push(input.branchName); // branchName is guaranteed by refine
167
+ if (input.startPoint) {
168
+ args.push(input.startPoint);
169
+ }
170
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
167
171
  ...context,
168
172
  operation,
169
173
  });
170
- await execAsync(command);
174
+ await execFileAsync("git", args);
171
175
  result = {
172
176
  success: true,
173
177
  mode: "create",
@@ -177,16 +181,17 @@ export async function gitBranchLogic(input, context) {
177
181
  break;
178
182
  case "delete":
179
183
  // branchName is validated by Zod refine
180
- command = `git -C "${targetPath}" branch `;
181
- if (input.remote)
182
- command += "-r ";
183
- command += input.force ? "-D " : "-d ";
184
- command += `"${input.branchName}"`; // branchName is guaranteed by refine
185
- logger.debug(`Executing command: ${command}`, {
184
+ args = ["-C", targetPath, "branch"];
185
+ if (input.remote) {
186
+ args.push("-r");
187
+ }
188
+ args.push(input.force ? "-D" : "-d");
189
+ args.push(input.branchName); // branchName is guaranteed by refine
190
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
186
191
  ...context,
187
192
  operation,
188
193
  });
189
- const { stdout: deleteStdout } = await execAsync(command);
194
+ const { stdout: deleteStdout } = await execFileAsync("git", args);
190
195
  result = {
191
196
  success: true,
192
197
  mode: "delete",
@@ -198,14 +203,14 @@ export async function gitBranchLogic(input, context) {
198
203
  break;
199
204
  case "rename":
200
205
  // branchName and newBranchName validated by Zod refine
201
- command = `git -C "${targetPath}" branch `;
202
- command += input.force ? "-M " : "-m ";
203
- command += `"${input.branchName}" "${input.newBranchName}"`;
204
- logger.debug(`Executing command: ${command}`, {
206
+ args = ["-C", targetPath, "branch"];
207
+ args.push(input.force ? "-M" : "-m");
208
+ args.push(input.branchName, input.newBranchName);
209
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
205
210
  ...context,
206
211
  operation,
207
212
  });
208
- await execAsync(command);
213
+ await execFileAsync("git", args);
209
214
  result = {
210
215
  success: true,
211
216
  mode: "rename",
@@ -215,13 +220,13 @@ export async function gitBranchLogic(input, context) {
215
220
  };
216
221
  break;
217
222
  case "show-current":
218
- command = `git -C "${targetPath}" branch --show-current`;
219
- logger.debug(`Executing command: ${command}`, {
223
+ args = ["-C", targetPath, "branch", "--show-current"];
224
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
220
225
  ...context,
221
226
  operation,
222
227
  });
223
228
  try {
224
- const { stdout: currentStdout } = await execAsync(command);
229
+ const { stdout: currentStdout } = await execFileAsync("git", args);
225
230
  const currentBranchName = currentStdout.trim();
226
231
  result = {
227
232
  success: true,
@@ -1,4 +1,4 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { z } from "zod";
4
4
  // Import utils from barrel (logger from ../utils/internal/logger.js)
@@ -7,7 +7,7 @@ import { logger } from "../../../utils/index.js";
7
7
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
8
8
  // Import utils from barrel (sanitization from ../utils/security/sanitization.js)
9
9
  import { sanitization } from "../../../utils/index.js";
10
- const execAsync = promisify(exec);
10
+ const execFileAsync = promisify(execFile);
11
11
  // Define the input schema for the git_checkout tool using Zod
12
12
  export const GitCheckoutInputSchema = z.object({
13
13
  path: z
@@ -75,22 +75,22 @@ export async function checkoutGit(input, context) {
75
75
  throw error;
76
76
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
77
77
  }
78
- // Basic sanitization for branch/path argument
79
- const safeBranchOrPath = input.branchOrPath.replace(/[`$&;*()|<>]/g, ""); // Remove potentially dangerous characters
80
78
  try {
81
79
  // Construct the git checkout command
82
- let command = `git -C "${targetPath}" checkout`;
80
+ const args = ["-C", targetPath, "checkout"];
83
81
  if (input.force) {
84
- command += " --force";
82
+ args.push("--force");
85
83
  }
86
84
  if (input.newBranch) {
87
- const safeNewBranch = input.newBranch.replace(/[^a-zA-Z0-9_.\-/]/g, ""); // Sanitize new branch name
88
- command += ` -b ${safeNewBranch}`;
85
+ args.push("-b", input.newBranch);
89
86
  }
90
- command += ` ${safeBranchOrPath}`; // Add the target branch/path
91
- logger.debug(`Executing command: ${command}`, { ...context, operation });
87
+ args.push(input.branchOrPath); // Add the target branch/path
88
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
89
+ ...context,
90
+ operation,
91
+ });
92
92
  // Execute command. Checkout often uses stderr for status messages.
93
- const { stdout, stderr } = await execAsync(command);
93
+ const { stdout, stderr } = await execFileAsync("git", args);
94
94
  const message = stderr.trim() || stdout.trim();
95
95
  logger.debug(`Git checkout stdout: ${stdout}`, { ...context, operation });
96
96
  if (stderr) {
@@ -99,7 +99,12 @@ export async function checkoutGit(input, context) {
99
99
  // Get the current branch name after the checkout operation
100
100
  let currentBranch;
101
101
  try {
102
- const { stdout: branchStdout } = await execAsync(`git -C "${targetPath}" branch --show-current`);
102
+ const { stdout: branchStdout } = await execFileAsync("git", [
103
+ "-C",
104
+ targetPath,
105
+ "branch",
106
+ "--show-current",
107
+ ]);
103
108
  currentBranch = branchStdout.trim();
104
109
  }
105
110
  catch (e) {
@@ -1,4 +1,4 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { z } from "zod";
4
4
  // Import utils from barrel (logger from ../utils/internal/logger.js)
@@ -7,7 +7,7 @@ import { logger } from "../../../utils/index.js";
7
7
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
8
8
  // Import utils from barrel (sanitization from ../utils/security/sanitization.js)
9
9
  import { sanitization } from "../../../utils/index.js";
10
- const execAsync = promisify(exec);
10
+ const execFileAsync = promisify(execFile);
11
11
  // Define the input schema for the git_cherry-pick tool using Zod
12
12
  export const GitCherryPickInputSchema = z.object({
13
13
  path: z
@@ -95,20 +95,27 @@ export async function gitCherryPickLogic(input, context) {
95
95
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
96
96
  }
97
97
  try {
98
- let command = `git -C "${targetPath}" cherry-pick`;
99
- if (input.mainline)
100
- command += ` -m ${input.mainline}`;
101
- if (input.strategy)
102
- command += ` -X${input.strategy}`; // Note: -X for strategy options
103
- if (input.noCommit)
104
- command += " --no-commit";
105
- if (input.signoff)
106
- command += " --signoff";
107
- // Add the commit reference(s) - ensure it's treated as a single argument potentially containing special chars like '..'
108
- command += ` "${input.commitRef.replace(/"/g, '\\"')}"`;
109
- logger.debug(`Executing command: ${command}`, { ...context, operation });
98
+ const args = ["-C", targetPath, "cherry-pick"];
99
+ if (input.mainline) {
100
+ args.push("-m", String(input.mainline));
101
+ }
102
+ if (input.strategy) {
103
+ args.push(`-X${input.strategy}`);
104
+ } // Note: -X for strategy options
105
+ if (input.noCommit) {
106
+ args.push("--no-commit");
107
+ }
108
+ if (input.signoff) {
109
+ args.push("--signoff");
110
+ }
111
+ // Add the commit reference(s)
112
+ args.push(input.commitRef);
113
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
114
+ ...context,
115
+ operation,
116
+ });
110
117
  try {
111
- const { stdout, stderr } = await execAsync(command);
118
+ const { stdout, stderr } = await execFileAsync("git", args);
112
119
  // Check stdout/stderr for conflict messages, although exit code 0 usually means success
113
120
  const output = stdout + stderr;
114
121
  const conflicts = /conflict/i.test(output);
@@ -1,4 +1,4 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { z } from "zod";
4
4
  // Import utils from barrel (logger from ../utils/internal/logger.js)
@@ -7,7 +7,7 @@ import { logger } from "../../../utils/index.js";
7
7
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
8
8
  // Import utils from barrel (sanitization from ../utils/security/sanitization.js)
9
9
  import { sanitization } from "../../../utils/index.js";
10
- const execAsync = promisify(exec);
10
+ const execFileAsync = promisify(execFile);
11
11
  // Define the input schema for the git_clean tool using Zod
12
12
  // No refinements needed here, but the 'force' check is critical in the logic
13
13
  export const GitCleanInputSchema = z.object({
@@ -102,18 +102,21 @@ export async function gitCleanLogic(input, context) {
102
102
  try {
103
103
  // Construct the command
104
104
  // Force (-f) is always added because the logic checks input.force
105
- let command = `git -C "${targetPath}" clean -f`;
105
+ const args = ["-C", targetPath, "clean", "-f"];
106
106
  if (input.dryRun) {
107
- command += " -n";
107
+ args.push("-n");
108
108
  }
109
109
  if (input.directories) {
110
- command += " -d";
110
+ args.push("-d");
111
111
  }
112
112
  if (input.ignored) {
113
- command += " -x";
113
+ args.push("-x");
114
114
  }
115
- logger.debug(`Executing command: ${command}`, { ...context, operation });
116
- const { stdout, stderr } = await execAsync(command);
115
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
116
+ ...context,
117
+ operation,
118
+ });
119
+ const { stdout, stderr } = await execFileAsync("git", args);
117
120
  if (stderr) {
118
121
  // Log stderr as warning, as git clean might report non-fatal issues here
119
122
  logger.warning(`Git clean command produced stderr`, {
@@ -1,4 +1,4 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import fs from "fs/promises";
3
3
  import { promisify } from "util";
4
4
  import { z } from "zod";
@@ -8,7 +8,7 @@ import { logger } from "../../../utils/index.js";
8
8
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
9
9
  // Import utils from barrel (sanitization from ../utils/security/sanitization.js)
10
10
  import { sanitization } from "../../../utils/index.js";
11
- const execAsync = promisify(exec);
11
+ const execFileAsync = promisify(execFile);
12
12
  // Define the input schema for the git_clone tool using Zod
13
13
  export const GitCloneInputSchema = z.object({
14
14
  repositoryUrl: z
@@ -108,22 +108,26 @@ export async function gitCloneLogic(input, context) {
108
108
  }
109
109
  try {
110
110
  // Construct the git clone command
111
- // Use placeholders and pass args safely if possible, but exec requires string command. Be careful with quoting.
112
- let command = `git clone`;
111
+ const args = ["clone"];
113
112
  if (input.quiet) {
114
- command += " --quiet";
113
+ args.push("--quiet");
115
114
  }
116
115
  if (input.branch) {
117
- command += ` --branch "${input.branch.replace(/"/g, '\\"')}"`;
116
+ args.push("--branch", input.branch);
118
117
  }
119
118
  if (input.depth) {
120
- command += ` --depth ${input.depth}`;
119
+ args.push("--depth", String(input.depth));
121
120
  }
122
- // Add repo URL and target path (ensure they are quoted)
123
- command += ` "${sanitizedRepoUrl}" "${sanitizedTargetPath}"`;
124
- logger.debug(`Executing command: ${command}`, { ...context, operation });
121
+ // Add repo URL and target path
122
+ args.push(sanitizedRepoUrl, sanitizedTargetPath);
123
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
124
+ ...context,
125
+ operation,
126
+ });
125
127
  // Increase timeout for clone operations as they can take time
126
- const { stdout, stderr } = await execAsync(command, { timeout: 300000 }); // 5 minutes timeout
128
+ const { stdout, stderr } = await execFileAsync("git", args, {
129
+ timeout: 300000,
130
+ }); // 5 minutes timeout
127
131
  if (stderr && !input.quiet) {
128
132
  // Stderr often contains progress info, log as info if quiet is false
129
133
  logger.info(`Git clone command produced stderr (progress/info)`, {
@@ -1,4 +1,4 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { z } from "zod";
4
4
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global
@@ -8,7 +8,7 @@ import { logger } from "../../../utils/index.js";
8
8
  import { sanitization } from "../../../utils/index.js";
9
9
  // Import config to check signing flag
10
10
  import { config } from "../../../config/index.js";
11
- const execAsync = promisify(exec);
11
+ const execFileAsync = promisify(execFile);
12
12
  // Define the input schema for the git_commit tool using Zod
13
13
  export const GitCommitInputSchema = z.object({
14
14
  path: z
@@ -108,15 +108,12 @@ export async function commitGitChanges(input, context) {
108
108
  // Correctly pass targetPath as rootDir in options object
109
109
  const sanitizedFiles = input.filesToStage.map((file) => sanitization.sanitizePath(file, { rootDir: targetPath })
110
110
  .sanitizedPath); // Sanitize relative to repo root
111
- const filesToAddString = sanitizedFiles
112
- .map((file) => `"${file}"`)
113
- .join(" "); // Quote paths for safety
114
- const addCommand = `git -C "${targetPath}" add -- ${filesToAddString}`;
115
- logger.debug(`Executing git add command: ${addCommand}`, {
111
+ const addArgs = ["-C", targetPath, "add", "--", ...sanitizedFiles];
112
+ logger.debug(`Executing git add command: git ${addArgs.join(" ")}`, {
116
113
  ...context,
117
114
  operation,
118
115
  });
119
- await execAsync(addCommand);
116
+ await execFileAsync("git", addArgs);
120
117
  logger.info(`Successfully staged specified files: ${sanitizedFiles.join(", ")}`, { ...context, operation });
121
118
  }
122
119
  catch (addError) {
@@ -131,42 +128,24 @@ export async function commitGitChanges(input, context) {
131
128
  }
132
129
  }
133
130
  // --- End staging files ---
134
- // Escape message for shell safety
135
- const escapeShellArg = (arg) => {
136
- // Escape backslashes first, then other special chars
137
- return arg
138
- .replace(/\\/g, "\\\\")
139
- .replace(/"/g, '\\"')
140
- .replace(/`/g, "\\`")
141
- .replace(/\$/g, "\\$");
142
- };
143
- const escapedMessage = escapeShellArg(input.message);
144
131
  // Construct the git commit command using the resolved targetPath
145
- let command = `git -C "${targetPath}" commit -m "${escapedMessage}"`;
132
+ const args = ["-C", targetPath];
133
+ if (input.author) {
134
+ args.push("-c", `user.name=${input.author.name}`, "-c", `user.email=${input.author.email}`);
135
+ }
136
+ args.push("commit", "-m", input.message);
146
137
  if (input.allowEmpty) {
147
- command += " --allow-empty";
138
+ args.push("--allow-empty");
148
139
  }
149
140
  if (input.amend) {
150
- command += " --amend --no-edit";
141
+ args.push("--amend", "--no-edit");
151
142
  }
152
- if (input.author) {
153
- // Escape author details as well
154
- const escapedAuthorName = escapeShellArg(input.author.name);
155
- const escapedAuthorEmail = escapeShellArg(input.author.email); // Email typically safe, but escape anyway
156
- // Use -c flags to override author for this commit, using the already escaped message
157
- command = `git -C "${targetPath}" -c user.name="${escapedAuthorName}" -c user.email="${escapedAuthorEmail}" commit -m "${escapedMessage}"`;
158
- }
159
- // Append common flags (ensure they are appended to the potentially modified command from author block)
160
- if (input.allowEmpty && !command.includes(" --allow-empty"))
161
- command += " --allow-empty";
162
- if (input.amend && !command.includes(" --amend"))
163
- command += " --amend --no-edit"; // Avoid double adding if author block modified command
164
143
  // Append signing flag if configured via GIT_SIGN_COMMITS env var
165
144
  if (config.gitSignCommits) {
166
- command += " -S"; // Add signing flag (-S)
145
+ args.push("-S"); // Add signing flag (-S)
167
146
  logger.info("Signing enabled via GIT_SIGN_COMMITS=true, adding -S flag.", { ...context, operation });
168
147
  }
169
- logger.debug(`Executing initial command attempt: ${command}`, {
148
+ logger.debug(`Executing initial command attempt: git ${args.join(" ")}`, {
170
149
  ...context,
171
150
  operation,
172
151
  });
@@ -175,7 +154,7 @@ export async function commitGitChanges(input, context) {
175
154
  let commitResult;
176
155
  try {
177
156
  // Initial attempt (potentially with -S flag)
178
- const execResult = await execAsync(command);
157
+ const execResult = await execFileAsync("git", args);
179
158
  stdout = execResult.stdout;
180
159
  stderr = execResult.stderr;
181
160
  }
@@ -185,34 +164,12 @@ export async function commitGitChanges(input, context) {
185
164
  initialErrorMessage.includes("signing failed");
186
165
  if (isSigningError && input.forceUnsignedOnFailure) {
187
166
  logger.warning("Initial commit attempt failed due to signing error. Retrying without signing as forceUnsignedOnFailure=true.", { ...context, operation, initialError: initialErrorMessage });
188
- // Construct command *without* -S flag, using escaped message/author
189
- const escapeShellArg = (arg) => {
190
- return arg
191
- .replace(/\\/g, "\\\\")
192
- .replace(/"/g, '\\"')
193
- .replace(/`/g, "\\`")
194
- .replace(/\$/g, "\\$");
195
- };
196
- const escapedMessage = escapeShellArg(input.message);
197
- let unsignedCommand = `git -C "${targetPath}" commit -m "${escapedMessage}"`;
198
- if (input.allowEmpty)
199
- unsignedCommand += " --allow-empty";
200
- if (input.amend)
201
- unsignedCommand += " --amend --no-edit";
202
- if (input.author) {
203
- const escapedAuthorName = escapeShellArg(input.author.name);
204
- const escapedAuthorEmail = escapeShellArg(input.author.email);
205
- unsignedCommand = `git -C "${targetPath}" -c user.name="${escapedAuthorName}" -c user.email="${escapedAuthorEmail}" commit -m "${escapedMessage}"`;
206
- // Re-append common flags if author block overwrote command
207
- if (input.allowEmpty && !unsignedCommand.includes(" --allow-empty"))
208
- unsignedCommand += " --allow-empty";
209
- if (input.amend && !unsignedCommand.includes(" --amend"))
210
- unsignedCommand += " --amend --no-edit";
211
- }
212
- logger.debug(`Executing unsigned fallback command: ${unsignedCommand}`, { ...context, operation });
167
+ // Construct command *without* -S flag
168
+ const unsignedArgs = args.filter((arg) => arg !== "-S");
169
+ logger.debug(`Executing unsigned fallback command: git ${unsignedArgs.join(" ")}`, { ...context, operation });
213
170
  try {
214
171
  // Retry commit without signing
215
- const fallbackResult = await execAsync(unsignedCommand);
172
+ const fallbackResult = await execFileAsync("git", unsignedArgs);
216
173
  stdout = fallbackResult.stdout;
217
174
  stderr = fallbackResult.stderr;
218
175
  // Add a note to the status message indicating signing was skipped
@@ -281,12 +238,19 @@ export async function commitGitChanges(input, context) {
281
238
  if (commitHash) {
282
239
  try {
283
240
  // Get the list of files included in this specific commit
284
- const showCommand = `git -C "${targetPath}" show --pretty="" --name-only ${commitHash}`;
285
- logger.debug(`Executing git show command: ${showCommand}`, {
241
+ const showArgs = [
242
+ "-C",
243
+ targetPath,
244
+ "show",
245
+ "--pretty=",
246
+ "--name-only",
247
+ commitHash,
248
+ ];
249
+ logger.debug(`Executing git show command: git ${showArgs.join(" ")}`, {
286
250
  ...context,
287
251
  operation,
288
252
  });
289
- const { stdout: showStdout } = await execAsync(showCommand);
253
+ const { stdout: showStdout } = await execFileAsync("git", showArgs);
290
254
  committedFiles = showStdout.trim().split("\n").filter(Boolean); // Split by newline, remove empty lines
291
255
  logger.debug(`Retrieved committed files list for ${commitHash}`, {
292
256
  ...context,