@rethunk/mcp-multi-root-git 2.9.1 → 3.1.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.
Files changed (54) hide show
  1. package/AGENTS.md +4 -6
  2. package/CHANGELOG.md +31 -0
  3. package/HUMANS.md +4 -4
  4. package/dist/server/batch-commit-tool.js +24 -15
  5. package/dist/server/error-codes.js +6 -9
  6. package/dist/server/git-blame-tool.js +60 -16
  7. package/dist/server/git-branch-list-tool.js +1 -7
  8. package/dist/server/git-cherry-pick-tool.js +1 -1
  9. package/dist/server/git-diff-summary-tool.js +3 -3
  10. package/dist/server/git-diff-tool.js +1 -4
  11. package/dist/server/git-fetch-tool.js +3 -12
  12. package/dist/server/git-inventory-tool.js +4 -4
  13. package/dist/server/git-log-tool.js +2 -2
  14. package/dist/server/git-merge-tool.js +1 -1
  15. package/dist/server/git-parity-tool.js +2 -2
  16. package/dist/server/git-push-tool.js +1 -1
  17. package/dist/server/git-reflog-tool.js +1 -7
  18. package/dist/server/git-reset-soft-tool.js +1 -1
  19. package/dist/server/git-show-tool.js +2 -11
  20. package/dist/server/git-stash-tool.js +3 -12
  21. package/dist/server/git-status-tool.js +2 -2
  22. package/dist/server/git-tag-tool.js +1 -7
  23. package/dist/server/git-worktree-tool.js +3 -7
  24. package/dist/server/list-presets-tool.js +2 -8
  25. package/dist/server/roots.js +41 -80
  26. package/dist/server/schemas.js +11 -19
  27. package/dist/server/tool-parameter-schemas.js +3 -3
  28. package/dist/server.js +1 -1
  29. package/docs/mcp-tools.md +117 -136
  30. package/package.json +4 -4
  31. package/schemas/batch_commit.json +3 -15
  32. package/schemas/git_blame.json +12 -10
  33. package/schemas/git_branch_list.json +1 -7
  34. package/schemas/git_cherry_pick.json +1 -13
  35. package/schemas/git_diff.json +1 -7
  36. package/schemas/git_diff_summary.json +1 -15
  37. package/schemas/git_fetch.json +1 -7
  38. package/schemas/git_inventory.json +18 -23
  39. package/schemas/git_log.json +18 -23
  40. package/schemas/git_merge.json +1 -13
  41. package/schemas/git_parity.json +18 -23
  42. package/schemas/git_push.json +1 -13
  43. package/schemas/git_reflog.json +1 -7
  44. package/schemas/git_reset_soft.json +1 -13
  45. package/schemas/git_show.json +1 -7
  46. package/schemas/git_stash_apply.json +2 -8
  47. package/schemas/git_stash_list.json +1 -7
  48. package/schemas/git_status.json +18 -23
  49. package/schemas/git_tag.json +1 -7
  50. package/schemas/git_worktree_add.json +1 -13
  51. package/schemas/git_worktree_list.json +1 -7
  52. package/schemas/git_worktree_remove.json +1 -13
  53. package/schemas/list_presets.json +18 -23
  54. package/tool-parameters.schema.json +122 -297
package/AGENTS.md CHANGED
@@ -18,9 +18,9 @@ IDEs injecting this as context: do not re-link from rules.
18
18
  | [`src/server.ts`](src/server.ts) | `FastMCP` + `roots: { enabled: true }`; `readMcpServerVersion()`; `registerRethunkGitTools` |
19
19
  | [`src/server/json.ts`](src/server/json.ts) | `jsonRespond()` (minified, no envelope), `spreadWhen`, `spreadDefined` |
20
20
  | [`src/server/git.ts`](src/server/git.ts) | `gateGit`, `spawnGitAsync` (optional `{ timeoutMs, signal }`), `asyncPool`, `GIT_SUBPROCESS_PARALLELISM`, `GIT_SUBPROCESS_TIMEOUT_MS`, `gitTopLevel`, `gitRevParseGitDir`, `gitRevParseHead`, `parseGitSubmodulePaths`, `hasGitMetadata`, `gitStatusSnapshotAsync`, `gitStatusShortBranchAsync`, `fetchAheadBehind`, `isSafeGitUpstreamToken` |
21
- | [`src/server/roots.ts`](src/server/roots.ts) | `requireGitAndRoots`, `requireSingleRepo`, `resolveAbsoluteGitRootsList`, `GitRootPickArgs` — shared tool preludes; session root resolution; optional `absoluteGitRoots` bulk pick |
21
+ | [`src/server/roots.ts`](src/server/roots.ts) | `requireGitAndRoots` (fan-out `root` resolution: string / string[] / `"*"`), `requireSingleRepo` (`workspaceRoot`), `resolveRootPathList`, `RootPickArgs` — shared tool preludes; session root resolution |
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
- | [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT`, **`MAX_ABSOLUTE_GIT_ROOTS`** (256), optional **`absoluteGitRoots`** on workspace pick |
23
+ | [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema` (single-repo: `workspaceRoot` + `format`), `RootPickSchema` (fan-out: polymorphic `root` + `format`), `MAX_INVENTORY_ROOTS_DEFAULT`, **`MAX_ROOT_PATHS`** (256) |
24
24
  | [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
25
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; `selectToolRegistrars(envValue, registrars)` — pure parse/filter fn for `RETHUNK_GIT_TOOLS` (reads env inside `registerRethunkGitTools`, not at module scope); `TOOL_REGISTRARS` ordered array |
@@ -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"`, 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.
51
+ - **JSON format version** (currently `"5"`, 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. v4 changes: `git_blame` output run-length grouped (`lines[]` → `groups[]`, commit metadata once per contiguous run, `maxLines` cap with `truncated`/`omittedLines`); `git_diff_summary` per-file `truncated` omitted when false. v5 changes: `batch_commit` successful `results[*]` entries drop the echoed `message`/`files` (the caller already supplied both) — failing entries still carry them for diagnosis.
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,12 +66,10 @@ 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 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.
69
+ **One routing param per tool** — the five fan-out read tools (`git_status`, `git_inventory`, `git_parity`, `list_presets`, `git_log`) take polymorphic **`root`** (string = one repo, string[] = explicit repo list, `"*"` = every MCP root). Every other tool including all mutating tools (`git_fetch`, `batch_commit`, `git_push`, `git_merge`, `git_cherry_pick`, `git_reset_soft`, `git_tag`, `git_stash_apply`, worktree add/remove) takes only **`workspaceRoot`** (trusted operator input) and resolves via `requireSingleRepo`; multi-repo routing params are not accepted on writes.
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
 
73
- **`absoluteGitRoots` is read-only** — pass it only on read tools (`git_status`, `git_inventory`, `git_parity`, `git_log`, `git_diff_summary`, `list_presets`). Mutating tools reject this parameter.
74
-
75
73
  **Protected branches are enforced by the server** — do not attempt `git_worktree_add` with a branch name matching `main`, `master`, `dev`, `develop`, `stable`, `trunk`, `prod`, `production`, `release*`, or `hotfix*`. The server rejects such calls.
76
74
 
77
75
  **Never force-push** — `git_push` has no force-push mode by design. `git_merge` with `strategy: "ff-only"` will fail cleanly rather than force.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
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
+ ## [3.1.0] — 2026-07-04
6
+
7
+ Token-cost reduction in `batch_commit` output. JSON format version bumped **4 → 5**.
8
+
9
+ ### Changed — breaking
10
+
11
+ - **`batch_commit` success entries drop echoed `message`/`files`.** Every entry in the JSON `results[]` array repeated the caller's own `message` and full `files[]` from the request, even on success — pure repetition, since the caller already has both. Successful entries now carry only `index`, `ok`, `sha` (or `staged`/`diffStat` in dry-run mode), and `output` when present. Failing entries are unchanged: `message`/`files` stay so the caller can identify the failed commit without cross-referencing the request. `MCP_JSON_FORMAT_VERSION` bumped to `"5"`.
12
+
13
+ ## [3.0.0] — 2026-07-03
14
+
15
+ Major release: token-cost reduction across the tool surface. Every tool now carries exactly one routing parameter, `git_blame` output is run-length grouped, and integer bounds are explicit. JSON format version bumped **3 → 4**.
16
+
17
+ ### Changed — breaking
18
+
19
+ - **Routing-param consolidation.** The four overlapping routing params (`workspaceRoot`, `rootIndex`, `allWorkspaceRoots`, `absoluteGitRoots`) are replaced by exactly one param per tool. The five fan-out read tools (`git_status`, `git_inventory`, `git_parity`, `list_presets`, `git_log`) take polymorphic **`root`**: a string (one repo path), a string array (explicit repo list — the old `absoluteGitRoots`), or `"*"` (every MCP root — the old `allWorkspaceRoots`). All other 18 tools take only **`workspaceRoot`** (`git_diff_summary` loses `absoluteGitRoots`). Defaults are unchanged: first MCP root, else `process.cwd()`.
20
+ - **`rootIndex` removed fleet-wide.** It was vestigial (no real consumer); the `root_index_out_of_range` error code is gone with it.
21
+ - **Root-validation error codes renamed.** `invalid_absolute_git_root` → `invalid_root_path`; `absolute_git_roots_empty` / `_too_many` / `_preset_conflict` / `_nested_or_preset_conflict` → `root_list_empty` / `root_list_too_many` / `root_list_preset_conflict` / `root_list_nested_or_preset_conflict`. `absolute_git_roots_exclusive` and `absolute_git_roots_single_repo_only` are removed — with a single routing param those states are unrepresentable.
22
+ - **`git_blame` output v4: run-length grouped.** JSON shape changed from `lines[]` (sha/author/date/summary repeated on every line) to `groups[]` — one entry per contiguous same-commit run carrying `sha`/`author`/`date`/`summary`/`startLine`/`endLine` once, plus `lines: [{ line, content }]`. Markdown output grouped the same way. `MCP_JSON_FORMAT_VERSION` bumped to `"4"`.
23
+ - **`git_diff_summary` per-file `truncated` omitted when false** (was always emitted), matching the format-contract rule that optional fields are absent when empty/null/false.
24
+
25
+ ### Added
26
+
27
+ - **`git_blame` `maxLines`** (default 2000, max 10000) — caps blamed lines; overflow is signalled with top-level `truncated: true` + `omittedLines`.
28
+
29
+ ### Fixed
30
+
31
+ - **Unbounded `.int()` params serialized `"maximum":9007199254740991` into tool schemas.** Explicit caps added: `batch_commit` `lines.from`/`lines.to` and `git_blame` `startLine`/`endLine` at 1000000, `git_stash_apply` `index` at 10000.
32
+
33
+ - **`batch_commit` hunk-level staging (`{ path, lines }`) could corrupt the git index.** `extractOverlappingHunks` joined the selected hunk(s) without a trailing newline; whenever the selection wasn't the *last* hunk in the file's real diff, the written patch file's final line had no newline terminator and `git apply --cached` rejected it with `error: corrupt patch at ...`. Fixed by always terminating the extracted patch with `\n`. Confirmed against a real multi-hunk diff before and after the fix; regression test added (`stages a non-final hunk without corrupting the patch`).
34
+ - **`batch_commit` `dryRun: true` could leave a partially-staged index after a failure.** When a commit entry listed multiple files and an earlier file staged successfully but a later file in the *same* entry failed to stage, the earlier file's staged state was never added to the dry-run cleanup set (tracking only happened after the whole per-file loop completed without failure) — so a failed "dry run" left real, uncommitted staged changes in the index. Fixed by tracking each file for cleanup immediately after it stages, not gated behind the later failure check. Regression test added (`dryRun: true unstages an earlier file when a later file in the same commit fails to stage`).
35
+
5
36
  ## [2.9.1] — 2026-06-11
6
37
 
7
38
  Patch release: security hardening, schema refresh, and developer-tooling correctness. No JSON-format change.
package/HUMANS.md CHANGED
@@ -94,14 +94,14 @@ Relative preset paths must stay inside the git toplevel; escapes are rejected.
94
94
 
95
95
  ## Sibling clone batches
96
96
 
97
- Use **`absoluteGitRoots`** when you want one read-only call to inspect independent sibling clones that are not all exposed as MCP workspace roots. This is most useful from agent workflows rooted at a parent directory or when an MCP client exposes only one repo root.
97
+ Pass a **`root` array** on the fan-out read tools (`git_status`, `git_inventory`, `git_parity`, `list_presets`, `git_log`) when you want one read-only call to inspect independent sibling clones that are not all exposed as MCP workspace roots. This is most useful from agent workflows rooted at a parent directory or when an MCP client exposes only one repo root.
98
98
 
99
99
  Example `git_status` batch:
100
100
 
101
101
  ```json
102
102
  {
103
103
  "format": "json",
104
- "absoluteGitRoots": [
104
+ "root": [
105
105
  "/usr/local/src/com.github/Rethunk-AI/mcp-multi-root-git",
106
106
  "/usr/local/src/com.github/Rethunk-AI/rethunk-github-mcp"
107
107
  ]
@@ -113,7 +113,7 @@ Example `git_parity` batch using the same pair in each sibling clone:
113
113
  ```json
114
114
  {
115
115
  "format": "json",
116
- "absoluteGitRoots": [
116
+ "root": [
117
117
  "/usr/local/src/com.github/Rethunk-AI/mcp-multi-root-git",
118
118
  "/usr/local/src/com.github/Rethunk-AI/rethunk-github-mcp"
119
119
  ],
@@ -121,7 +121,7 @@ Example `git_parity` batch using the same pair in each sibling clone:
121
121
  }
122
122
  ```
123
123
 
124
- `absoluteGitRoots` is read-only by design. Mutating tools such as **`batch_commit`**, **`git_push`**, **`git_merge`**, and **`git_cherry_pick`** omit it from their schema; use `workspaceRoot` or MCP roots for writes. Full parameter rules and error codes live in **[docs/mcp-tools.md](docs/mcp-tools.md#absoluteGitRoots-sibling-clones)**.
124
+ Multi-repo routing is read-only by design. Mutating tools such as **`batch_commit`**, **`git_push`**, **`git_merge`**, and **`git_cherry_pick`** accept only `workspaceRoot`; use it or MCP roots for writes. Full parameter rules and error codes live in **[docs/mcp-tools.md](docs/mcp-tools.md#root-resolution)**.
125
125
 
126
126
  ## Prerequisites
127
127
 
@@ -13,8 +13,13 @@ const FileEntrySchema = z.union([
13
13
  path: z.string().min(1).describe("File path relative to git root."),
14
14
  lines: z
15
15
  .object({
16
- from: z.number().int().min(1).describe("Start line number (1-indexed)."),
17
- to: z.number().int().min(1).describe("End line number (1-indexed, inclusive)."),
16
+ from: z.number().int().min(1).max(1000000).describe("Start line number (1-indexed)."),
17
+ to: z
18
+ .number()
19
+ .int()
20
+ .min(1)
21
+ .max(1000000)
22
+ .describe("End line number (1-indexed, inclusive)."),
18
23
  })
19
24
  .describe("Line range to stage. Only hunks overlapping [from, to] are staged."),
20
25
  }),
@@ -119,7 +124,10 @@ function extractOverlappingHunks(diffContent, fromLine, toLine) {
119
124
  i++;
120
125
  }
121
126
  }
122
- return result.length > fileHeaderLines.length ? result.join("\n") : null;
127
+ // Trailing newline is required: when the selected hunk(s) aren't the last hunk in the
128
+ // real diff, the last copied line is a content line that had a newline after it in the
129
+ // original diff output. Dropping it produces a patch `git apply` rejects as corrupt.
130
+ return result.length > fileHeaderLines.length ? `${result.join("\n")}\n` : null;
123
131
  }
124
132
  /**
125
133
  * Stages a file with optional line range. If lines are provided, only hunks
@@ -225,7 +233,7 @@ export function registerBatchCommitTool(server) {
225
233
  destructiveHint: false,
226
234
  idempotentHint: false,
227
235
  },
228
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
236
+ parameters: WorkspacePickSchema.extend({
229
237
  commits: z
230
238
  .array(CommitEntrySchema)
231
239
  .min(1)
@@ -299,6 +307,14 @@ export function registerBatchCommitTool(server) {
299
307
  stagingError = stageResult.error || "Unknown error";
300
308
  break;
301
309
  }
310
+ // Track for dry-run cleanup as soon as a file stages successfully — a later
311
+ // file in this same commit entry may still fail, and anything already staged
312
+ // must still be unstaged so a failed dry run never leaves index state behind.
313
+ // Excludes paths that were already staged before this call (so pre-existing
314
+ // staged state survives).
315
+ if (args.dryRun && !preStagedPaths.has(fileEntry.path)) {
316
+ stagedFilesForCleanup.add(fileEntry.path);
317
+ }
302
318
  }
303
319
  if (stagingFailed) {
304
320
  results.push({
@@ -312,15 +328,6 @@ export function registerBatchCommitTool(server) {
312
328
  });
313
329
  break;
314
330
  }
315
- // Track staged files for cleanup in dry-run, excluding paths that were
316
- // already staged before this call (so pre-existing staged state survives).
317
- if (args.dryRun) {
318
- for (const path of filePaths) {
319
- if (!preStagedPaths.has(path)) {
320
- stagedFilesForCleanup.add(path);
321
- }
322
- }
323
- }
324
331
  // --- Dry-run mode: collect preview and unstage ---
325
332
  if (args.dryRun) {
326
333
  // Get diff stat for this staged entry
@@ -381,8 +388,10 @@ export function registerBatchCommitTool(server) {
381
388
  index: r.index,
382
389
  ok: r.ok,
383
390
  ...spreadDefined("sha", r.sha),
384
- message: r.message,
385
- files: r.files,
391
+ // message/files are the caller's own request echoed back — only worth
392
+ // repeating on failure, where the caller needs them to diagnose without
393
+ // cross-referencing the request.
394
+ ...spreadWhen(!r.ok, { message: r.message, files: r.files }),
386
395
  ...spreadDefined("staged", r.staged),
387
396
  ...spreadDefined("diffStat", r.diffStat),
388
397
  ...spreadDefined("error", r.error),
@@ -14,15 +14,12 @@ export const ERROR_CODES = {
14
14
  // Repository resolution
15
15
  NOT_A_GIT_REPOSITORY: "not_a_git_repository",
16
16
  NO_WORKSPACE_ROOT: "no_workspace_root",
17
- ROOT_INDEX_OUT_OF_RANGE: "root_index_out_of_range",
18
- // absoluteGitRoots validation
19
- ABSOLUTE_GIT_ROOTS_EMPTY: "absolute_git_roots_empty",
20
- ABSOLUTE_GIT_ROOTS_EXCLUSIVE: "absolute_git_roots_exclusive",
21
- ABSOLUTE_GIT_ROOTS_NESTED_OR_PRESET_CONFLICT: "absolute_git_roots_nested_or_preset_conflict",
22
- ABSOLUTE_GIT_ROOTS_PRESET_CONFLICT: "absolute_git_roots_preset_conflict",
23
- ABSOLUTE_GIT_ROOTS_SINGLE_REPO_ONLY: "absolute_git_roots_single_repo_only",
24
- ABSOLUTE_GIT_ROOTS_TOO_MANY: "absolute_git_roots_too_many",
25
- INVALID_ABSOLUTE_GIT_ROOT: "invalid_absolute_git_root",
17
+ // `root` array validation
18
+ INVALID_ROOT_PATH: "invalid_root_path",
19
+ ROOT_LIST_EMPTY: "root_list_empty",
20
+ ROOT_LIST_NESTED_OR_PRESET_CONFLICT: "root_list_nested_or_preset_conflict",
21
+ ROOT_LIST_PRESET_CONFLICT: "root_list_preset_conflict",
22
+ ROOT_LIST_TOO_MANY: "root_list_too_many",
26
23
  // Presets
27
24
  PRESET_FILE_INVALID: "preset_file_invalid",
28
25
  PRESET_NOT_FOUND: "preset_not_found",
@@ -3,7 +3,7 @@ import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js
3
3
  import { ERROR_CODES } from "./error-codes.js";
4
4
  import { spawnGitAsync } from "./git.js";
5
5
  import { isSafeGitRefToken } from "./git-refs.js";
6
- import { jsonRespond, spreadDefined } from "./json.js";
6
+ import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
7
7
  import { requireSingleRepo } from "./roots.js";
8
8
  import { WorkspacePickSchema } from "./schemas.js";
9
9
  /**
@@ -125,17 +125,50 @@ function parsePorcelain(output) {
125
125
  return result;
126
126
  }
127
127
  // ---------------------------------------------------------------------------
128
+ // Run-length grouping
129
+ // ---------------------------------------------------------------------------
130
+ /**
131
+ * Collapse per-line blame records into one group per contiguous same-commit
132
+ * run, so commit metadata (sha/author/date/summary) is emitted once per run
133
+ * instead of once per line.
134
+ */
135
+ function groupBlameLines(lines) {
136
+ const groups = [];
137
+ let current;
138
+ for (const l of lines) {
139
+ if (current && current.sha === l.sha && current.endLine === l.line - 1) {
140
+ current.endLine = l.line;
141
+ current.lines.push({ line: l.line, content: l.content });
142
+ continue;
143
+ }
144
+ current = {
145
+ sha: l.sha,
146
+ author: l.author,
147
+ date: l.date,
148
+ summary: l.summary,
149
+ startLine: l.line,
150
+ endLine: l.line,
151
+ lines: [{ line: l.line, content: l.content }],
152
+ };
153
+ groups.push(current);
154
+ }
155
+ return groups;
156
+ }
157
+ // ---------------------------------------------------------------------------
128
158
  // Markdown rendering
129
159
  // ---------------------------------------------------------------------------
130
160
  function renderBlameMarkdown(result) {
131
161
  const header = result.ref !== undefined
132
162
  ? `# git blame ${result.ref} -- ${result.path}`
133
163
  : `# git blame ${result.path}`;
134
- const rows = result.lines.map((l) => {
135
- const sha7 = l.sha.slice(0, 7);
136
- return `${sha7} (${l.author} ${l.date}) ${l.content}`;
137
- });
138
- return [`${header}`, "", "```", ...rows, "```"].join("\n");
164
+ const out = [header];
165
+ for (const g of result.groups) {
166
+ out.push("", `## ${g.sha.slice(0, 7)} ${g.author} ${g.date} — ${g.summary} (lines ${g.startLine}–${g.endLine})`, "```", ...g.lines.map((l) => `${l.line}: ${l.content}`), "```");
167
+ }
168
+ if (result.truncated) {
169
+ out.push("", `_(truncated — ${result.omittedLines} more line(s) not shown; raise maxLines)_`);
170
+ }
171
+ return out.join("\n");
139
172
  }
140
173
  // ---------------------------------------------------------------------------
141
174
  // Tool registration
@@ -143,31 +176,35 @@ function renderBlameMarkdown(result) {
143
176
  export function registerGitBlameTool(server) {
144
177
  server.addTool({
145
178
  name: "git_blame",
146
- description: "Annotate each line of a file with the commit SHA, author, date, and summary that last modified it. Optionally restrict to a commit-ish ref and/or a line range.",
179
+ description: "File authorship, grouped into contiguous same-commit line runs (sha, author, date, summary once per run). Optionally restrict to a commit-ish ref and/or a line range.",
147
180
  annotations: {
148
181
  readOnlyHint: true,
149
182
  },
150
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
151
- .pick({
152
- workspaceRoot: true,
153
- rootIndex: true,
154
- format: true,
155
- })
156
- .extend({
183
+ parameters: WorkspacePickSchema.extend({
157
184
  path: z.string().min(1).describe("Repo-relative path to the file to blame."),
158
185
  ref: z.string().optional().describe("Optional commit-ish (SHA, branch, tag) to blame at."),
159
186
  startLine: z
160
187
  .number()
161
188
  .int()
162
189
  .min(1)
190
+ .max(1000000)
163
191
  .optional()
164
192
  .describe("First line of the range to blame (1-based). Requires endLine."),
165
193
  endLine: z
166
194
  .number()
167
195
  .int()
168
196
  .min(1)
197
+ .max(1000000)
169
198
  .optional()
170
199
  .describe("Last line of the range to blame (1-based, inclusive). Requires startLine."),
200
+ maxLines: z
201
+ .number()
202
+ .int()
203
+ .min(1)
204
+ .max(10000)
205
+ .optional()
206
+ .default(2000)
207
+ .describe("Max blamed lines to return. Default: 2000."),
171
208
  }),
172
209
  execute: async (args) => {
173
210
  const pre = requireSingleRepo(server, args);
@@ -212,11 +249,18 @@ export function registerGitBlameTool(server) {
212
249
  detail: (r.stderr || r.stdout).trim(),
213
250
  });
214
251
  }
215
- const blameLines = parsePorcelain(r.stdout);
252
+ const allLines = parsePorcelain(r.stdout);
253
+ const maxLines = args.maxLines ?? 2000;
254
+ const truncated = allLines.length > maxLines;
255
+ const blameLines = truncated ? allLines.slice(0, maxLines) : allLines;
216
256
  const blameJson = {
217
257
  ...spreadDefined("ref", args.ref),
218
258
  path: args.path,
219
- lines: blameLines,
259
+ groups: groupBlameLines(blameLines),
260
+ ...spreadWhen(truncated, {
261
+ truncated: true,
262
+ omittedLines: allLines.length - maxLines,
263
+ }),
220
264
  };
221
265
  if (args.format === "json") {
222
266
  return jsonRespond(blameJson);
@@ -104,13 +104,7 @@ export function registerGitBranchListTool(server) {
104
104
  annotations: {
105
105
  readOnlyHint: true,
106
106
  },
107
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
108
- .pick({
109
- workspaceRoot: true,
110
- rootIndex: true,
111
- format: true,
112
- })
113
- .extend({
107
+ parameters: WorkspacePickSchema.extend({
114
108
  includeRemotes: z
115
109
  .boolean()
116
110
  .optional()
@@ -104,7 +104,7 @@ export function registerGitCherryPickTool(server) {
104
104
  destructiveHint: false,
105
105
  idempotentHint: false,
106
106
  },
107
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
107
+ parameters: WorkspacePickSchema.extend({
108
108
  sources: z
109
109
  .array(z.string().min(1))
110
110
  .min(1)
@@ -29,7 +29,7 @@ const DEFAULT_EXCLUDE_PATTERNS = [
29
29
  * Format per line: "<additions>\t<deletions>\t<path>"
30
30
  * Binary files emit "-\t-\t<path>" and are recorded as 0/0.
31
31
  */
32
- function parseNumstatOutput(numstat) {
32
+ export function parseNumstatOutput(numstat) {
33
33
  const result = new Map();
34
34
  for (const line of numstat.split("\n")) {
35
35
  const parts = line.split("\t");
@@ -184,7 +184,7 @@ export function registerGitDiffSummaryTool(server) {
184
184
  annotations: {
185
185
  readOnlyHint: true,
186
186
  },
187
- parameters: WorkspacePickSchema.omit({ allWorkspaceRoots: true }).extend({
187
+ parameters: WorkspacePickSchema.extend({
188
188
  range: z
189
189
  .string()
190
190
  .optional()
@@ -282,7 +282,7 @@ export function registerGitDiffSummaryTool(server) {
282
282
  additions: stat.additions,
283
283
  deletions: stat.deletions,
284
284
  ...spreadDefined("oldPath", oldPath),
285
- truncated,
285
+ ...spreadWhen(truncated, { truncated: true }),
286
286
  diff: diffText,
287
287
  });
288
288
  }
@@ -66,10 +66,7 @@ export function registerGitDiffTool(server) {
66
66
  annotations: {
67
67
  readOnlyHint: true,
68
68
  },
69
- parameters: WorkspacePickSchema.omit({
70
- absoluteGitRoots: true,
71
- allWorkspaceRoots: true,
72
- }).extend({
69
+ parameters: WorkspacePickSchema.extend({
73
70
  base: z
74
71
  .string()
75
72
  .optional()
@@ -12,7 +12,7 @@ import { WorkspacePickSchema } from "./schemas.js";
12
12
  * Lines containing "[new" indicate new refs (new branch, new tag, new ref).
13
13
  * Lines with " -> " but not containing "[new" indicate updated refs.
14
14
  */
15
- function parseGitFetchOutput(output) {
15
+ export function parseGitFetchOutput(output) {
16
16
  const lines = output.split("\n");
17
17
  const updatedRefs = [];
18
18
  const newRefs = [];
@@ -97,22 +97,13 @@ export function registerGitFetchTool(server) {
97
97
  annotations: {
98
98
  readOnlyHint: false, // Fetch modifies refs but not working tree; not strictly read-only but safe
99
99
  },
100
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
101
- .pick({
102
- workspaceRoot: true,
103
- rootIndex: true,
104
- format: true,
105
- })
106
- .extend({
100
+ parameters: WorkspacePickSchema.extend({
107
101
  remote: z
108
102
  .string()
109
103
  .optional()
110
104
  .default("origin")
111
105
  .describe("Remote to fetch from (default: origin)."),
112
- branch: z
113
- .string()
114
- .optional()
115
- .describe("If specified: fetch only this branch (e.g. 'main')."),
106
+ branch: z.string().optional().describe("If specified: fetch only this branch (e.g. 'main')."),
116
107
  prune: z
117
108
  .boolean()
118
109
  .optional()
@@ -5,7 +5,7 @@ import { buildInventorySectionMarkdown, collectInventoryEntry, makeSkipEntry, va
5
5
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
6
6
  import { applyPresetNestedRoots } from "./presets.js";
7
7
  import { requireGitAndRoots } from "./roots.js";
8
- import { MAX_INVENTORY_ROOTS_DEFAULT, WorkspacePickSchema } from "./schemas.js";
8
+ import { MAX_INVENTORY_ROOTS_DEFAULT, RootPickSchema } from "./schemas.js";
9
9
  export function registerGitInventoryTool(server) {
10
10
  server.addTool({
11
11
  name: "git_inventory",
@@ -13,7 +13,7 @@ export function registerGitInventoryTool(server) {
13
13
  annotations: {
14
14
  readOnlyHint: true,
15
15
  },
16
- parameters: WorkspacePickSchema.extend({
16
+ parameters: RootPickSchema.extend({
17
17
  nestedRoots: z.array(z.string()).optional(),
18
18
  preset: z.string().optional(),
19
19
  presetMerge: z
@@ -26,9 +26,9 @@ export function registerGitInventoryTool(server) {
26
26
  maxRoots: z.number().int().min(1).max(256).optional().default(MAX_INVENTORY_ROOTS_DEFAULT),
27
27
  }),
28
28
  execute: async (args) => {
29
- if (args.absoluteGitRoots != null && args.absoluteGitRoots.length > 0) {
29
+ if (Array.isArray(args.root)) {
30
30
  if (args.preset || (args.nestedRoots?.length ?? 0) > 0) {
31
- return jsonRespond({ error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_NESTED_OR_PRESET_CONFLICT });
31
+ return jsonRespond({ error: ERROR_CODES.ROOT_LIST_NESTED_OR_PRESET_CONFLICT });
32
32
  }
33
33
  }
34
34
  const pre = requireGitAndRoots(server, args, args.preset);
@@ -5,7 +5,7 @@ import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitTopLevel, spawnGitAsync } fro
5
5
  import { isSafeGitAncestorRef } from "./git-refs.js";
6
6
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
7
7
  import { requireGitAndRoots } from "./roots.js";
8
- import { WorkspacePickSchema } from "./schemas.js";
8
+ import { RootPickSchema } from "./schemas.js";
9
9
  // ---------------------------------------------------------------------------
10
10
  // Constants
11
11
  // ---------------------------------------------------------------------------
@@ -180,7 +180,7 @@ export function registerGitLogTool(server) {
180
180
  annotations: {
181
181
  readOnlyHint: true,
182
182
  },
183
- parameters: WorkspacePickSchema.extend({
183
+ parameters: RootPickSchema.extend({
184
184
  format: z
185
185
  .enum(["markdown", "json", "oneline"])
186
186
  .optional()
@@ -216,7 +216,7 @@ export function registerGitMergeTool(server) {
216
216
  destructiveHint: false,
217
217
  idempotentHint: false,
218
218
  },
219
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
219
+ parameters: WorkspacePickSchema.extend({
220
220
  sources: z
221
221
  .array(z.string().min(1))
222
222
  .min(1)
@@ -5,7 +5,7 @@ import { validateRepoPath } from "./inventory.js";
5
5
  import { jsonRespond, spreadDefined } from "./json.js";
6
6
  import { applyPresetParityPairs } from "./presets.js";
7
7
  import { requireGitAndRoots } from "./roots.js";
8
- import { WorkspacePickSchema } from "./schemas.js";
8
+ import { RootPickSchema } from "./schemas.js";
9
9
  export function registerGitParityTool(server) {
10
10
  server.addTool({
11
11
  name: "git_parity",
@@ -13,7 +13,7 @@ export function registerGitParityTool(server) {
13
13
  annotations: {
14
14
  readOnlyHint: true,
15
15
  },
16
- parameters: WorkspacePickSchema.extend({
16
+ parameters: RootPickSchema.extend({
17
17
  pairs: z
18
18
  .array(z.object({
19
19
  left: z.string(),
@@ -16,7 +16,7 @@ export function registerGitPushTool(server) {
16
16
  destructiveHint: false,
17
17
  idempotentHint: false,
18
18
  },
19
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
19
+ parameters: WorkspacePickSchema.extend({
20
20
  remote: z
21
21
  .string()
22
22
  .optional()
@@ -82,13 +82,7 @@ export function registerGitReflogTool(server) {
82
82
  annotations: {
83
83
  readOnlyHint: true,
84
84
  },
85
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
86
- .pick({
87
- workspaceRoot: true,
88
- rootIndex: true,
89
- format: true,
90
- })
91
- .extend({
85
+ parameters: WorkspacePickSchema.extend({
92
86
  ref: z
93
87
  .string()
94
88
  .optional()
@@ -15,7 +15,7 @@ export function registerGitResetSoftTool(server) {
15
15
  destructiveHint: false,
16
16
  idempotentHint: false,
17
17
  },
18
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
18
+ parameters: WorkspacePickSchema.extend({
19
19
  ref: z
20
20
  .string()
21
21
  .min(1)
@@ -156,17 +156,8 @@ export function registerGitShowTool(server) {
156
156
  annotations: {
157
157
  readOnlyHint: true,
158
158
  },
159
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
160
- .pick({
161
- workspaceRoot: true,
162
- rootIndex: true,
163
- format: true,
164
- })
165
- .extend({
166
- ref: z
167
- .string()
168
- .min(1)
169
- .describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
159
+ parameters: WorkspacePickSchema.extend({
160
+ ref: z.string().min(1).describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
170
161
  path: z
171
162
  .string()
172
163
  .optional()
@@ -14,11 +14,7 @@ export function registerGitStashListTool(server) {
14
14
  annotations: {
15
15
  readOnlyHint: true,
16
16
  },
17
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true }).pick({
18
- workspaceRoot: true,
19
- rootIndex: true,
20
- format: true,
21
- }),
17
+ parameters: WorkspacePickSchema,
22
18
  execute: async (args) => {
23
19
  const pre = requireSingleRepo(server, args);
24
20
  if (!pre.ok)
@@ -83,17 +79,12 @@ export function registerGitStashApplyTool(server) {
83
79
  destructiveHint: false,
84
80
  idempotentHint: false,
85
81
  },
86
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
87
- .pick({
88
- workspaceRoot: true,
89
- rootIndex: true,
90
- format: true,
91
- })
92
- .extend({
82
+ parameters: WorkspacePickSchema.extend({
93
83
  index: z
94
84
  .number()
95
85
  .int()
96
86
  .min(0)
87
+ .max(10000)
97
88
  .optional()
98
89
  .default(0)
99
90
  .describe("Stash index (defaults to 0 for stash@{0})."),