@rethunk/mcp-multi-root-git 2.4.0 → 2.6.0

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.
package/AGENTS.md CHANGED
@@ -22,7 +22,7 @@ IDEs injecting this as context: do not re-link from rules.
22
22
  | [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `loadPresetsFromGitTop`, `presetLoadErrorPayload`, `applyPresetNestedRoots`, `applyPresetParityPairs`; Zod schemas must match [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json) |
23
23
  | [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT`, **`MAX_ABSOLUTE_GIT_ROOTS`** (256), optional **`absoluteGitRoots`** on workspace pick |
24
24
  | [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
25
- | [`src/server/git-refs.ts`](src/server/git-refs.ts) | `isProtectedBranch`, `isSafeGitRefToken`, `isSafeGitRangeToken`, `isSafeGitAncestorRef`; `getCurrentBranch`, `resolveRef`, `isWorkingTreeClean`, `isFullyMergedInto`, `commitListBetween`; `listWorktrees`, `worktreeForBranch`; `conflictPaths` |
25
+ | [`src/server/git-refs.ts`](src/server/git-refs.ts) | `isProtectedBranch`, `isSafeGitRefToken`, `isSafeGitRangeToken`, `isSafeGitAncestorRef`; `getCurrentBranch`, `resolveRef`, `isWorkingTreeClean`, `isFullyMergedInto`, `isContentEquivalentlyMergedInto`, `commitListBetween`; `listWorktrees`, `worktreeForBranch`; `inferRemoteFromUpstream`; `conflictPaths` |
26
26
  | [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below |
27
27
  | [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `git_status` |
28
28
  | [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `git_inventory` |
@@ -48,7 +48,7 @@ IDEs injecting this as context: do not re-link from rules.
48
48
  ## Changing contracts
49
49
 
50
50
  - **No banner paragraphs** in shipped docs. Use normal titles + cross-links.
51
- - **JSON format version** (currently `"3"`, discoverable via MCP `initialize`): bump on incompatible JSON changes (renamed/nested/omitted fields). Document migration here + [docs/mcp-tools.md](docs/mcp-tools.md). v2 removed the `rethunkGitMcp` envelope; payloads are minified; optional fields omitted when empty/null/false. v3 changes in `git_log`: `sha7` → `sha` (full SHA), `workspace_root` → `workspaceRoot`, `ageRelative` removed, `email` omitted when empty.
51
+ - **JSON format version** (currently `"3"`, exported as `MCP_JSON_FORMAT_VERSION` in `src/server.ts` and surfaced via the FastMCP `instructions` field — discoverable from the MCP `initialize` response): bump on incompatible JSON changes (renamed/nested/omitted fields). Document migration here + [docs/mcp-tools.md](docs/mcp-tools.md). v2 removed the `rethunkGitMcp` envelope; payloads are minified; optional fields omitted when empty/null/false. v3 changes in `git_log`: `sha7` → `sha` (full SHA), `workspace_root` → `workspaceRoot`, `ageRelative` removed, `email` omitted when empty.
52
52
  - **Preset file:** keep `presets.ts` Zod schemas aligned with [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json).
53
53
  - **Public tool surface:** rename/add → update [docs/mcp-tools.md](docs/mcp-tools.md) + [README.md](README.md) (if mentioned), then regenerate the shipped schema artifacts (`tool-parameters.schema.json`, `schemas/index.json`, `schemas/*.json`). Install/client wiring → [docs/install.md](docs/install.md) only.
54
54
 
@@ -66,7 +66,7 @@ Rules for LLMs operating in or against this repository.
66
66
 
67
67
  **End-user Git/MCP preference** (status/log/diff/commits): **`~/.claude/CLAUDE.md`** § **Git & GitHub** — same policy when dogfooding this server from a harness.
68
68
 
69
- **Mutating tools require workspace-root confirmation** — `git_fetch`, `batch_commit`, `git_push`, `git_merge`, `git_cherry_pick`, `git_reset_soft`, `git_tag`, and `git_stash_apply` operate only on roots confirmed by `requireGitAndRoots` / `requireSingleRepo`. Never pass caller-supplied absolute paths to mutating tools; use `workspaceRoot` or MCP roots.
69
+ **Mutating tools use `workspaceRoot` as trusted operator input** — `git_fetch`, `batch_commit`, `git_push`, `git_merge`, `git_cherry_pick`, `git_reset_soft`, `git_tag`, and `git_stash_apply` operate on roots resolved via `requireGitAndRoots` / `requireSingleRepo`. Pass the target repo as `workspaceRoot` (trusted caller input) or via MCP roots. The `absoluteGitRoots` bulk multi-root parameter is not accepted by mutating tools passing it is rejected.
70
70
 
71
71
  **`batch_commit` atomic staging — single call per logical change** — Do NOT attempt incremental staging across multiple `batch_commit` calls. Each call is self-contained: it stages all files in all entries, commits them sequentially, and the moment the call completes, all commits have landed. Include all related files (for all related commit entries) in a single `batch_commit` call. A call cannot be resumed or extended by a later call — each is an independent transaction. If entry N fails, entries before N remain committed; entries after N are skipped (not rolled back).
72
72
 
@@ -76,7 +76,7 @@ Rules for LLMs operating in or against this repository.
76
76
 
77
77
  **Never force-push** — `git_push` has no force-push mode by design. `git_merge` with `strategy: "ff-only"` will fail cleanly rather than force.
78
78
 
79
- **Contract bumps need documentation** — if a JSON output shape changes incompatibly, bump `MCP_JSON_FORMAT_VERSION` in `src/server.ts` and document the migration in both this file and [docs/mcp-tools.md](docs/mcp-tools.md).
79
+ **Contract bumps need documentation** — if a JSON output shape changes incompatibly, bump the `MCP_JSON_FORMAT_VERSION` constant in `src/server.ts` (it is surfaced in the server `instructions` field and discoverable via MCP `initialize`) and document the migration in both this file and [docs/mcp-tools.md](docs/mcp-tools.md).
80
80
 
81
81
  **Path confinement** — any tool accepting file paths must use `resolvePathForRepo` / `assertRelativePathUnderTop` from [`src/repo-paths.ts`](src/repo-paths.ts) and include escaping-attempt tests.
82
82
 
package/CHANGELOG.md CHANGED
@@ -2,7 +2,61 @@
2
2
 
3
3
  All notable changes to `@rethunk/mcp-multi-root-git` are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com); the project uses [Semantic Versioning](https://semver.org).
4
4
 
5
- ## [Unreleased]
5
+ ## [2.6.0] — 2026-05-22
6
+
7
+ Security-hardening and correctness release surfaced by a full-repo critical review.
8
+
9
+ ### Security
10
+
11
+ - **Argument-injection hardening** — `git_show`, `git_log`, and `git_fetch` passed caller-supplied ref/branch/remote values straight into git argv. A value beginning with `-` is parsed by git as an option (`--output=<path>` writes an arbitrary file; `--upload-pack=<cmd>` on fetch runs a command). These read tools now validate those tokens against the argv-safe subset before invoking git, returning `unsafe_ref_token`, `unsafe_remote_token`, or `path_escapes_repo` on rejection — the same gate already applied to the mutating tools.
12
+ - **`isProtectedBranch` normalization** — input is now normalized (trim, strip leading `refs/heads/`, lowercase) before matching, so `refs/heads/main`, `Main`, and `MAIN` are all recognized as protected. Previously the exact-name set was case-sensitive and a `refs/heads/` prefix evaded it entirely.
13
+
14
+ ### Fixed
15
+
16
+ - **`batch_commit` linked worktrees** — hunk-level staging wrote its scratch patch under `${gitTop}/.git/`, which fails with `ENOTDIR` in a linked worktree (where `.git` is a file). The scratch path is now resolved via `git rev-parse --absolute-git-dir`, correct for both normal repos and worktrees.
17
+ - **`batch_commit` dry run** — preview cleanup ran an unconditional `git reset HEAD --` over every touched path, silently unstaging work the caller had staged before invoking. A pre-staged snapshot is now taken first; cleanup resets only paths the dry run itself staged.
18
+ - **`git_stash_list` index** — `index` was taken from the loop counter, so a malformed entry dropped by the parse guard shifted every later index and `git_stash_apply` could target the wrong stash. The index is now parsed from the canonical `stash@{N}` ref.
19
+ - **`git_diff_summary` rename parsing** — the new path of a rename whose path contained the substring ` b/` was mis-reported because the greedy `diff --git` header regex split at the wrong point; the authoritative `rename to` body line is now used.
20
+ - **`.gitmodules` parser** — `parseGitSubmodulePaths` matched any `path =` line regardless of INI section and ignored comments; it now tracks `[submodule]` sections and strips `;`/`#` comments.
21
+ - **`git_inventory` / `git_parity` v3 JSON** — `workspace_root` field corrected to `workspaceRoot` (camelCase), completing the v3 contract rename already applied to `git_log`.
22
+
23
+ ### Changed
24
+
25
+ - **`git_tag` error codes renamed** — `tag_empty` → `empty_tag_name`, `tag_unsafe` → `unsafe_tag_token`, `ref_unsafe` → `unsafe_ref_token`; consistent with the server-wide naming convention.
26
+ - **`git_diff` multi-root parameters removed** — `absoluteGitRoots` and `allWorkspaceRoots` are no longer accepted by `git_diff`; it is now a pure single-repo tool (identical posture to `git_show`). Use `workspaceRoot` or `rootIndex` to select the target repo. `git_diff_summary` keeps single-entry `absoluteGitRoots` and drops only `allWorkspaceRoots`.
27
+ - **`MCP_JSON_FORMAT_VERSION` constant** — `"3"` is now an exported constant in `src/server.ts` and surfaced in the FastMCP `instructions` field, making the format version discoverable from the MCP `initialize` response.
28
+ - **CI** — the 20 published per-tool `schemas/*.json` artifacts are now drift-checked on every PR via a new `schema:individual:check` step; previously only `tool-parameters.schema.json` was gated.
29
+
30
+ ## [2.5.0] — 2026-05-15
31
+
32
+ Bug-fix and documentation release; includes one new `git_log` output format.
33
+
34
+ ### Added
35
+
36
+ - **`git_log` `format: "oneline"`** — minimal `<sha7> <subject>` output per commit for low-token post-commit verification. Multi-root output uses `### repo (branch)` separators; single-root output emits one line per commit with no headers.
37
+
38
+ ### Fixed
39
+
40
+ - **`git_stash_list`**: format string used for-each-ref placeholders (`%(subject)`, `%(objectname:short)`) instead of git-log format (`%s`, `%h`), printing literal placeholder text as the stash message.
41
+ - **`git_stash_list`**: stash message containing `|` caused the SHA field to be parsed as a mid-message fragment; SHA is now always the last pipe-separated field.
42
+ - **`batch_commit`**: deleted tracked files (missing on disk) could not be staged; now staged via `git rm --cached`. Combining `{ path, lines }` with a deleted file is validated as an error.
43
+ - **`batch_commit` hunk-level staging**: `newCount=0` pure-deletion hunks were excluded from line ranges that included the deletion point (`hunkEnd` was `newStart-1`); now `hunkEnd = newStart` for zero-count hunks.
44
+ - **`git_diff_summary`**: diff header regex mis-parsed file paths containing ` b/` (e.g. `src/b/file.ts`), reporting wrong path and zero stats; now uses midpoint-symmetry split for non-renames, falls back to regex for renames.
45
+ - **`git_diff_summary`**: `git diff --stat` bar-graph character counting is scaled to terminal width and under-reports for large files; replaced with `git diff --numstat` for exact addition/deletion counts.
46
+ - **`git_cherry_pick`**: branch deletion after cherry-pick now uses patch-id equivalence by default so source branches with differing SHAs but identical content are correctly detected as merged. Pass `strictMergedRefEquality: true` for strict SHA-reachability semantics.
47
+ - **`parseGitSubmodulePaths`**: non-regular files at `.gitmodules` (character devices, sockets — common in Claude Code / sandbox environments) are now rejected via `lstatSync().isFile()` before `readFileSync`, preventing `EACCES` errors.
48
+
49
+ ### Tests
50
+
51
+ - Integration tests added for `git_stash_list`, `git_stash_apply`, `git_fetch`, `git_status`, `git_inventory`, and `git_parity` execute paths.
52
+ - `isContentEquivalentlyMergedInto` integration tests via real git repos with cherry-picked and diverged histories.
53
+ - `batch_commit` dryRun tests covering deleted-file unstaging.
54
+
55
+ ### Documentation
56
+
57
+ - **`git_status`, `git_inventory`, `git_parity`, `list_presets`** now have complete parameter tables, JSON shape examples, and error code tables in `docs/mcp-tools.md`.
58
+ - **`git_cherry_pick`** `strictMergedRefEquality` parameter added to docs; `deleteMergedBranches` description corrected (default is patch-id equivalence, not SHA-reachability).
59
+ - **`batch_commit`** `files` parameter and `stage_failed` error code updated to document deleted-file staging path.
6
60
 
7
61
  ## [2.4.0] — 2026-05-07
8
62
 
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { z } from "zod";
2
3
  import { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
3
4
  import { spawnGitAsync } from "./git.js";
@@ -22,7 +23,9 @@ const CommitEntrySchema = z.object({
22
23
  files: z
23
24
  .array(FileEntrySchema)
24
25
  .min(1)
25
- .describe("Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging."),
26
+ .describe("Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging. " +
27
+ "Deleted files (missing on disk but tracked in HEAD) are staged as removals via `git rm --cached`. " +
28
+ "Combining { path, lines } with a deleted file is an error."),
26
29
  });
27
30
  const PushModeSchema = z
28
31
  .enum(["never", "after"])
@@ -74,7 +77,9 @@ function extractOverlappingHunks(diffContent, fromLine, toLine) {
74
77
  if (hunkMatch) {
75
78
  const newStart = Number.parseInt(hunkMatch[1] || "0", 10);
76
79
  const newCount = Number.parseInt(hunkMatch[2] || "1", 10);
77
- const hunkEnd = newStart + newCount - 1;
80
+ // newCount=0 means pure deletion; hunkEnd must equal newStart so ranges
81
+ // that include newStart correctly capture the deletion.
82
+ const hunkEnd = newCount === 0 ? newStart : newStart + newCount - 1;
78
83
  // Check if hunk overlaps with requested line range
79
84
  const hasOverlap = !(hunkEnd < fromLine || newStart > toLine);
80
85
  if (hasOverlap) {
@@ -123,6 +128,23 @@ function extractOverlappingHunks(diffContent, fromLine, toLine) {
123
128
  * overlapping the range are staged via a partial patch. Otherwise, stages the whole file.
124
129
  */
125
130
  async function stageFile(gitTop, filePath, lines) {
131
+ const absPath = resolvePathForRepo(filePath, gitTop);
132
+ const fileOnDisk = existsSync(absPath);
133
+ if (!fileOnDisk) {
134
+ if (lines) {
135
+ return { ok: false, error: "cannot stage line range for deleted file" };
136
+ }
137
+ // File missing on disk — stage as removal if tracked in HEAD
138
+ const lsResult = await spawnGitAsync(gitTop, ["ls-files", "--error-unmatch", "--", filePath]);
139
+ if (!lsResult.ok) {
140
+ return { ok: false, error: `pathspec '${filePath}' did not match any files` };
141
+ }
142
+ const rmResult = await spawnGitAsync(gitTop, ["rm", "--cached", "--", filePath]);
143
+ return {
144
+ ok: rmResult.ok,
145
+ error: rmResult.ok ? undefined : (rmResult.stderr || rmResult.stdout).trim(),
146
+ };
147
+ }
126
148
  if (!lines) {
127
149
  // Simple case: stage the whole file
128
150
  const addResult = await spawnGitAsync(gitTop, ["add", "--", filePath]);
@@ -140,8 +162,14 @@ async function stageFile(gitTop, filePath, lines) {
140
162
  if (!partialPatch) {
141
163
  return { ok: false, error: "No hunks found in line range" };
142
164
  }
143
- // Write partial patch to temp file in the git repo and apply it to the index
144
- const tempPatchFile = `${gitTop}/.git/.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
165
+ // Resolve the real git dir (works for linked worktrees where .git is a file, not a dir)
166
+ const gitDirResult = await spawnGitAsync(gitTop, ["rev-parse", "--absolute-git-dir"]);
167
+ if (!gitDirResult.ok) {
168
+ return { ok: false, error: (gitDirResult.stderr || gitDirResult.stdout).trim() };
169
+ }
170
+ const gitDir = gitDirResult.stdout.trim();
171
+ // Write partial patch to temp file in the git dir and apply it to the index
172
+ const tempPatchFile = `${gitDir}/.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
145
173
  const { writeFileSync, unlinkSync } = await import("node:fs");
146
174
  writeFileSync(tempPatchFile, partialPatch, "utf8");
147
175
  const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
@@ -217,6 +245,18 @@ export function registerBatchCommitTool(server) {
217
245
  const gitTop = pre.gitTop;
218
246
  const results = [];
219
247
  const stagedFilesForCleanup = new Set();
248
+ // Snapshot already-staged paths BEFORE the preview loop so dry-run cleanup
249
+ // doesn't unstage files the caller had staged before invoking.
250
+ let preStagedPaths = new Set();
251
+ if (args.dryRun) {
252
+ const snapResult = await spawnGitAsync(gitTop, ["diff", "--cached", "--name-only"]);
253
+ if (snapResult.ok) {
254
+ preStagedPaths = new Set(snapResult.stdout
255
+ .split("\n")
256
+ .map((s) => s.trim())
257
+ .filter(Boolean));
258
+ }
259
+ }
220
260
  for (let i = 0; i < args.commits.length; i++) {
221
261
  const entry = args.commits[i];
222
262
  if (!entry)
@@ -276,10 +316,13 @@ export function registerBatchCommitTool(server) {
276
316
  });
277
317
  break;
278
318
  }
279
- // Track staged files for cleanup in dry-run
319
+ // Track staged files for cleanup in dry-run, excluding paths that were
320
+ // already staged before this call (so pre-existing staged state survives).
280
321
  if (args.dryRun) {
281
322
  for (const path of filePaths) {
282
- stagedFilesForCleanup.add(path);
323
+ if (!preStagedPaths.has(path)) {
324
+ stagedFilesForCleanup.add(path);
325
+ }
283
326
  }
284
327
  }
285
328
  // --- Dry-run mode: collect preview and unstage ---
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { spawnGitAsync } from "./git.js";
3
- import { commitListBetween, conflictPaths, getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
3
+ import { commitListBetween, conflictPaths, getCurrentBranch, isContentEquivalentlyMergedInto, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
4
4
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
5
  import { requireSingleRepo } from "./roots.js";
6
6
  import { WorkspacePickSchema } from "./schemas.js";
@@ -96,8 +96,11 @@ export function registerGitCherryPickTool(server) {
96
96
  description: "Play commits from one or more sources onto a destination. Sources may be SHAs, " +
97
97
  "`A..B` ranges, or branch names (expanded to `onto..<branch>`, oldest-first). " +
98
98
  "Commits already reachable from the destination are skipped. Refuses on dirty tree; " +
99
- "stops on the first conflict and reports paths. Optional flags auto-delete fully " +
100
- "merged source branches and their worktrees, skipping protected names.",
99
+ "stops on the first conflict and reports paths. Optional flags auto-delete source " +
100
+ "branches and worktrees after success; deletion uses patch-id equivalence by default " +
101
+ "(content-identical commits with different SHAs are treated as merged, which is the " +
102
+ "normal cherry-pick outcome). Pass `strictMergedRefEquality: true` for strict `git branch -d` " +
103
+ "ancestry semantics. Protected branch names always skipped.",
101
104
  annotations: {
102
105
  readOnlyHint: false,
103
106
  destructiveHint: false,
@@ -127,6 +130,15 @@ export function registerGitCherryPickTool(server) {
127
130
  .default(false)
128
131
  .describe("After success, remove any local worktree attached to a branch-kind source " +
129
132
  "(`git worktree remove`). Protected tails always skipped."),
133
+ strictMergedRefEquality: z
134
+ .boolean()
135
+ .optional()
136
+ .default(false)
137
+ .describe("When false (default), branch deletion uses patch-id equivalence: a source branch " +
138
+ "is deleted when every commit it contains has a content-equivalent commit on the " +
139
+ "destination (same diff, different SHA — the normal cherry-pick outcome). " +
140
+ "Set to true to require strict ref ancestry (`git branch -d` semantics), which " +
141
+ "will refuse deletion after a cherry-pick because the SHA differs."),
130
142
  }),
131
143
  execute: async (args) => {
132
144
  const pre = requireSingleRepo(server, args);
@@ -221,11 +233,23 @@ export function registerGitCherryPickTool(server) {
221
233
  }
222
234
  }
223
235
  if (args.deleteMergedBranches) {
224
- const merged = await isFullyMergedInto(gitTop, src.raw, onto);
225
- if (merged) {
226
- const r = await spawnGitAsync(gitTop, ["branch", "-d", src.raw]);
227
- if (r.ok)
228
- src.branchDeleted = true;
236
+ if (args.strictMergedRefEquality) {
237
+ const merged = await isFullyMergedInto(gitTop, src.raw, onto);
238
+ if (merged) {
239
+ const r = await spawnGitAsync(gitTop, ["branch", "-d", src.raw]);
240
+ if (r.ok)
241
+ src.branchDeleted = true;
242
+ }
243
+ }
244
+ else {
245
+ const merged = await isContentEquivalentlyMergedInto(gitTop, src.raw, onto);
246
+ if (merged) {
247
+ // -D required: git branch -d checks ref ancestry (fails after cherry-pick),
248
+ // but we've already verified content equivalence via patch-id.
249
+ const r = await spawnGitAsync(gitTop, ["branch", "-D", src.raw]);
250
+ if (r.ok)
251
+ src.branchDeleted = true;
252
+ }
229
253
  }
230
254
  }
231
255
  }
@@ -24,23 +24,25 @@ const DEFAULT_EXCLUDE_PATTERNS = [
24
24
  // Helpers
25
25
  // ---------------------------------------------------------------------------
26
26
  /**
27
- * Parse `git diff --stat` output into per-file stats.
28
- * Format: " path/to/file | N ++++---"
29
- * Final line: " N files changed, N insertions(+), N deletions(-)"
27
+ * Parse `git diff --numstat` output into exact per-file counts.
28
+ * Format per line: "<additions>\t<deletions>\t<path>"
29
+ * Binary files emit "-\t-\t<path>" and are recorded as 0/0.
30
30
  */
31
- function parseStatOutput(stat) {
31
+ function parseNumstatOutput(numstat) {
32
32
  const result = new Map();
33
- for (const line of stat.split("\n")) {
34
- // Skip the summary line and blank lines
35
- if (!line.includes("|"))
33
+ for (const line of numstat.split("\n")) {
34
+ const parts = line.split("\t");
35
+ if (parts.length < 3)
36
36
  continue;
37
- const pipeIdx = line.indexOf("|");
38
- const filePart = line.slice(0, pipeIdx).trim();
39
- const statPart = line.slice(pipeIdx + 1).trim();
40
- // statPart looks like "5 ++---" or "3 +++", count + and -
41
- const additions = (statPart.match(/\+/g) ?? []).length;
42
- const deletions = (statPart.match(/-/g) ?? []).length;
43
- result.set(filePart, { additions, deletions });
37
+ const [addStr, delStr, ...pathParts] = parts;
38
+ const filePath = pathParts.join("\t");
39
+ if (!filePath)
40
+ continue;
41
+ const additions = addStr === "-" ? 0 : Number.parseInt(addStr ?? "0", 10);
42
+ const deletions = delStr === "-" ? 0 : Number.parseInt(delStr ?? "0", 10);
43
+ if (!Number.isNaN(additions) && !Number.isNaN(deletions)) {
44
+ result.set(filePath, { additions, deletions });
45
+ }
44
46
  }
45
47
  return result;
46
48
  }
@@ -68,10 +70,26 @@ function parseDiffOutput(diff) {
68
70
  * Body may contain "rename from", "rename to", "new file mode", "deleted file mode".
69
71
  */
70
72
  function extractFileInfo(header, body) {
71
- // Parse "diff --git a/X b/Y"
72
- const headerMatch = /^diff --git a\/(.+) b\/(.+)$/.exec(header);
73
- const aPath = headerMatch?.[1] ?? "";
74
- const bPath = headerMatch?.[2] ?? aPath;
73
+ // Parse "diff --git a/X b/Y". For non-renames X === Y; use midpoint split so
74
+ // paths containing " b/" (e.g. "src/b/file.ts") are not mis-parsed by a greedy regex.
75
+ const prefix = "diff --git a/";
76
+ const raw = header.startsWith(prefix) ? header.slice(prefix.length) : "";
77
+ const midLen = (raw.length - " b/".length) / 2;
78
+ let aPath = "";
79
+ let bPath = "";
80
+ if (Number.isInteger(midLen) && midLen > 0) {
81
+ const candidate = raw.slice(0, midLen);
82
+ if (raw.slice(midLen) === ` b/${candidate}`) {
83
+ aPath = candidate;
84
+ bPath = candidate;
85
+ }
86
+ }
87
+ if (!aPath) {
88
+ // Fall back for renames (aPath ≠ bPath); rename paths also come from body lines.
89
+ const headerMatch = /^diff --git a\/(.+) b\/(.+)$/.exec(header);
90
+ aPath = headerMatch?.[1] ?? "";
91
+ bPath = headerMatch?.[2] ?? aPath;
92
+ }
75
93
  let status = "modified";
76
94
  let oldPath;
77
95
  if (/^new file mode/m.test(body)) {
@@ -84,6 +102,12 @@ function extractFileInfo(header, body) {
84
102
  status = "renamed";
85
103
  const fromMatch = /^rename from (.+)$/m.exec(body);
86
104
  oldPath = fromMatch?.[1];
105
+ // Prefer the authoritative "rename to <path>" body line over the greedy
106
+ // header regex split, which mis-parses paths containing the literal " b/".
107
+ const toMatch = /^rename to (.+)$/m.exec(body);
108
+ if (toMatch?.[1]) {
109
+ bPath = toMatch[1];
110
+ }
87
111
  }
88
112
  const path = status === "deleted" ? aPath : bPath;
89
113
  return { path, oldPath, status };
@@ -160,7 +184,7 @@ export function registerGitDiffSummaryTool(server) {
160
184
  annotations: {
161
185
  readOnlyHint: true,
162
186
  },
163
- parameters: WorkspacePickSchema.extend({
187
+ parameters: WorkspacePickSchema.omit({ allWorkspaceRoots: true }).extend({
164
188
  range: z
165
189
  .string()
166
190
  .optional()
@@ -202,15 +226,15 @@ export function registerGitDiffSummaryTool(server) {
202
226
  return jsonRespond({ error: diffArgsResult.error });
203
227
  }
204
228
  const diffArgs = diffArgsResult.args;
205
- // --- Run git diff --stat ---
206
- const statResult = await spawnGitAsync(gitTop, ["diff", "--stat", ...diffArgs]);
229
+ // --- Run git diff --numstat for exact addition/deletion counts ---
230
+ const statResult = await spawnGitAsync(gitTop, ["diff", "--numstat", ...diffArgs]);
207
231
  if (!statResult.ok) {
208
232
  return jsonRespond({
209
233
  error: "git_diff_failed",
210
234
  detail: (statResult.stderr || statResult.stdout).trim(),
211
235
  });
212
236
  }
213
- const statMap = parseStatOutput(statResult.stdout);
237
+ const statMap = parseNumstatOutput(statResult.stdout);
214
238
  // --- Run git diff ---
215
239
  const diffResult = await spawnGitAsync(gitTop, ["diff", ...diffArgs]);
216
240
  if (!diffResult.ok) {
@@ -61,7 +61,10 @@ export function registerGitDiffTool(server) {
61
61
  annotations: {
62
62
  readOnlyHint: true,
63
63
  },
64
- parameters: WorkspacePickSchema.extend({
64
+ parameters: WorkspacePickSchema.omit({
65
+ absoluteGitRoots: true,
66
+ allWorkspaceRoots: true,
67
+ }).extend({
65
68
  base: z
66
69
  .string()
67
70
  .optional()
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { spawnGitAsync } from "./git.js";
2
+ import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
3
3
  import { jsonRespond } from "./json.js";
4
4
  import { requireSingleRepo } from "./roots.js";
5
5
  import { WorkspacePickSchema } from "./schemas.js";
@@ -78,6 +78,13 @@ export function registerGitFetchTool(server) {
78
78
  const branch = args.branch?.trim();
79
79
  const prune = args.prune === true;
80
80
  const tags = args.tags === true;
81
+ // Validate remote and branch to prevent argument injection.
82
+ if (!isSafeGitUpstreamToken(remote)) {
83
+ return jsonRespond({ error: "unsafe_remote_token", remote });
84
+ }
85
+ if (branch && !isSafeGitUpstreamToken(branch)) {
86
+ return jsonRespond({ error: "unsafe_ref_token", branch });
87
+ }
81
88
  // Build git fetch command
82
89
  const fetchArgs = ["fetch"];
83
90
  if (prune) {
@@ -57,7 +57,7 @@ export function registerGitInventoryTool(server) {
57
57
  const err = { error: "not_a_git_repository", path: workspaceRoot };
58
58
  if (args.format === "json") {
59
59
  allJson.push({
60
- workspace_root: workspaceRoot,
60
+ workspaceRoot: workspaceRoot,
61
61
  ...(upstream.mode === "fixed" ? { upstream } : {}),
62
62
  entries: [
63
63
  makeSkipEntry(workspaceRoot, workspaceRoot, upstream.mode, JSON.stringify(err)),
@@ -117,7 +117,7 @@ export function registerGitInventoryTool(server) {
117
117
  }
118
118
  if (args.format === "json") {
119
119
  allJson.push({
120
- workspace_root: top,
120
+ workspaceRoot: top,
121
121
  ...spreadDefined("presetSchemaVersion", presetSchemaVersion),
122
122
  ...spreadWhen(nestedRootsTruncated, {
123
123
  nestedRootsTruncated: true,
@@ -1,6 +1,7 @@
1
1
  import { basename } from "node:path";
2
2
  import { z } from "zod";
3
3
  import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitTopLevel, spawnGitAsync } from "./git.js";
4
+ import { isSafeGitAncestorRef } from "./git-refs.js";
4
5
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
6
  import { requireGitAndRoots } from "./roots.js";
6
7
  import { WorkspacePickSchema } from "./schemas.js";
@@ -133,6 +134,22 @@ async function runGitLog(opts) {
133
134
  // ---------------------------------------------------------------------------
134
135
  // Markdown rendering
135
136
  // ---------------------------------------------------------------------------
137
+ function renderLogOneline(group, multiRoot) {
138
+ const lines = [];
139
+ if (multiRoot) {
140
+ lines.push(`### ${group.repo} (${group.branch})`);
141
+ }
142
+ for (const c of group.commits) {
143
+ lines.push(`${c.sha.slice(0, 7)} ${c.subject}`);
144
+ }
145
+ if (group.commits.length === 0) {
146
+ lines.push("(no commits)");
147
+ }
148
+ if (group.truncated) {
149
+ lines.push(`(+${group.omittedCount} more)`);
150
+ }
151
+ return lines.join("\n");
152
+ }
136
153
  function renderLogMarkdown(group, filterSummary) {
137
154
  const lines = [];
138
155
  lines.push(`### ${group.repo} (${group.branch})${filterSummary ? ` — ${filterSummary}` : ""}`);
@@ -164,6 +181,13 @@ export function registerGitLogTool(server) {
164
181
  readOnlyHint: true,
165
182
  },
166
183
  parameters: WorkspacePickSchema.extend({
184
+ format: z
185
+ .enum(["markdown", "json", "oneline"])
186
+ .optional()
187
+ .default("markdown")
188
+ .describe("`markdown` (default): headed sections per root. " +
189
+ "`json`: structured groups array. " +
190
+ "`oneline`: `<sha7> <subject>` per line, no headers (single-root) or `### repo (branch)` separator per group (multi-root). Lowest-token option for post-commit verification."),
167
191
  since: z
168
192
  .string()
169
193
  .optional()
@@ -208,6 +232,10 @@ export function registerGitLogTool(server) {
208
232
  return jsonRespond({ error: "invalid_paths", path: p });
209
233
  }
210
234
  }
235
+ // Validate branch — reject leading-dash and other injection attempts.
236
+ if (args.branch && !isSafeGitAncestorRef(args.branch)) {
237
+ return jsonRespond({ error: "unsafe_ref_token", branch: args.branch });
238
+ }
211
239
  const maxCommits = Math.min(args.maxCommits ?? DEFAULT_MAX_COMMITS, MAX_COMMITS_HARD_CAP);
212
240
  // Fan out across roots.
213
241
  const jobs = pre.roots.map((rootInput) => ({ rootInput }));
@@ -258,6 +286,20 @@ export function registerGitLogTool(server) {
258
286
  });
259
287
  return jsonRespond({ groups });
260
288
  }
289
+ // Oneline
290
+ if (args.format === "oneline") {
291
+ const multiRoot = results.length > 1;
292
+ const chunks = [];
293
+ for (const r of results) {
294
+ if (r._error) {
295
+ chunks.push(multiRoot ? `### ${r.workspaceRoot}\n(error: ${r.error})` : `(error: ${r.error})`);
296
+ continue;
297
+ }
298
+ const { _error: _e, ...group } = r;
299
+ chunks.push(renderLogOneline(group, multiRoot));
300
+ }
301
+ return chunks.join("\n\n");
302
+ }
261
303
  // Markdown
262
304
  const mdChunks = ["# Git log"];
263
305
  for (const r of results) {
@@ -37,7 +37,7 @@ export function registerGitParityTool(server) {
37
37
  const err = jsonRespond(errPayload);
38
38
  if (args.format === "json") {
39
39
  results.push({
40
- workspace_root: workspaceRoot,
40
+ workspaceRoot: workspaceRoot,
41
41
  status: "MISMATCH",
42
42
  pairs: [{ label: "—", leftPath: "", rightPath: "", match: false, error: err }],
43
43
  });
@@ -114,7 +114,7 @@ export function registerGitParityTool(server) {
114
114
  }
115
115
  }
116
116
  results.push({
117
- workspace_root: top,
117
+ workspaceRoot: top,
118
118
  ...spreadDefined("presetSchemaVersion", parityPresetSchemaVersion),
119
119
  status: allOk ? "OK" : "MISMATCH",
120
120
  pairs: pairResults,