@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.
- package/AGENTS.md +4 -6
- package/CHANGELOG.md +31 -0
- package/HUMANS.md +4 -4
- package/dist/server/batch-commit-tool.js +24 -15
- package/dist/server/error-codes.js +6 -9
- package/dist/server/git-blame-tool.js +60 -16
- package/dist/server/git-branch-list-tool.js +1 -7
- package/dist/server/git-cherry-pick-tool.js +1 -1
- package/dist/server/git-diff-summary-tool.js +3 -3
- package/dist/server/git-diff-tool.js +1 -4
- package/dist/server/git-fetch-tool.js +3 -12
- package/dist/server/git-inventory-tool.js +4 -4
- package/dist/server/git-log-tool.js +2 -2
- package/dist/server/git-merge-tool.js +1 -1
- package/dist/server/git-parity-tool.js +2 -2
- package/dist/server/git-push-tool.js +1 -1
- package/dist/server/git-reflog-tool.js +1 -7
- package/dist/server/git-reset-soft-tool.js +1 -1
- package/dist/server/git-show-tool.js +2 -11
- package/dist/server/git-stash-tool.js +3 -12
- package/dist/server/git-status-tool.js +2 -2
- package/dist/server/git-tag-tool.js +1 -7
- package/dist/server/git-worktree-tool.js +3 -7
- package/dist/server/list-presets-tool.js +2 -8
- package/dist/server/roots.js +41 -80
- package/dist/server/schemas.js +11 -19
- package/dist/server/tool-parameter-schemas.js +3 -3
- package/dist/server.js +1 -1
- package/docs/mcp-tools.md +117 -136
- package/package.json +4 -4
- package/schemas/batch_commit.json +3 -15
- package/schemas/git_blame.json +12 -10
- package/schemas/git_branch_list.json +1 -7
- package/schemas/git_cherry_pick.json +1 -13
- package/schemas/git_diff.json +1 -7
- package/schemas/git_diff_summary.json +1 -15
- package/schemas/git_fetch.json +1 -7
- package/schemas/git_inventory.json +18 -23
- package/schemas/git_log.json +18 -23
- package/schemas/git_merge.json +1 -13
- package/schemas/git_parity.json +18 -23
- package/schemas/git_push.json +1 -13
- package/schemas/git_reflog.json +1 -7
- package/schemas/git_reset_soft.json +1 -13
- package/schemas/git_show.json +1 -7
- package/schemas/git_stash_apply.json +2 -8
- package/schemas/git_stash_list.json +1 -7
- package/schemas/git_status.json +18 -23
- package/schemas/git_tag.json +1 -7
- package/schemas/git_worktree_add.json +1 -13
- package/schemas/git_worktree_list.json +1 -7
- package/schemas/git_worktree_remove.json +1 -13
- package/schemas/list_presets.json +18 -23
- 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
|
|
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
|
|
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 `"
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
385
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
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: "
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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:
|
|
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 (
|
|
29
|
+
if (Array.isArray(args.root)) {
|
|
30
30
|
if (args.preset || (args.nestedRoots?.length ?? 0) > 0) {
|
|
31
|
-
return jsonRespond({ error: ERROR_CODES.
|
|
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 {
|
|
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:
|
|
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.
|
|
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 {
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
160
|
-
.
|
|
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
|
|
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.
|
|
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})."),
|