@ezmodo/mcp-server 0.13.5 → 0.14.1

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.
@@ -23,19 +23,10 @@
23
23
 
24
24
  import { execFileSync } from 'child_process';
25
25
  import { existsSync, readFileSync } from 'fs';
26
- import { homedir } from 'os';
27
26
  import { join } from 'path';
28
-
29
- // `.config/ezmodo` is current, `.config/zephly` the pre-rebrand name still
30
- // present in older installs. Current wins; legacy is a read fallback only.
31
- function configDirs() {
32
- const home = homedir();
33
- if (process.platform === 'win32') {
34
- const base = process.env.APPDATA || home;
35
- return [join(base, 'ezmodo'), join(base, 'zephly')];
36
- }
37
- return [join(home, '.config', 'ezmodo'), join(home, '.config', 'zephly')];
38
- }
27
+ // Shared with lib/token-store.js so the two cannot disagree about where the
28
+ // ezmodo config directory is they write files side by side there.
29
+ import { configDirs } from './user-paths.js';
39
30
 
40
31
  /** The Linux/Windows path: a 0600 JSON file written by `ezmodo auth login`. */
41
32
  function fromCredentialsFile() {
@@ -22,10 +22,19 @@ import {
22
22
 
23
23
  import { TOOLS } from '../tools/index.js';
24
24
  import { HANDLERS } from '../handlers/index.js';
25
- import { PROMPTS, getPromptContent } from '../prompts/index.js';
25
+ import { listPrompts, getPromptContent } from '../prompts/index.js';
26
26
  import { MCP_VERSION } from './version.js';
27
27
  import { getLogger } from './logger.js';
28
28
  import { isRemoteSafe } from './remote-tools.js';
29
+ import {
30
+ EMAIL_ALREADY_REGISTERED,
31
+ NOT_AUTHENTICATED,
32
+ NO_ORGANIZATION,
33
+ emailAlreadyRegistered,
34
+ organizationRequired,
35
+ signInRequired,
36
+ } from './auth-guidance.js';
37
+ import { getInstructions } from './instructions.js';
29
38
 
30
39
  /**
31
40
  * @param {object} [options]
@@ -34,6 +43,44 @@ import { isRemoteSafe } from './remote-tools.js';
34
43
  * 'remote' excludes tools that operate on the local filesystem or git — see
35
44
  * lib/remote-tools.js for why that is an allowlist and not a denylist.
36
45
  */
46
+ /**
47
+ * Whether a failure means "nobody is signed in" rather than "the call went
48
+ * wrong".
49
+ *
50
+ * 401 counts as well as the no-credential case: a credential that WAS valid can
51
+ * stop being so — a revoked API key, or a refresh token whose grant expired
52
+ * while the editor sat open overnight — and telling the user to sign in is the
53
+ * right answer to both.
54
+ *
55
+ * 403 deliberately does NOT count. That means signed in but not permitted,
56
+ * most often a new account in no organization yet, and sending someone back
57
+ * through a sign-in that cannot fix it is worse than saying nothing. That case
58
+ * gets its own answer below — saying nothing was the placeholder, not the plan.
59
+ */
60
+ function isAuthFailure(error) {
61
+ return error?.code === NOT_AUTHENTICATED || error?.status === 401;
62
+ }
63
+
64
+ /**
65
+ * Whether a failure means "signed in, but in no organization" (#2639).
66
+ *
67
+ * Keyed on the API's code, never on the message: the API owns the wording and
68
+ * will improve it, and a prose match that silently stops matching degrades to
69
+ * the bare 403 this exists to replace — the failure would be invisible, since
70
+ * the call still fails either way, just uselessly.
71
+ */
72
+ function isNoOrganization(error) {
73
+ return error?.code === NO_ORGANIZATION;
74
+ }
75
+
76
+ /**
77
+ * Whether a failure means "signed in, but this email is already spoken for"
78
+ * (#2652). Same keying, and the same reason for it.
79
+ */
80
+ function isEmailAlreadyRegistered(error) {
81
+ return error?.code === EMAIL_ALREADY_REGISTERED;
82
+ }
83
+
37
84
  export function createServer({ surface = 'local' } = {}) {
38
85
  const log = getLogger();
39
86
 
@@ -46,7 +93,14 @@ export function createServer({ surface = 'local' } = {}) {
46
93
 
47
94
  const server = new Server(
48
95
  { name: 'ezmodo-mcp-server', version: MCP_VERSION },
49
- { capabilities: { tools: {}, prompts: {} } }
96
+ {
97
+ capabilities: { tools: {}, prompts: {} },
98
+ // Reaches every client on every session, which is what makes it the one
99
+ // place the work-tracking discipline can live without a per-editor
100
+ // plugin (#2633). Varies by surface for the same reason the tool list
101
+ // does.
102
+ instructions: getInstructions(surface),
103
+ }
50
104
  );
51
105
 
52
106
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
@@ -83,6 +137,41 @@ export function createServer({ surface = 'local' } = {}) {
83
137
  } catch (error) {
84
138
  const errMsg = error.message || String(error);
85
139
  log.error('Tool call failed', { tool: name, error: errMsg, durationMs: Date.now() - start });
140
+
141
+ // An authentication failure is not an error the model should relay as
142
+ // one — it is a request for the user to do something. Funnelled HERE, at
143
+ // the single dispatch point, for the same reason the surface filter is:
144
+ // every tool goes through it, so none of them can be missed or drift
145
+ // (#2632). Only on the local surface; over the connector Claude owns the
146
+ // OAuth and this advice would be wrong.
147
+ if (surface !== 'remote' && isAuthFailure(error)) {
148
+ return {
149
+ content: [{ type: 'text', text: JSON.stringify(signInRequired({ reason: errMsg }), null, 2) }],
150
+ isError: true,
151
+ };
152
+ }
153
+ // Answered on EVERY surface, unlike the sign-in prompt above. Sign-in
154
+ // advice is surface-specific because over the connector Claude owns the
155
+ // OAuth; a missing workspace is ours either way, and a claude.ai user
156
+ // hits it exactly as a local one does.
157
+ if (isNoOrganization(error)) {
158
+ return {
159
+ content: [
160
+ { type: 'text', text: JSON.stringify(organizationRequired({ reason: errMsg }), null, 2) },
161
+ ],
162
+ isError: true,
163
+ };
164
+ }
165
+ // Checked separately from the branch above, never merged into it: these
166
+ // two look alike from outside and their answers point opposite ways.
167
+ if (isEmailAlreadyRegistered(error)) {
168
+ return {
169
+ content: [
170
+ { type: 'text', text: JSON.stringify(emailAlreadyRegistered({ reason: errMsg }), null, 2) },
171
+ ],
172
+ isError: true,
173
+ };
174
+ }
86
175
  // Returned as content rather than thrown: a tool that fails is a result
87
176
  // the model can read and act on, not a transport error.
88
177
  return {
@@ -92,10 +181,14 @@ export function createServer({ surface = 'local' } = {}) {
92
181
  }
93
182
  });
94
183
 
95
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
184
+ // Filtered by surface exactly as tools are, and for the same reason: `submit`
185
+ // reads git SHAs and links commits, which a hosted server cannot do.
186
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
187
+ prompts: listPrompts(surface),
188
+ }));
96
189
 
97
190
  server.setRequestHandler(GetPromptRequestSchema, async (request) => {
98
- const content = getPromptContent(request.params.name);
191
+ const content = getPromptContent(request.params.name, request.params.arguments, surface);
99
192
  if (!content) {
100
193
  throw new Error(`Unknown prompt: ${request.params.name}`);
101
194
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The one place that decides which credential a request is made with.
3
+ *
4
+ * Four sources, and the ORDER is the contract (#2631):
5
+ *
6
+ * 1. The request context. Only ever set by the HTTP transport, where one
7
+ * process serves many callers and the credential belongs to the request
8
+ * rather than the process (#2599). Over stdio this is always empty.
9
+ * 2. EZMODO_API_KEY (or legacy ZEPHLY_API_KEY). An EXPLICIT credential must
10
+ * beat an implicit one, or overriding the key for a single project becomes
11
+ * impossible to reason about — and this is what keeps CI, containers and
12
+ * anything headless working exactly as before OAuth existed.
13
+ * 3. The OAuth token this server obtained for itself, refreshed if stale.
14
+ * The zero-configuration path: nothing to paste, nothing in a profile.
15
+ * 4. The credential `ezmodo auth login` stored. Last because it belongs to
16
+ * another program: a server that silently authenticates as whoever the
17
+ * CLI happens to be logged in as should do so only when nothing else
18
+ * said otherwise.
19
+ *
20
+ * 3 above 4 is the deliberate part. Both are implicit, so neither "wins" on
21
+ * explicitness; what separates them is that the OAuth token is THIS server's
22
+ * own, obtained by a person who was shown a consent screen naming this
23
+ * connector, while the CLI's key is a credential borrowed from a different
24
+ * tool. Prefer the one whose grant the user actually saw.
25
+ */
26
+
27
+ import { getApiKey } from './env.js';
28
+ import { getRequestContext, resolveApiKey } from './request-context.js';
29
+ import { getAccessToken, getSignedInIdentity } from './oauth.js';
30
+ import { readCliCredential } from './cli-credential.js';
31
+
32
+ /**
33
+ * The CLI credential, looked up at most once.
34
+ *
35
+ * Memoized because reading it is not free: on macOS it shells out to
36
+ * `security` to read the login Keychain, and doing that on every API call
37
+ * would put a subprocess spawn — and a possible ACL prompt — on the hot path.
38
+ * `undefined` means "not looked up yet"; `null` means "looked up, nothing
39
+ * there".
40
+ */
41
+ let cachedCliCredential;
42
+
43
+ function cliCredential() {
44
+ if (cachedCliCredential === undefined) {
45
+ cachedCliCredential = readCliCredential() ?? null;
46
+ }
47
+ return cachedCliCredential;
48
+ }
49
+
50
+ /**
51
+ * Resolve the credential for the call in flight.
52
+ *
53
+ * Returns null rather than throwing when there is nothing to use. Every caller
54
+ * is on the path of a tool call, and "sign in first" is something an agent can
55
+ * act on; an exception is not.
56
+ *
57
+ * @returns {Promise<{ token: string, source: string }|null>}
58
+ */
59
+ export async function resolveCredential() {
60
+ // Steps 1 and 2 together. Deliberately NOT re-implemented here:
61
+ // resolveApiKey() already means "request context, else environment", and a
62
+ // second copy of that precedence is how the two drift apart.
63
+ const explicit = resolveApiKey();
64
+ if (explicit) {
65
+ const fromRequest = Boolean(getRequestContext()?.apiKey);
66
+ return { token: explicit, source: fromRequest ? 'request' : 'EZMODO_API_KEY' };
67
+ }
68
+
69
+ const fromOAuth = await getAccessToken();
70
+ if (fromOAuth) {
71
+ const identity = getSignedInIdentity();
72
+ return { token: fromOAuth, source: identity?.email ? `OAuth (${identity.email})` : 'OAuth' };
73
+ }
74
+
75
+ const fromCli = cliCredential();
76
+ if (fromCli) return { token: fromCli.key, source: fromCli.source };
77
+
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * What the startup banner reports, without triggering a network refresh.
83
+ *
84
+ * Startup must not block on Keycloak: an expired token at boot would make the
85
+ * server hang before it ever spoke MCP, and the refresh happens on the first
86
+ * call anyway.
87
+ *
88
+ * @returns {{ source: string, detail?: string }|null}
89
+ */
90
+ export function describeCredentialSync() {
91
+ const fromEnv = getApiKey();
92
+ if (fromEnv) return { source: 'EZMODO_API_KEY', detail: `${fromEnv.substring(0, 12)}...` };
93
+
94
+ const identity = getSignedInIdentity();
95
+ if (identity) return { source: 'OAuth', detail: identity.email };
96
+
97
+ const fromCli = cliCredential();
98
+ if (fromCli) return { source: fromCli.source, detail: `${fromCli.key.substring(0, 12)}...` };
99
+
100
+ return null;
101
+ }
102
+
103
+ /** Test seam: forget the memoized CLI lookup. */
104
+ export function resetCredentialCache() {
105
+ cachedCliCredential = undefined;
106
+ }
@@ -3,26 +3,60 @@
3
3
  * Utility functions for git operations used by worktree tools
4
4
  */
5
5
 
6
- import { execSync } from 'child_process';
6
+ import { execFileSync } from 'child_process';
7
7
 
8
8
  /**
9
- * Execute git command with proper error handling
10
- * @param {string} command - Git command to execute
9
+ * Run git with an ARGUMENT ARRAY — never a command string, and never a shell.
10
+ *
11
+ * This used to be `execSync(command)`, with callers building the command by
12
+ * interpolating branch names and paths into a template. #2614 stopped the
13
+ * remote transport from reaching any of it, which removed the internet-facing
14
+ * blast radius; this closes the hole itself, so the same mistake cannot be made
15
+ * again by a future caller who is not thinking about shells.
16
+ *
17
+ * execFileSync spawns git directly, so the arguments are passed as-is: a value
18
+ * containing `;`, a backtick or a quote is a literal branch name that git will
19
+ * simply not find, rather than a second command. Nothing in this file quotes
20
+ * anything any more, because there is no shell left to quote for.
21
+ *
22
+ * @param {string[]} args - Arguments to git, one element per argument
11
23
  * @param {string} cwd - Working directory
12
- * @returns {string} Command output
24
+ * @returns {string} Command output, trimmed
13
25
  */
14
- export function execGit(command, cwd) {
26
+ export function execGit(args, cwd) {
27
+ if (!Array.isArray(args)) {
28
+ throw new TypeError('execGit takes an argument array, not a command string');
29
+ }
15
30
  try {
16
- return execSync(command, {
31
+ return execFileSync('git', args, {
17
32
  cwd,
18
33
  encoding: 'utf-8',
19
34
  stdio: 'pipe',
20
35
  }).trim();
21
36
  } catch (error) {
22
- throw new Error(`Git command failed: ${command}\n${error.message}`);
37
+ throw new Error(`Git command failed: git ${args.join(' ')}\n${error.message}`);
23
38
  }
24
39
  }
25
40
 
41
+ /**
42
+ * Reject a value that git would read as an OPTION rather than as a name.
43
+ *
44
+ * The residual risk after dropping the shell is not injection, it is option
45
+ * injection: `--exec=...` in a branch-name position is still one argument, and
46
+ * git still honours it. Callers pass user-supplied refs and paths, so the
47
+ * values that land in those positions are checked here.
48
+ *
49
+ * @param {string} value - The candidate branch name, ref or path
50
+ * @param {string} label - What it is, for the error message
51
+ * @returns {string} The value, unchanged
52
+ */
53
+ function assertNotOption(value, label) {
54
+ if (typeof value !== 'string' || value.startsWith('-')) {
55
+ throw new Error(`Invalid ${label}: ${value}`);
56
+ }
57
+ return value;
58
+ }
59
+
26
60
  /**
27
61
  * Check if directory is a git repository
28
62
  * @param {string} dir - Directory path
@@ -30,11 +64,7 @@ export function execGit(command, cwd) {
30
64
  */
31
65
  export function isGitRepository(dir) {
32
66
  try {
33
- execSync('git rev-parse --git-dir', {
34
- cwd: dir,
35
- encoding: 'utf-8',
36
- stdio: 'pipe',
37
- });
67
+ execGit(['rev-parse', '--git-dir'], dir);
38
68
  return true;
39
69
  } catch {
40
70
  return false;
@@ -48,11 +78,7 @@ export function isGitRepository(dir) {
48
78
  */
49
79
  export function getRepositoryRoot(dir) {
50
80
  try {
51
- return execSync('git rev-parse --show-toplevel', {
52
- cwd: dir,
53
- encoding: 'utf-8',
54
- stdio: 'pipe',
55
- }).trim();
81
+ return execGit(['rev-parse', '--show-toplevel'], dir);
56
82
  } catch {
57
83
  return null;
58
84
  }
@@ -65,7 +91,7 @@ export function getRepositoryRoot(dir) {
65
91
  */
66
92
  export function getRemoteUrl(repoPath) {
67
93
  try {
68
- return execGit('git config --get remote.origin.url', repoPath);
94
+ return execGit(['config', '--get', 'remote.origin.url'], repoPath);
69
95
  } catch {
70
96
  return null;
71
97
  }
@@ -78,7 +104,7 @@ export function getRemoteUrl(repoPath) {
78
104
  */
79
105
  export function getCurrentBranch(repoPath) {
80
106
  try {
81
- return execGit('git branch --show-current', repoPath);
107
+ return execGit(['branch', '--show-current'], repoPath);
82
108
  } catch {
83
109
  return null;
84
110
  }
@@ -93,15 +119,17 @@ export function getCurrentBranch(repoPath) {
93
119
  export function branchExists(repoPath, branchName) {
94
120
  const result = { local: false, remote: false };
95
121
 
122
+ assertNotOption(branchName, 'branch name');
123
+
96
124
  try {
97
- execGit(`git show-ref --verify refs/heads/${branchName}`, repoPath);
125
+ execGit(['show-ref', '--verify', `refs/heads/${branchName}`], repoPath);
98
126
  result.local = true;
99
127
  } catch {
100
128
  // Branch doesn't exist locally
101
129
  }
102
130
 
103
131
  try {
104
- execGit(`git show-ref --verify refs/remotes/origin/${branchName}`, repoPath);
132
+ execGit(['show-ref', '--verify', `refs/remotes/origin/${branchName}`], repoPath);
105
133
  result.remote = true;
106
134
  } catch {
107
135
  // Branch doesn't exist remotely
@@ -118,18 +146,21 @@ export function branchExists(repoPath, branchName) {
118
146
  * @returns {string} Commit hash
119
147
  */
120
148
  export function createBranch(repoPath, branchName, baseBranch) {
149
+ assertNotOption(branchName, 'branch name');
150
+ assertNotOption(baseBranch, 'base branch');
151
+
121
152
  // Ensure base branch is up to date
122
153
  try {
123
- execGit(`git fetch origin ${baseBranch}`, repoPath);
154
+ execGit(['fetch', 'origin', baseBranch], repoPath);
124
155
  } catch {
125
156
  // Fetch might fail if no remote, that's ok
126
157
  }
127
158
 
128
159
  // Create branch
129
- execGit(`git branch ${branchName} ${baseBranch}`, repoPath);
160
+ execGit(['branch', branchName, baseBranch], repoPath);
130
161
 
131
162
  // Get commit hash
132
- return execGit(`git rev-parse ${branchName}`, repoPath);
163
+ return execGit(['rev-parse', branchName], repoPath);
133
164
  }
134
165
 
135
166
  /**
@@ -141,8 +172,23 @@ export function createBranch(repoPath, branchName, baseBranch) {
141
172
  * @returns {void}
142
173
  */
143
174
  export function createWorktree(repoPath, worktreePath, branchName, createBranch = false) {
144
- const branchFlag = createBranch ? '-b' : '';
145
- execGit(`git worktree add ${branchFlag} "${worktreePath}" ${branchName}`, repoPath);
175
+ assertNotOption(worktreePath, 'worktree path');
176
+ assertNotOption(branchName, 'branch name');
177
+
178
+ // `git worktree add -b <new-branch> <path>` — the branch name comes FIRST
179
+ // after -b. The string-building version had it the other way round
180
+ // (`add -b "<path>" <branch>`), so git read the path as the branch name and
181
+ // refused it. Nothing caught that because every caller in worktree-tools.js
182
+ // passes createBranch = false, having created the branch already; this path
183
+ // has never worked. Corrected here rather than left as a trap, since the
184
+ // whole point of moving to an argument array is that the arguments are now
185
+ // legible.
186
+ execGit(
187
+ createBranch
188
+ ? ['worktree', 'add', '-b', branchName, worktreePath]
189
+ : ['worktree', 'add', worktreePath, branchName],
190
+ repoPath
191
+ );
146
192
  }
147
193
 
148
194
  /**
@@ -152,7 +198,7 @@ export function createWorktree(repoPath, worktreePath, branchName, createBranch
152
198
  */
153
199
  export function listWorktrees(repoPath) {
154
200
  try {
155
- const output = execGit('git worktree list --porcelain', repoPath);
201
+ const output = execGit(['worktree', 'list', '--porcelain'], repoPath);
156
202
  const worktrees = [];
157
203
  const lines = output.split('\n');
158
204
 
@@ -190,8 +236,9 @@ export function listWorktrees(repoPath) {
190
236
  * @returns {void}
191
237
  */
192
238
  export function removeWorktree(repoPath, worktreePath, force = false) {
193
- const forceFlag = force ? '--force' : '';
194
- execGit(`git worktree remove ${forceFlag} "${worktreePath}"`, repoPath);
239
+ assertNotOption(worktreePath, 'worktree path');
240
+
241
+ execGit(['worktree', 'remove', ...(force ? ['--force'] : []), worktreePath], repoPath);
195
242
  }
196
243
 
197
244
  /**
@@ -209,7 +256,7 @@ export function getWorktreeStatus(worktreePath) {
209
256
  };
210
257
 
211
258
  try {
212
- const output = execGit('git status --porcelain', worktreePath);
259
+ const output = execGit(['status', '--porcelain'], worktreePath);
213
260
 
214
261
  if (output) {
215
262
  status.isDirty = true;
@@ -248,19 +295,22 @@ export function getAheadBehindCounts(worktreePath, localBranch, remoteBranch) {
248
295
  const counts = { ahead: 0, behind: 0 };
249
296
 
250
297
  try {
298
+ assertNotOption(localBranch, 'local branch');
299
+ assertNotOption(remoteBranch, 'remote branch');
300
+
251
301
  // Fetch latest
252
- execGit('git fetch origin', worktreePath);
302
+ execGit(['fetch', 'origin'], worktreePath);
253
303
 
254
304
  // Get ahead count
255
305
  const aheadOutput = execGit(
256
- `git rev-list --count ${remoteBranch}..${localBranch}`,
306
+ ['rev-list', '--count', `${remoteBranch}..${localBranch}`],
257
307
  worktreePath
258
308
  );
259
309
  counts.ahead = parseInt(aheadOutput, 10) || 0;
260
310
 
261
311
  // Get behind count
262
312
  const behindOutput = execGit(
263
- `git rev-list --count ${localBranch}..${remoteBranch}`,
313
+ ['rev-list', '--count', `${localBranch}..${remoteBranch}`],
264
314
  worktreePath
265
315
  );
266
316
  counts.behind = parseInt(behindOutput, 10) || 0;
@@ -280,7 +330,7 @@ export function checkForConflicts(worktreePath) {
280
330
  const result = { hasConflicts: false, files: [] };
281
331
 
282
332
  try {
283
- const output = execGit('git status --porcelain', worktreePath);
333
+ const output = execGit(['status', '--porcelain'], worktreePath);
284
334
  const lines = output.split('\n').filter(l => l);
285
335
 
286
336
  for (const line of lines) {
@@ -310,8 +360,9 @@ export function checkForConflicts(worktreePath) {
310
360
  * @returns {void}
311
361
  */
312
362
  export function deleteBranch(repoPath, branchName, force = false) {
313
- const flag = force ? '-D' : '-d';
314
- execGit(`git branch ${flag} ${branchName}`, repoPath);
363
+ assertNotOption(branchName, 'branch name');
364
+
365
+ execGit(['branch', force ? '-D' : '-d', branchName], repoPath);
315
366
  }
316
367
 
317
368
  /**
@@ -323,7 +374,9 @@ export function deleteBranch(repoPath, branchName, force = false) {
323
374
  */
324
375
  export function isBranchMerged(repoPath, branchName, baseBranch) {
325
376
  try {
326
- const output = execGit(`git branch --merged ${baseBranch}`, repoPath);
377
+ assertNotOption(baseBranch, 'base branch');
378
+
379
+ const output = execGit(['branch', '--merged', baseBranch], repoPath);
327
380
  return output.includes(branchName);
328
381
  } catch {
329
382
  return false;
@@ -338,10 +391,12 @@ export function isBranchMerged(repoPath, branchName, baseBranch) {
338
391
  */
339
392
  export function getLastCommit(repoPath, branchName) {
340
393
  try {
341
- const hash = execGit(`git rev-parse ${branchName}`, repoPath);
342
- const author = execGit(`git log -1 --format=%an ${branchName}`, repoPath);
343
- const timestamp = execGit(`git log -1 --format=%ct ${branchName}`, repoPath);
344
- const message = execGit(`git log -1 --format=%s ${branchName}`, repoPath);
394
+ assertNotOption(branchName, 'branch name');
395
+
396
+ const hash = execGit(['rev-parse', branchName], repoPath);
397
+ const author = execGit(['log', '-1', '--format=%an', branchName], repoPath);
398
+ const timestamp = execGit(['log', '-1', '--format=%ct', branchName], repoPath);
399
+ const message = execGit(['log', '-1', '--format=%s', branchName], repoPath);
345
400
 
346
401
  return {
347
402
  hash,
@@ -397,7 +452,10 @@ export function generateBranchName(task, defaultPrefix = 'feature') {
397
452
  */
398
453
  export function getMergeBase(repoPath, branch1, branch2) {
399
454
  try {
400
- return execGit(`git merge-base ${branch1} ${branch2}`, repoPath);
455
+ assertNotOption(branch1, 'branch');
456
+ assertNotOption(branch2, 'branch');
457
+
458
+ return execGit(['merge-base', branch1, branch2], repoPath);
401
459
  } catch {
402
460
  return null;
403
461
  }
@@ -411,16 +469,16 @@ export function getMergeBase(repoPath, branch1, branch2) {
411
469
  export function getDefaultBranch(repoPath) {
412
470
  // Try to get from remote HEAD
413
471
  try {
414
- const ref = execGit('git symbolic-ref refs/remotes/origin/HEAD', repoPath);
472
+ const ref = execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], repoPath);
415
473
  return ref.replace('refs/remotes/origin/', '');
416
474
  } catch {
417
475
  // Fall back to checking if main or master exists
418
476
  try {
419
- execGit('git show-ref --verify refs/heads/main', repoPath);
477
+ execGit(['show-ref', '--verify', 'refs/heads/main'], repoPath);
420
478
  return 'main';
421
479
  } catch {
422
480
  try {
423
- execGit('git show-ref --verify refs/heads/master', repoPath);
481
+ execGit(['show-ref', '--verify', 'refs/heads/master'], repoPath);
424
482
  return 'master';
425
483
  } catch {
426
484
  return 'main'; // Default fallback
@@ -439,13 +497,17 @@ export function getDefaultBranch(repoPath) {
439
497
  export function getCommitsSince(repoPath, sinceCommit, branchName) {
440
498
  try {
441
499
  // Format: hash|short|author|email|timestamp|subject
500
+ assertNotOption(branchName, 'branch name');
501
+ if (sinceCommit) {
502
+ assertNotOption(sinceCommit, 'commit');
503
+ }
504
+
505
+ // No quotes around the format: there is no shell to strip them, so a
506
+ // quoted format string would reach git verbatim and appear in the output.
442
507
  const format = '%H|%h|%an|%ae|%ct|%s';
443
508
  const range = sinceCommit ? `${sinceCommit}..${branchName}` : branchName;
444
509
 
445
- const output = execGit(
446
- `git log --format="${format}" ${range}`,
447
- repoPath
448
- );
510
+ const output = execGit(['log', `--format=${format}`, range], repoPath);
449
511
 
450
512
  if (!output) {
451
513
  return [];
@@ -469,8 +531,9 @@ export function getCommitsSince(repoPath, sinceCommit, branchName) {
469
531
  }
470
532
 
471
533
  /**
472
- * A commit SHA, and nothing else. The value reaches execGit inside a shell
473
- * string, so anything that is not hex never gets there.
534
+ * A commit SHA, and nothing else. Kept after execGit stopped using a shell
535
+ * (#2614 step 5): it is still the cheapest way to tell a real hash from a ref
536
+ * that would make diff-tree mean something other than "this commit".
474
537
  */
475
538
  const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
476
539
 
@@ -497,7 +560,7 @@ export function getCommitFiles(repoPath, sha) {
497
560
  }
498
561
  try {
499
562
  const output = execGit(
500
- `git diff-tree --no-commit-id --name-only -r ${sha}`,
563
+ ['diff-tree', '--no-commit-id', '--name-only', '-r', sha],
501
564
  repoPath
502
565
  );
503
566
  return output ? output.split('\n').map(line => line.trim()).filter(Boolean) : [];