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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +21 -0
  3. package/HUMANS.md +1 -1
  4. package/README.md +1 -1
  5. package/dist/server/batch-commit-tool.js +16 -20
  6. package/dist/server/error-codes.js +95 -0
  7. package/dist/server/git-blame-tool.js +227 -0
  8. package/dist/server/git-branch-list-tool.js +138 -0
  9. package/dist/server/git-cherry-pick-tool.js +22 -31
  10. package/dist/server/git-diff-summary-tool.js +6 -6
  11. package/dist/server/git-diff-tool.js +54 -18
  12. package/dist/server/git-fetch-tool.js +142 -14
  13. package/dist/server/git-inventory-tool.js +5 -4
  14. package/dist/server/git-log-tool.js +18 -19
  15. package/dist/server/git-merge-tool.js +21 -24
  16. package/dist/server/git-parity-tool.js +3 -2
  17. package/dist/server/git-push-tool.js +9 -5
  18. package/dist/server/git-reflog-tool.js +126 -0
  19. package/dist/server/git-reset-soft-tool.js +7 -9
  20. package/dist/server/git-show-tool.js +83 -23
  21. package/dist/server/git-stash-tool.js +5 -6
  22. package/dist/server/git-tag-tool.js +8 -7
  23. package/dist/server/git-worktree-tool.js +14 -16
  24. package/dist/server/git.js +70 -4
  25. package/dist/server/list-presets-tool.js +2 -1
  26. package/dist/server/presets-resource.js +3 -2
  27. package/dist/server/presets.js +11 -5
  28. package/dist/server/roots.js +11 -10
  29. package/dist/server/schemas.js +4 -4
  30. package/dist/server/tool-parameter-schemas.js +9 -0
  31. package/dist/server/tools.js +6 -0
  32. package/docs/mcp-tools.md +120 -7
  33. package/package.json +10 -13
  34. package/schemas/git_blame.json +52 -0
  35. package/schemas/git_branch_list.json +36 -0
  36. package/schemas/git_diff.json +14 -1
  37. package/schemas/git_reflog.json +44 -0
  38. package/schemas/git_show.json +12 -1
  39. package/schemas/index.json +15 -0
  40. package/tool-parameters.schema.json +236 -86
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`, `asyncPool`, `GIT_SUBPROCESS_PARALLELISM`, `gitTopLevel`, `gitRevParseGitDir`, `gitRevParseHead`, `parseGitSubmodulePaths`, `hasGitMetadata`, `gitStatusSnapshotAsync`, `gitStatusShortBranchAsync`, `fetchAheadBehind`, `isSafeGitUpstreamToken` |
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 |
@@ -54,7 +54,7 @@ IDEs injecting this as context: do not re-link from rules.
54
54
 
55
55
  ## Validate + CI
56
56
 
57
- Local: `bun run build` | `bun run check` | `bun run schema:tools:check` | `bun run test`. CI ([`ci.yml`](.github/workflows/ci.yml)) runs same on PRs + `main` after `bun install --frozen-lockfile`, then `bun run test:coverage` + `bun run coverage:check`, and uploads prerelease `npm pack` artifact. Tag `v*.*.*` matching `package.json` version → [`release.yml`](.github/workflows/release.yml) publishes to GitHub Packages as `@rethunk-ai/mcp-multi-root-git` + cuts GitHub Release. npmjs publish is manual (see [HUMANS.md](HUMANS.md)).
57
+ Local: `bun run build` | `bun run lint` | `bun run schema:tools:check` | `bun run test`. CI ([`ci.yml`](.github/workflows/ci.yml)) runs same on PRs + `main` after `bun install --frozen-lockfile`, then `bun run test:coverage` + `bun run coverage:check`, and uploads prerelease `npm pack` artifact. Tag `v*.*.*` matching `package.json` version → [`release.yml`](.github/workflows/release.yml) publishes to GitHub Packages as `@rethunk-ai/mcp-multi-root-git` + cuts GitHub Release. npmjs publish is manual (see [HUMANS.md](HUMANS.md)).
58
58
 
59
59
  Optional [`.githooks/`](.githooks): `bun run setup-hooks` once per clone. pre-commit=`check`; pre-push=frozen install + build + check + test.
60
60
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
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
+ ## [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
+
5
26
  ## [2.6.0] — 2026-05-22
6
27
 
7
28
  Security-hardening and correctness release surfaced by a full-repo critical review.
package/HUMANS.md CHANGED
@@ -205,7 +205,7 @@ It verifies the `package.json` version has a matching `CHANGELOG.md` section, bo
205
205
 
206
206
  npmjs no longer fits an unattended CI publish flow for this org; **do not** rely on automation to [npmjs](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git). To publish the **same** package name consumers already use (**`@rethunk/mcp-multi-root-git`**):
207
207
 
208
- 1. On a clean checkout at the release commit (usually **`main`** after bumping version), run **`bun run prepublishOnly`** (or `bun run build && bun run check && bun run test`).
208
+ 1. On a clean checkout at the release commit (usually **`main`** after bumping version), run **`bun run prepublishOnly`** (or `bun run build && bun run lint && bun run test`).
209
209
  2. Log in to the public registry once per machine: **`npm login`** (or `npm adduser`) so **`npm whoami`** shows the account that owns **`@rethunk`** on npmjs.
210
210
  3. Ensure **`package.json`** still has **`"name": "@rethunk/mcp-multi-root-git"`** and **`publishConfig.access`** is **`"public"`** (no **`publishConfig.registry`** pointing at GitHub — leave default registry for npmjs).
211
211
  4. Publish: **`npm publish --access public`** (runs **`prepublishOnly`** again unless you pass **`--ignore-scripts`** after you already verified locally).
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm version](https://img.shields.io/npm/v/%40rethunk%2Fmcp-multi-root-git.svg)](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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, and soft-reset. **Install and MCP client wiring:** **[docs/install.md](docs/install.md)** only — do not duplicate those steps elsewhere.
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";
@@ -23,23 +24,20 @@ const CommitEntrySchema = z.object({
23
24
  files: z
24
25
  .array(FileEntrySchema)
25
26
  .min(1)
26
- .describe("Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging. " +
27
- "Deleted files (missing on disk but tracked in HEAD) are staged as removals via `git rm --cached`. " +
28
- "Combining { path, lines } with a deleted file is an error."),
27
+ .describe("Paths to stage, relative to git root. String or `{ path, lines }` for hunk-level staging. " +
28
+ "Deleted tracked files are staged via `git rm --cached`. Cannot combine `lines` with a deleted file."),
29
29
  });
30
30
  const PushModeSchema = z
31
31
  .enum(["never", "after"])
32
32
  .optional()
33
33
  .default("never")
34
- .describe("`never` (default): no push. `after`: push the current branch to its upstream once all commits succeed; " +
35
- "fails with `push_no_upstream` if the branch has no upstream (commits are NOT rolled back). " +
36
- "Enum reserved for future modes such as `force-with-lease`.");
34
+ .describe("`never` (default): no push. `after`: push current branch to upstream after all commits succeed; " +
35
+ "fails with `push_no_upstream` if no upstream (commits are NOT rolled back).");
37
36
  const DryRunSchema = z
38
37
  .boolean()
39
38
  .optional()
40
39
  .default(false)
41
- .describe("When true, stage files, collect preview (files staged, commit messages), return preview response without writing commits. " +
42
- "Unstages any files that were staged for the preview. Response indicates DRY RUN mode.");
40
+ .describe("Stage files and return a preview without writing commits; unstages afterwards. Response is marked DRY RUN.");
43
41
  /**
44
42
  * Parses a unified diff to extract hunks that overlap with a given line range.
45
43
  * Returns a partial patch containing only the overlapping hunks, including header lines.
@@ -192,11 +190,11 @@ async function stageFile(gitTop, filePath, lines) {
192
190
  export async function runPushAfter(gitTop) {
193
191
  const branch = await getCurrentBranch(gitTop);
194
192
  if (!branch) {
195
- return { ok: false, error: "push_detached_head" };
193
+ return { ok: false, error: ERROR_CODES.PUSH_DETACHED_HEAD };
196
194
  }
197
195
  const t = await inferRemoteFromUpstream(gitTop);
198
196
  if (!t.ok) {
199
- return { ok: false, branch, error: "push_no_upstream", detail: t.detail };
197
+ return { ok: false, branch, error: ERROR_CODES.PUSH_NO_UPSTREAM, detail: t.detail };
200
198
  }
201
199
  const pushResult = await spawnGitAsync(gitTop, ["push", t.remote, branch]);
202
200
  if (!pushResult.ok) {
@@ -204,7 +202,7 @@ export async function runPushAfter(gitTop) {
204
202
  ok: false,
205
203
  branch,
206
204
  upstream: t.upstream,
207
- error: "push_failed",
205
+ error: ERROR_CODES.PUSH_FAILED,
208
206
  detail: (pushResult.stderr || pushResult.stdout).trim(),
209
207
  };
210
208
  }
@@ -219,11 +217,9 @@ export async function runPushAfter(gitTop) {
219
217
  export function registerBatchCommitTool(server) {
220
218
  server.addTool({
221
219
  name: "batch_commit",
222
- description: "Create multiple sequential git commits in a single call. " +
223
- "Each entry stages the listed files then commits with the given message. " +
224
- 'Stops on first failure. Optional `push: "after"` pushes the current branch ' +
225
- "to its upstream once all commits succeed. " +
226
- "Optional `dryRun: true` previews what would be staged/committed without writing commits.",
220
+ description: "Create multiple sequential git commits in one call. " +
221
+ "Each entry stages its files then commits. Stops on first failure. " +
222
+ 'Optional `push: "after"` pushes after all commits succeed. `dryRun: true` previews without writing.',
227
223
  annotations: {
228
224
  readOnlyHint: false,
229
225
  destructiveHint: false,
@@ -234,7 +230,7 @@ export function registerBatchCommitTool(server) {
234
230
  .array(CommitEntrySchema)
235
231
  .min(1)
236
232
  .max(50)
237
- .describe("Commits to create, applied in order."),
233
+ .describe("Ordered list of commits to create."),
238
234
  push: PushModeSchema,
239
235
  dryRun: DryRunSchema,
240
236
  }),
@@ -288,7 +284,7 @@ export function registerBatchCommitTool(server) {
288
284
  ok: false,
289
285
  message: entry.message,
290
286
  files: filePaths,
291
- error: "path_escapes_repository",
287
+ error: ERROR_CODES.PATH_ESCAPES_REPOSITORY,
292
288
  detail: escapedPaths.join(", "),
293
289
  });
294
290
  break;
@@ -310,7 +306,7 @@ export function registerBatchCommitTool(server) {
310
306
  ok: false,
311
307
  message: entry.message,
312
308
  files: filePaths,
313
- error: "stage_failed",
309
+ error: ERROR_CODES.STAGE_FAILED,
314
310
  detail: stagingError,
315
311
  ...spreadDefined("output", stagingError || undefined),
316
312
  });
@@ -349,7 +345,7 @@ export function registerBatchCommitTool(server) {
349
345
  ok: false,
350
346
  message: entry.message,
351
347
  files: filePaths,
352
- error: "commit_failed",
348
+ error: ERROR_CODES.COMMIT_FAILED,
353
349
  detail: gitOutput,
354
350
  ...spreadDefined("output", gitOutput || undefined),
355
351
  });
@@ -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
+ }
@@ -0,0 +1,138 @@
1
+ import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
3
+ import { spawnGitAsync } from "./git.js";
4
+ import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
+ import { requireSingleRepo } from "./roots.js";
6
+ import { WorkspacePickSchema } from "./schemas.js";
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+ async function runBranchList(opts) {
11
+ const { top, includeRemotes } = opts;
12
+ // Local branches: name, full SHA, upstream (may be empty), HEAD marker (* or space)
13
+ const localR = await spawnGitAsync(top, [
14
+ "for-each-ref",
15
+ "--format=%(refname:short)%00%(objectname)%00%(upstream:short)%00%(HEAD)",
16
+ "refs/heads",
17
+ ]);
18
+ if (!localR.ok) {
19
+ return {
20
+ error: ERROR_CODES.BRANCH_LIST_FAILED,
21
+ detail: (localR.stderr || localR.stdout).trim(),
22
+ };
23
+ }
24
+ const branches = [];
25
+ const localLines = (localR.stdout || "").split("\n").filter((l) => l.length > 0);
26
+ for (const line of localLines) {
27
+ const parts = line.split("\x00");
28
+ const name = parts[0] ?? "";
29
+ const sha = parts[1] ?? "";
30
+ const upstream = parts[2] ?? "";
31
+ const headMarker = parts[3] ?? "";
32
+ if (!name || !sha)
33
+ continue;
34
+ branches.push({
35
+ name,
36
+ sha,
37
+ current: headMarker === "*",
38
+ ...spreadDefined("upstream", upstream || undefined),
39
+ });
40
+ }
41
+ if (!includeRemotes) {
42
+ return { branches };
43
+ }
44
+ // Remote branches: name, full SHA — skip symbolic origin/HEAD refs
45
+ const remoteR = await spawnGitAsync(top, [
46
+ "for-each-ref",
47
+ "--format=%(refname:short)%00%(objectname)",
48
+ "refs/remotes",
49
+ ]);
50
+ if (!remoteR.ok) {
51
+ return {
52
+ error: ERROR_CODES.BRANCH_LIST_FAILED,
53
+ detail: (remoteR.stderr || remoteR.stdout).trim(),
54
+ };
55
+ }
56
+ const remotes = [];
57
+ const remoteLines = (remoteR.stdout || "").split("\n").filter((l) => l.length > 0);
58
+ for (const line of remoteLines) {
59
+ const parts = line.split("\x00");
60
+ const name = parts[0] ?? "";
61
+ const sha = parts[1] ?? "";
62
+ // Skip symbolic origin/HEAD
63
+ if (!name || !sha || name.endsWith("/HEAD"))
64
+ continue;
65
+ remotes.push({ name, sha });
66
+ }
67
+ return {
68
+ branches,
69
+ ...spreadWhen(remotes.length > 0, { remotes }),
70
+ };
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // Markdown rendering
74
+ // ---------------------------------------------------------------------------
75
+ function renderBranchListMarkdown(result) {
76
+ const lines = ["## Branches", ""];
77
+ if (result.branches.length === 0) {
78
+ lines.push("_(none)_");
79
+ }
80
+ else {
81
+ for (const b of result.branches) {
82
+ const prefix = b.current ? "* " : " ";
83
+ const upstream = b.upstream ? ` → ${b.upstream}` : "";
84
+ lines.push(`${prefix}**${b.name}**${upstream} (\`${b.sha}\`)`);
85
+ }
86
+ }
87
+ if (result.remotes && result.remotes.length > 0) {
88
+ lines.push("");
89
+ lines.push("## Remote branches");
90
+ lines.push("");
91
+ for (const r of result.remotes) {
92
+ lines.push(`- **${r.name}** (\`${r.sha}\`)`);
93
+ }
94
+ }
95
+ return lines.join("\n");
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Tool registration
99
+ // ---------------------------------------------------------------------------
100
+ export function registerGitBranchListTool(server) {
101
+ server.addTool({
102
+ name: "git_branch_list",
103
+ description: "List local (and optionally remote-tracking) branches. Current branch marked `current: true`. Set `includeRemotes: true` for remotes.",
104
+ annotations: {
105
+ readOnlyHint: true,
106
+ },
107
+ parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
108
+ .pick({
109
+ workspaceRoot: true,
110
+ rootIndex: true,
111
+ format: true,
112
+ })
113
+ .extend({
114
+ includeRemotes: z
115
+ .boolean()
116
+ .optional()
117
+ .default(false)
118
+ .describe("Include remote-tracking branches from refs/remotes (symbolic origin/HEAD excluded)."),
119
+ }),
120
+ execute: async (args) => {
121
+ const pre = requireSingleRepo(server, args);
122
+ if (!pre.ok)
123
+ return jsonRespond(pre.error);
124
+ const { gitTop } = pre;
125
+ const result = await runBranchList({
126
+ top: gitTop,
127
+ includeRemotes: args.includeRemotes ?? false,
128
+ });
129
+ if ("error" in result) {
130
+ return jsonRespond(result);
131
+ }
132
+ if (args.format === "json") {
133
+ return jsonRespond(result);
134
+ }
135
+ return renderBranchListMarkdown(result);
136
+ },
137
+ });
138
+ }