@rethunk/mcp-multi-root-git 2.5.0 → 2.8.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 -4
- package/CHANGELOG.md +45 -1
- package/README.md +1 -1
- package/dist/server/batch-commit-tool.js +32 -10
- package/dist/server/error-codes.js +95 -0
- package/dist/server/git-blame-tool.js +227 -0
- package/dist/server/git-branch-list-tool.js +140 -0
- package/dist/server/git-cherry-pick-tool.js +11 -10
- package/dist/server/git-diff-summary-tool.js +10 -3
- package/dist/server/git-diff-tool.js +60 -13
- package/dist/server/git-fetch-tool.js +148 -13
- package/dist/server/git-inventory-tool.js +7 -6
- package/dist/server/git-log-tool.js +14 -4
- package/dist/server/git-merge-tool.js +15 -14
- package/dist/server/git-parity-tool.js +5 -4
- package/dist/server/git-push-tool.js +9 -5
- package/dist/server/git-reflog-tool.js +126 -0
- package/dist/server/git-refs.js +12 -5
- package/dist/server/git-reset-soft-tool.js +4 -3
- package/dist/server/git-show-tool.js +96 -22
- package/dist/server/git-stash-tool.js +17 -15
- package/dist/server/git-tag-tool.js +8 -7
- package/dist/server/git-worktree-tool.js +8 -7
- package/dist/server/git.js +86 -7
- package/dist/server/list-presets-tool.js +2 -1
- package/dist/server/presets-resource.js +3 -2
- package/dist/server/presets.js +11 -5
- package/dist/server/roots.js +11 -10
- package/dist/server/tool-parameter-schemas.js +9 -0
- package/dist/server/tools.js +6 -0
- package/dist/server.js +7 -0
- package/docs/mcp-tools.md +134 -12
- package/package.json +4 -3
- package/schemas/git_blame.json +52 -0
- package/schemas/git_branch_list.json +36 -0
- package/schemas/git_diff.json +14 -15
- package/schemas/git_diff_summary.json +0 -6
- package/schemas/git_reflog.json +44 -0
- package/schemas/git_show.json +13 -1
- package/schemas/index.json +15 -0
- package/tool-parameters.schema.json +153 -22
package/AGENTS.md
CHANGED
|
@@ -17,7 +17,7 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
17
17
|
|------|---------|
|
|
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
|
-
| [`src/server/git.ts`](src/server/git.ts) | `gateGit`, `spawnGitAsync
|
|
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
21
|
| [`src/server/roots.ts`](src/server/roots.ts) | `requireGitAndRoots`, `requireSingleRepo`, `resolveAbsoluteGitRootsList`, `GitRootPickArgs` — shared tool preludes; session root resolution; optional `absoluteGitRoots` bulk pick |
|
|
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 |
|
|
@@ -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"`,
|
|
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
|
|
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,51 @@
|
|
|
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
|
-
## [
|
|
5
|
+
## [2.8.0] — 2026-05-29
|
|
6
|
+
|
|
7
|
+
Feature release: three new read-only inspection tools (tool count 20 → 23). Additive — no JSON format-version bump.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`git_blame`** — line-by-line authorship for a file: commit SHA, author, ISO date, summary, and content per line. Blames at an optional `ref` and an optional `startLine`/`endLine` range (`-L`); path-confined like the other path tools (`path_escapes_repo`).
|
|
12
|
+
- **`git_branch_list`** — list local branches with sha, current marker, and upstream; optional `includeRemotes` adds remote-tracking branches (symbolic `origin/HEAD` skipped). Returns `{ branches, remotes? }`.
|
|
13
|
+
- **`git_reflog`** — show the reflog for a ref (default `HEAD`): recent HEAD movements with selector (`HEAD@{N}`), full SHA, and message; `maxEntries` cap (default 30, max 200).
|
|
14
|
+
|
|
15
|
+
## [2.7.0] — 2026-05-29
|
|
16
|
+
|
|
17
|
+
Feature release: deepens three read tools per recurring agent pain points (TODO.md) and hardens the git subprocess layer. All changes are additive — no JSON format-version bump.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`git_diff` — multi-path + context control** — new `paths: string[]` scopes the diff to multiple files (unioned with the legacy single `path`), and `unified: number` (0–100) sets the context-line width (`-U<n>`). Path confinement is now enforced on every path input, returning `path_escapes_repo` on escape.
|
|
22
|
+
- **`git_show` — stat + multi-path modes** — new `stat: true` returns a `--stat` diffstat (commit message + per-file counts, no full patch), and `paths: string[]` filters the shown patch/stat to specific files, with the same path-confinement guarantees as single `path`.
|
|
23
|
+
- **`git_fetch` — structured ref deltas** — emits `updated: [{ ref, oldSha, newSha, flag }]`, `created: [{ ref, newSha, flag }]`, and `pruned: [{ ref }]` parsed from `git fetch --porcelain` (git ≥ 2.41). On older git the option is detected as unsupported and the tool falls back to the legacy line parse; the string `updatedRefs` / `newRefs` fields are retained for back-compat in both modes.
|
|
24
|
+
- **`GIT_SUBPROCESS_TIMEOUT_MS`** — `spawnGitAsync` now accepts an optional `{ timeoutMs, signal }` argument and applies a default timeout (120000 ms, configurable via the env var; `0` disables). A hung git operation against a dead remote no longer blocks the server indefinitely — the child is killed (SIGTERM) on expiry or `AbortSignal` abort, and the result resolves `ok: false` with `timedOut` / `aborted` flags. Existing callers are unchanged beyond gaining default timeout protection.
|
|
25
|
+
|
|
26
|
+
## [2.6.0] — 2026-05-22
|
|
27
|
+
|
|
28
|
+
Security-hardening and correctness release surfaced by a full-repo critical review.
|
|
29
|
+
|
|
30
|
+
### Security
|
|
31
|
+
|
|
32
|
+
- **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.
|
|
33
|
+
- **`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.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **`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.
|
|
38
|
+
- **`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.
|
|
39
|
+
- **`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.
|
|
40
|
+
- **`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.
|
|
41
|
+
- **`.gitmodules` parser** — `parseGitSubmodulePaths` matched any `path =` line regardless of INI section and ignored comments; it now tracks `[submodule]` sections and strips `;`/`#` comments.
|
|
42
|
+
- **`git_inventory` / `git_parity` v3 JSON** — `workspace_root` field corrected to `workspaceRoot` (camelCase), completing the v3 contract rename already applied to `git_log`.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **`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.
|
|
47
|
+
- **`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`.
|
|
48
|
+
- **`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.
|
|
49
|
+
- **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.
|
|
6
50
|
|
|
7
51
|
## [2.5.0] — 2026-05-15
|
|
8
52
|
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
**git** tools over MCP: status, multi-root inventory, `HEAD` parity, presets, logs, structured and raw diff views, commit inspection, fetch, stash, tag, batch commit, push, merge, cherry-pick, worktrees,
|
|
7
|
+
**git** tools over MCP: status, multi-root inventory, `HEAD` parity, presets, logs, structured and raw diff views, commit inspection, fetch, stash, tag, batch commit, push, merge, cherry-pick, worktrees, soft-reset, blame, branch listing, and reflog. **Install and MCP client wiring:** **[docs/install.md](docs/install.md)** only — do not duplicate those steps elsewhere.
|
|
8
8
|
|
|
9
9
|
**Repository:** [github.com/Rethunk-AI/mcp-multi-root-git](https://github.com/Rethunk-AI/mcp-multi-root-git) · **npmjs (manual releases):** [`@rethunk/mcp-multi-root-git`](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git) · **GitHub Packages (CI on each tag):** [`@rethunk-ai/mcp-multi-root-git`](https://github.com/Rethunk-AI/mcp-multi-root-git/packages) — see [docs/install.md](docs/install.md) and [HUMANS.md](HUMANS.md) Publishing.
|
|
10
10
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
|
|
4
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
4
5
|
import { spawnGitAsync } from "./git.js";
|
|
5
6
|
import { getCurrentBranch, inferRemoteFromUpstream } from "./git-refs.js";
|
|
6
7
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
@@ -162,8 +163,14 @@ async function stageFile(gitTop, filePath, lines) {
|
|
|
162
163
|
if (!partialPatch) {
|
|
163
164
|
return { ok: false, error: "No hunks found in line range" };
|
|
164
165
|
}
|
|
165
|
-
//
|
|
166
|
-
const
|
|
166
|
+
// Resolve the real git dir (works for linked worktrees where .git is a file, not a dir)
|
|
167
|
+
const gitDirResult = await spawnGitAsync(gitTop, ["rev-parse", "--absolute-git-dir"]);
|
|
168
|
+
if (!gitDirResult.ok) {
|
|
169
|
+
return { ok: false, error: (gitDirResult.stderr || gitDirResult.stdout).trim() };
|
|
170
|
+
}
|
|
171
|
+
const gitDir = gitDirResult.stdout.trim();
|
|
172
|
+
// Write partial patch to temp file in the git dir and apply it to the index
|
|
173
|
+
const tempPatchFile = `${gitDir}/.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
|
|
167
174
|
const { writeFileSync, unlinkSync } = await import("node:fs");
|
|
168
175
|
writeFileSync(tempPatchFile, partialPatch, "utf8");
|
|
169
176
|
const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
|
|
@@ -186,11 +193,11 @@ async function stageFile(gitTop, filePath, lines) {
|
|
|
186
193
|
export async function runPushAfter(gitTop) {
|
|
187
194
|
const branch = await getCurrentBranch(gitTop);
|
|
188
195
|
if (!branch) {
|
|
189
|
-
return { ok: false, error:
|
|
196
|
+
return { ok: false, error: ERROR_CODES.PUSH_DETACHED_HEAD };
|
|
190
197
|
}
|
|
191
198
|
const t = await inferRemoteFromUpstream(gitTop);
|
|
192
199
|
if (!t.ok) {
|
|
193
|
-
return { ok: false, branch, error:
|
|
200
|
+
return { ok: false, branch, error: ERROR_CODES.PUSH_NO_UPSTREAM, detail: t.detail };
|
|
194
201
|
}
|
|
195
202
|
const pushResult = await spawnGitAsync(gitTop, ["push", t.remote, branch]);
|
|
196
203
|
if (!pushResult.ok) {
|
|
@@ -198,7 +205,7 @@ export async function runPushAfter(gitTop) {
|
|
|
198
205
|
ok: false,
|
|
199
206
|
branch,
|
|
200
207
|
upstream: t.upstream,
|
|
201
|
-
error:
|
|
208
|
+
error: ERROR_CODES.PUSH_FAILED,
|
|
202
209
|
detail: (pushResult.stderr || pushResult.stdout).trim(),
|
|
203
210
|
};
|
|
204
211
|
}
|
|
@@ -239,6 +246,18 @@ export function registerBatchCommitTool(server) {
|
|
|
239
246
|
const gitTop = pre.gitTop;
|
|
240
247
|
const results = [];
|
|
241
248
|
const stagedFilesForCleanup = new Set();
|
|
249
|
+
// Snapshot already-staged paths BEFORE the preview loop so dry-run cleanup
|
|
250
|
+
// doesn't unstage files the caller had staged before invoking.
|
|
251
|
+
let preStagedPaths = new Set();
|
|
252
|
+
if (args.dryRun) {
|
|
253
|
+
const snapResult = await spawnGitAsync(gitTop, ["diff", "--cached", "--name-only"]);
|
|
254
|
+
if (snapResult.ok) {
|
|
255
|
+
preStagedPaths = new Set(snapResult.stdout
|
|
256
|
+
.split("\n")
|
|
257
|
+
.map((s) => s.trim())
|
|
258
|
+
.filter(Boolean));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
242
261
|
for (let i = 0; i < args.commits.length; i++) {
|
|
243
262
|
const entry = args.commits[i];
|
|
244
263
|
if (!entry)
|
|
@@ -270,7 +289,7 @@ export function registerBatchCommitTool(server) {
|
|
|
270
289
|
ok: false,
|
|
271
290
|
message: entry.message,
|
|
272
291
|
files: filePaths,
|
|
273
|
-
error:
|
|
292
|
+
error: ERROR_CODES.PATH_ESCAPES_REPOSITORY,
|
|
274
293
|
detail: escapedPaths.join(", "),
|
|
275
294
|
});
|
|
276
295
|
break;
|
|
@@ -292,16 +311,19 @@ export function registerBatchCommitTool(server) {
|
|
|
292
311
|
ok: false,
|
|
293
312
|
message: entry.message,
|
|
294
313
|
files: filePaths,
|
|
295
|
-
error:
|
|
314
|
+
error: ERROR_CODES.STAGE_FAILED,
|
|
296
315
|
detail: stagingError,
|
|
297
316
|
...spreadDefined("output", stagingError || undefined),
|
|
298
317
|
});
|
|
299
318
|
break;
|
|
300
319
|
}
|
|
301
|
-
// Track staged files for cleanup in dry-run
|
|
320
|
+
// Track staged files for cleanup in dry-run, excluding paths that were
|
|
321
|
+
// already staged before this call (so pre-existing staged state survives).
|
|
302
322
|
if (args.dryRun) {
|
|
303
323
|
for (const path of filePaths) {
|
|
304
|
-
|
|
324
|
+
if (!preStagedPaths.has(path)) {
|
|
325
|
+
stagedFilesForCleanup.add(path);
|
|
326
|
+
}
|
|
305
327
|
}
|
|
306
328
|
}
|
|
307
329
|
// --- Dry-run mode: collect preview and unstage ---
|
|
@@ -328,7 +350,7 @@ export function registerBatchCommitTool(server) {
|
|
|
328
350
|
ok: false,
|
|
329
351
|
message: entry.message,
|
|
330
352
|
files: filePaths,
|
|
331
|
-
error:
|
|
353
|
+
error: ERROR_CODES.COMMIT_FAILED,
|
|
332
354
|
detail: gitOutput,
|
|
333
355
|
...spreadDefined("output", gitOutput || undefined),
|
|
334
356
|
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralised error-code registry.
|
|
3
|
+
*
|
|
4
|
+
* Every value here is the exact string that appears on the wire in JSON
|
|
5
|
+
* responses (the `error` field). Keys are SCREAMING_SNAKE equivalents so
|
|
6
|
+
* callers get autocomplete and typos become compile errors.
|
|
7
|
+
*
|
|
8
|
+
* Adding a new code: append one entry here, then use ERROR_CODES.YOUR_CODE at
|
|
9
|
+
* the call-site — do not introduce a new inline string literal.
|
|
10
|
+
*/
|
|
11
|
+
export const ERROR_CODES = {
|
|
12
|
+
// Git availability
|
|
13
|
+
GIT_NOT_FOUND: "git_not_found",
|
|
14
|
+
// Repository resolution
|
|
15
|
+
NOT_A_GIT_REPOSITORY: "not_a_git_repository",
|
|
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",
|
|
26
|
+
// Presets
|
|
27
|
+
PRESET_FILE_INVALID: "preset_file_invalid",
|
|
28
|
+
PRESET_NOT_FOUND: "preset_not_found",
|
|
29
|
+
// Input validation — tokens / refs / paths
|
|
30
|
+
EMPTY_TAG_NAME: "empty_tag_name",
|
|
31
|
+
INVALID_LINE_RANGE: "invalid_line_range",
|
|
32
|
+
INVALID_PATHS: "invalid_paths",
|
|
33
|
+
INVALID_REMOTE_OR_BRANCH: "invalid_remote_or_branch",
|
|
34
|
+
INVALID_SINCE: "invalid_since",
|
|
35
|
+
PATH_ESCAPES_REPO: "path_escapes_repo",
|
|
36
|
+
PATH_ESCAPES_REPOSITORY: "path_escapes_repository",
|
|
37
|
+
UNSAFE_RANGE_TOKEN: "unsafe_range_token",
|
|
38
|
+
UNSAFE_REF_TOKEN: "unsafe_ref_token",
|
|
39
|
+
UNSAFE_REMOTE_TOKEN: "unsafe_remote_token",
|
|
40
|
+
UNSAFE_TAG_TOKEN: "unsafe_tag_token",
|
|
41
|
+
// Branch / ref state
|
|
42
|
+
INTO_DETACHED_HEAD: "into_detached_head",
|
|
43
|
+
ONTO_DETACHED_HEAD: "onto_detached_head",
|
|
44
|
+
PROTECTED_BRANCH: "protected_branch",
|
|
45
|
+
WORKING_TREE_DIRTY: "working_tree_dirty",
|
|
46
|
+
// Branch list
|
|
47
|
+
BRANCH_LIST_FAILED: "branch_list_failed",
|
|
48
|
+
// Commit / stage
|
|
49
|
+
COMMIT_FAILED: "commit_failed",
|
|
50
|
+
STAGE_FAILED: "stage_failed",
|
|
51
|
+
// Diff
|
|
52
|
+
GIT_DIFF_FAILED: "git_diff_failed",
|
|
53
|
+
// Log
|
|
54
|
+
GIT_LOG_FAILED: "git_log_failed",
|
|
55
|
+
// Show
|
|
56
|
+
GIT_SHOW_FAILED: "git_show_failed",
|
|
57
|
+
// Blame
|
|
58
|
+
GIT_BLAME_FAILED: "git_blame_failed",
|
|
59
|
+
// Reflog
|
|
60
|
+
REFLOG_FAILED: "reflog_failed",
|
|
61
|
+
// Stash
|
|
62
|
+
STASH_LIST_FAILED: "stash_list_failed",
|
|
63
|
+
// Fetch
|
|
64
|
+
// (uses UNSAFE_REMOTE_TOKEN and UNSAFE_REF_TOKEN)
|
|
65
|
+
// Push
|
|
66
|
+
PUSH_DETACHED_HEAD: "push_detached_head",
|
|
67
|
+
PUSH_FAILED: "push_failed",
|
|
68
|
+
PUSH_NO_UPSTREAM: "push_no_upstream",
|
|
69
|
+
// Merge
|
|
70
|
+
CANNOT_FAST_FORWARD: "cannot_fast_forward",
|
|
71
|
+
DESTINATION_NOT_FOUND: "destination_not_found",
|
|
72
|
+
MERGE_BASE_FAILED: "merge_base_failed",
|
|
73
|
+
MERGE_CONFLICTS: "merge_conflicts",
|
|
74
|
+
MERGE_FAILED: "merge_failed",
|
|
75
|
+
REBASE_CONFLICTS: "rebase_conflicts",
|
|
76
|
+
SOURCE_NOT_FOUND: "source_not_found",
|
|
77
|
+
// Cherry-pick
|
|
78
|
+
CHECKOUT_FAILED: "checkout_failed",
|
|
79
|
+
RANGE_RESOLUTION_FAILED: "range_resolution_failed",
|
|
80
|
+
// Reset
|
|
81
|
+
RESET_FAILED: "reset_failed",
|
|
82
|
+
// Tag
|
|
83
|
+
REF_NOT_FOUND: "ref_not_found",
|
|
84
|
+
TAG_CREATE_FAILED: "tag_create_failed",
|
|
85
|
+
TAG_DELETE_FAILED: "tag_delete_failed",
|
|
86
|
+
TAG_VERIFICATION_FAILED: "tag_verification_failed",
|
|
87
|
+
// Worktree
|
|
88
|
+
CANNOT_REMOVE_MAIN_WORKTREE: "cannot_remove_main_worktree",
|
|
89
|
+
WORKTREE_ADD_FAILED: "worktree_add_failed",
|
|
90
|
+
WORKTREE_NOT_FOUND: "worktree_not_found",
|
|
91
|
+
WORKTREE_REMOVE_FAILED: "worktree_remove_failed",
|
|
92
|
+
// Inventory / parity
|
|
93
|
+
NO_PAIRS: "no_pairs",
|
|
94
|
+
REMOTE_BRANCH_MISMATCH: "remote_branch_mismatch",
|
|
95
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
4
|
+
import { spawnGitAsync } from "./git.js";
|
|
5
|
+
import { isSafeGitRefToken } from "./git-refs.js";
|
|
6
|
+
import { jsonRespond, spreadDefined } from "./json.js";
|
|
7
|
+
import { requireSingleRepo } from "./roots.js";
|
|
8
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
9
|
+
/**
|
|
10
|
+
* Convert a Unix epoch + timezone offset string (e.g. "+0200") to an ISO 8601 string.
|
|
11
|
+
* The tz offset is in the format ±HHMM as emitted by git blame --porcelain.
|
|
12
|
+
*/
|
|
13
|
+
function epochTzToIso(epoch, tz) {
|
|
14
|
+
// tz looks like "+0200" or "-0500"
|
|
15
|
+
const sign = tz.startsWith("-") ? -1 : 1;
|
|
16
|
+
const tzBody = tz.replace(/^[+-]/, "");
|
|
17
|
+
const tzHours = Number.parseInt(tzBody.slice(0, 2), 10);
|
|
18
|
+
const tzMins = Number.parseInt(tzBody.slice(2), 10);
|
|
19
|
+
const offsetMs = sign * (tzHours * 60 + tzMins) * 60 * 1000;
|
|
20
|
+
const localMs = epoch * 1000 + offsetMs;
|
|
21
|
+
const d = new Date(localMs);
|
|
22
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
23
|
+
const datePart = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
|
24
|
+
const timePart = `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`;
|
|
25
|
+
const tzSign = sign >= 0 ? "+" : "-";
|
|
26
|
+
const tzStr = `${tzSign}${pad2(tzHours)}:${pad2(tzMins)}`;
|
|
27
|
+
return `${datePart}T${timePart}${tzStr}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Parse `git blame --porcelain` output into structured lines.
|
|
31
|
+
*
|
|
32
|
+
* Porcelain format per commit block:
|
|
33
|
+
* <sha> <orig-line> <final-line> [<num-lines>]
|
|
34
|
+
* author <name>
|
|
35
|
+
* author-mail <email>
|
|
36
|
+
* author-time <epoch>
|
|
37
|
+
* author-tz <±HHMM>
|
|
38
|
+
* committer ...
|
|
39
|
+
* summary <msg>
|
|
40
|
+
* ...
|
|
41
|
+
* \t<line content>
|
|
42
|
+
*
|
|
43
|
+
* Key-value header lines only appear for the FIRST occurrence of a SHA.
|
|
44
|
+
* Subsequent blocks for the same SHA have only the header line + TAB line.
|
|
45
|
+
*/
|
|
46
|
+
function parsePorcelain(output) {
|
|
47
|
+
const lines = output.split("\n");
|
|
48
|
+
const metaCache = new Map();
|
|
49
|
+
const result = [];
|
|
50
|
+
let i = 0;
|
|
51
|
+
while (i < lines.length) {
|
|
52
|
+
const headerLine = lines[i];
|
|
53
|
+
if (headerLine === undefined || headerLine.trim() === "") {
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Header: "<sha40> <origLine> <finalLine> [numLines]"
|
|
58
|
+
const headerMatch = /^([0-9a-f]{40}) \d+ (\d+)/.exec(headerLine);
|
|
59
|
+
if (!headerMatch) {
|
|
60
|
+
i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const sha = headerMatch[1] ?? "";
|
|
64
|
+
const finalLine = Number.parseInt(headerMatch[2] ?? "0", 10);
|
|
65
|
+
i++;
|
|
66
|
+
// Collect key-value lines until the TAB-prefixed content line
|
|
67
|
+
let author = "";
|
|
68
|
+
let authorTime = 0;
|
|
69
|
+
let authorTz = "+0000";
|
|
70
|
+
let summary = "";
|
|
71
|
+
let content = "";
|
|
72
|
+
let foundContent = false;
|
|
73
|
+
while (i < lines.length) {
|
|
74
|
+
const l = lines[i];
|
|
75
|
+
if (l === undefined) {
|
|
76
|
+
i++;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
if (l.startsWith("\t")) {
|
|
80
|
+
content = l.slice(1);
|
|
81
|
+
foundContent = true;
|
|
82
|
+
i++;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
// Parse known key-value pairs
|
|
86
|
+
if (l.startsWith("author ") && !l.startsWith("author-")) {
|
|
87
|
+
author = l.slice("author ".length);
|
|
88
|
+
}
|
|
89
|
+
else if (l.startsWith("author-time ")) {
|
|
90
|
+
authorTime = Number.parseInt(l.slice("author-time ".length), 10);
|
|
91
|
+
}
|
|
92
|
+
else if (l.startsWith("author-tz ")) {
|
|
93
|
+
authorTz = l.slice("author-tz ".length).trim();
|
|
94
|
+
}
|
|
95
|
+
else if (l.startsWith("summary ")) {
|
|
96
|
+
summary = l.slice("summary ".length);
|
|
97
|
+
}
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
if (!foundContent)
|
|
101
|
+
continue;
|
|
102
|
+
// Merge with cache: first occurrence populates cache; subsequent occurrences read it.
|
|
103
|
+
const cached = metaCache.get(sha);
|
|
104
|
+
if (cached === undefined) {
|
|
105
|
+
// First occurrence — we collected the metadata
|
|
106
|
+
metaCache.set(sha, { author, authorTime, summary, authorTz });
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Subsequent occurrence — restore from cache
|
|
110
|
+
author = cached.author;
|
|
111
|
+
authorTime = cached.authorTime;
|
|
112
|
+
authorTz = cached.authorTz;
|
|
113
|
+
summary = cached.summary;
|
|
114
|
+
}
|
|
115
|
+
const date = epochTzToIso(authorTime, authorTz);
|
|
116
|
+
result.push({
|
|
117
|
+
line: finalLine,
|
|
118
|
+
sha,
|
|
119
|
+
author,
|
|
120
|
+
date,
|
|
121
|
+
summary,
|
|
122
|
+
content,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Markdown rendering
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
function renderBlameMarkdown(result) {
|
|
131
|
+
const header = result.ref !== undefined
|
|
132
|
+
? `# git blame ${result.ref} -- ${result.path}`
|
|
133
|
+
: `# 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");
|
|
139
|
+
}
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Tool registration
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
export function registerGitBlameTool(server) {
|
|
144
|
+
server.addTool({
|
|
145
|
+
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.",
|
|
147
|
+
annotations: {
|
|
148
|
+
readOnlyHint: true,
|
|
149
|
+
},
|
|
150
|
+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
|
|
151
|
+
.pick({
|
|
152
|
+
workspaceRoot: true,
|
|
153
|
+
rootIndex: true,
|
|
154
|
+
format: true,
|
|
155
|
+
})
|
|
156
|
+
.extend({
|
|
157
|
+
path: z.string().min(1).describe("Repo-relative path to the file to blame."),
|
|
158
|
+
ref: z.string().optional().describe("Optional commit-ish (SHA, branch, tag) to blame at."),
|
|
159
|
+
startLine: z
|
|
160
|
+
.number()
|
|
161
|
+
.int()
|
|
162
|
+
.min(1)
|
|
163
|
+
.optional()
|
|
164
|
+
.describe("First line of the range to blame (1-based). Requires endLine."),
|
|
165
|
+
endLine: z
|
|
166
|
+
.number()
|
|
167
|
+
.int()
|
|
168
|
+
.min(1)
|
|
169
|
+
.optional()
|
|
170
|
+
.describe("Last line of the range to blame (1-based, inclusive). Requires startLine."),
|
|
171
|
+
}),
|
|
172
|
+
execute: async (args) => {
|
|
173
|
+
const pre = requireSingleRepo(server, args);
|
|
174
|
+
if (!pre.ok)
|
|
175
|
+
return jsonRespond(pre.error);
|
|
176
|
+
const top = pre.gitTop;
|
|
177
|
+
// Path confinement
|
|
178
|
+
const resolved = resolvePathForRepo(args.path, top);
|
|
179
|
+
if (!assertRelativePathUnderTop(args.path, resolved, top)) {
|
|
180
|
+
return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: args.path });
|
|
181
|
+
}
|
|
182
|
+
// Ref validation
|
|
183
|
+
if (args.ref !== undefined) {
|
|
184
|
+
if (!isSafeGitRefToken(args.ref)) {
|
|
185
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Line range validation
|
|
189
|
+
const startLine = args.startLine;
|
|
190
|
+
const endLine = args.endLine;
|
|
191
|
+
if (startLine !== undefined || endLine !== undefined) {
|
|
192
|
+
if (startLine === undefined || endLine === undefined) {
|
|
193
|
+
return jsonRespond({ error: ERROR_CODES.INVALID_LINE_RANGE });
|
|
194
|
+
}
|
|
195
|
+
if (startLine > endLine) {
|
|
196
|
+
return jsonRespond({ error: ERROR_CODES.INVALID_LINE_RANGE });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Build git blame args
|
|
200
|
+
const blameArgs = ["blame", "--porcelain"];
|
|
201
|
+
if (args.ref !== undefined) {
|
|
202
|
+
blameArgs.push(args.ref);
|
|
203
|
+
}
|
|
204
|
+
if (startLine !== undefined && endLine !== undefined) {
|
|
205
|
+
blameArgs.push(`-L${startLine},${endLine}`);
|
|
206
|
+
}
|
|
207
|
+
blameArgs.push("--", args.path);
|
|
208
|
+
const r = await spawnGitAsync(top, blameArgs);
|
|
209
|
+
if (!r.ok) {
|
|
210
|
+
return jsonRespond({
|
|
211
|
+
error: ERROR_CODES.GIT_BLAME_FAILED,
|
|
212
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const blameLines = parsePorcelain(r.stdout);
|
|
216
|
+
const blameJson = {
|
|
217
|
+
...spreadDefined("ref", args.ref),
|
|
218
|
+
path: args.path,
|
|
219
|
+
lines: blameLines,
|
|
220
|
+
};
|
|
221
|
+
if (args.format === "json") {
|
|
222
|
+
return jsonRespond(blameJson);
|
|
223
|
+
}
|
|
224
|
+
return renderBlameMarkdown(blameJson);
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
}
|