@rethunk/mcp-multi-root-git 2.6.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 +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/dist/server/batch-commit-tool.js +7 -6
- 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 +3 -2
- package/dist/server/git-diff-tool.js +56 -12
- package/dist/server/git-fetch-tool.js +142 -14
- package/dist/server/git-inventory-tool.js +5 -4
- package/dist/server/git-log-tool.js +10 -5
- package/dist/server/git-merge-tool.js +15 -14
- package/dist/server/git-parity-tool.js +3 -2
- package/dist/server/git-push-tool.js +9 -5
- package/dist/server/git-reflog-tool.js +126 -0
- package/dist/server/git-reset-soft-tool.js +4 -3
- package/dist/server/git-show-tool.js +83 -23
- package/dist/server/git-stash-tool.js +2 -1
- package/dist/server/git-tag-tool.js +8 -7
- package/dist/server/git-worktree-tool.js +8 -7
- package/dist/server/git.js +70 -4
- 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/docs/mcp-tools.md +119 -6
- package/package.json +2 -2
- package/schemas/git_blame.json +52 -0
- package/schemas/git_branch_list.json +36 -0
- package/schemas/git_diff.json +14 -1
- package/schemas/git_reflog.json +44 -0
- package/schemas/git_show.json +12 -1
- package/schemas/index.json +15 -0
- package/tool-parameters.schema.json +152 -2
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 |
|
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/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";
|
|
@@ -192,11 +193,11 @@ async function stageFile(gitTop, filePath, lines) {
|
|
|
192
193
|
export async function runPushAfter(gitTop) {
|
|
193
194
|
const branch = await getCurrentBranch(gitTop);
|
|
194
195
|
if (!branch) {
|
|
195
|
-
return { ok: false, error:
|
|
196
|
+
return { ok: false, error: ERROR_CODES.PUSH_DETACHED_HEAD };
|
|
196
197
|
}
|
|
197
198
|
const t = await inferRemoteFromUpstream(gitTop);
|
|
198
199
|
if (!t.ok) {
|
|
199
|
-
return { ok: false, branch, error:
|
|
200
|
+
return { ok: false, branch, error: ERROR_CODES.PUSH_NO_UPSTREAM, detail: t.detail };
|
|
200
201
|
}
|
|
201
202
|
const pushResult = await spawnGitAsync(gitTop, ["push", t.remote, branch]);
|
|
202
203
|
if (!pushResult.ok) {
|
|
@@ -204,7 +205,7 @@ export async function runPushAfter(gitTop) {
|
|
|
204
205
|
ok: false,
|
|
205
206
|
branch,
|
|
206
207
|
upstream: t.upstream,
|
|
207
|
-
error:
|
|
208
|
+
error: ERROR_CODES.PUSH_FAILED,
|
|
208
209
|
detail: (pushResult.stderr || pushResult.stdout).trim(),
|
|
209
210
|
};
|
|
210
211
|
}
|
|
@@ -288,7 +289,7 @@ export function registerBatchCommitTool(server) {
|
|
|
288
289
|
ok: false,
|
|
289
290
|
message: entry.message,
|
|
290
291
|
files: filePaths,
|
|
291
|
-
error:
|
|
292
|
+
error: ERROR_CODES.PATH_ESCAPES_REPOSITORY,
|
|
292
293
|
detail: escapedPaths.join(", "),
|
|
293
294
|
});
|
|
294
295
|
break;
|
|
@@ -310,7 +311,7 @@ export function registerBatchCommitTool(server) {
|
|
|
310
311
|
ok: false,
|
|
311
312
|
message: entry.message,
|
|
312
313
|
files: filePaths,
|
|
313
|
-
error:
|
|
314
|
+
error: ERROR_CODES.STAGE_FAILED,
|
|
314
315
|
detail: stagingError,
|
|
315
316
|
...spreadDefined("output", stagingError || undefined),
|
|
316
317
|
});
|
|
@@ -349,7 +350,7 @@ export function registerBatchCommitTool(server) {
|
|
|
349
350
|
ok: false,
|
|
350
351
|
message: entry.message,
|
|
351
352
|
files: filePaths,
|
|
352
|
-
error:
|
|
353
|
+
error: ERROR_CODES.COMMIT_FAILED,
|
|
353
354
|
detail: gitOutput,
|
|
354
355
|
...spreadDefined("output", gitOutput || undefined),
|
|
355
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
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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 branches (and optionally remote-tracking branches) for a single git repository. " +
|
|
104
|
+
"Returns `{ branches: [{ name, sha, current, upstream? }], remotes?: [{ name, sha }] }`. " +
|
|
105
|
+
"The current branch is marked with `current: true`. Remote branches are included when `includeRemotes: true`.",
|
|
106
|
+
annotations: {
|
|
107
|
+
readOnlyHint: true,
|
|
108
|
+
},
|
|
109
|
+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
|
|
110
|
+
.pick({
|
|
111
|
+
workspaceRoot: true,
|
|
112
|
+
rootIndex: true,
|
|
113
|
+
format: true,
|
|
114
|
+
})
|
|
115
|
+
.extend({
|
|
116
|
+
includeRemotes: z
|
|
117
|
+
.boolean()
|
|
118
|
+
.optional()
|
|
119
|
+
.default(false)
|
|
120
|
+
.describe("When true, also return remote-tracking branches from refs/remotes (excluding symbolic origin/HEAD)."),
|
|
121
|
+
}),
|
|
122
|
+
execute: async (args) => {
|
|
123
|
+
const pre = requireSingleRepo(server, args);
|
|
124
|
+
if (!pre.ok)
|
|
125
|
+
return jsonRespond(pre.error);
|
|
126
|
+
const { gitTop } = pre;
|
|
127
|
+
const result = await runBranchList({
|
|
128
|
+
top: gitTop,
|
|
129
|
+
includeRemotes: args.includeRemotes ?? false,
|
|
130
|
+
});
|
|
131
|
+
if ("error" in result) {
|
|
132
|
+
return jsonRespond(result);
|
|
133
|
+
}
|
|
134
|
+
if (args.format === "json") {
|
|
135
|
+
return jsonRespond(result);
|
|
136
|
+
}
|
|
137
|
+
return renderBranchListMarkdown(result);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
3
|
import { spawnGitAsync } from "./git.js";
|
|
3
4
|
import { commitListBetween, conflictPaths, getCurrentBranch, isContentEquivalentlyMergedInto, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
4
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
@@ -30,12 +31,12 @@ async function branchExists(gitTop, name) {
|
|
|
30
31
|
async function resolveSource(gitTop, onto, raw) {
|
|
31
32
|
if (raw.includes("..")) {
|
|
32
33
|
if (!isSafeGitRangeToken(raw)) {
|
|
33
|
-
return { error:
|
|
34
|
+
return { error: ERROR_CODES.UNSAFE_REF_TOKEN, raw };
|
|
34
35
|
}
|
|
35
36
|
const r = await spawnGitAsync(gitTop, ["rev-list", "--reverse", raw]);
|
|
36
37
|
if (!r.ok) {
|
|
37
38
|
return {
|
|
38
|
-
error:
|
|
39
|
+
error: ERROR_CODES.RANGE_RESOLUTION_FAILED,
|
|
39
40
|
detail: (r.stderr || r.stdout).trim(),
|
|
40
41
|
raw,
|
|
41
42
|
};
|
|
@@ -47,18 +48,18 @@ async function resolveSource(gitTop, onto, raw) {
|
|
|
47
48
|
return { raw, kind: "range", commits };
|
|
48
49
|
}
|
|
49
50
|
if (!isSafeGitRefToken(raw)) {
|
|
50
|
-
return { error:
|
|
51
|
+
return { error: ERROR_CODES.UNSAFE_REF_TOKEN, raw };
|
|
51
52
|
}
|
|
52
53
|
if (await branchExists(gitTop, raw)) {
|
|
53
54
|
const commits = await commitListBetween(gitTop, onto, raw);
|
|
54
55
|
if (commits === null) {
|
|
55
|
-
return { error:
|
|
56
|
+
return { error: ERROR_CODES.RANGE_RESOLUTION_FAILED, raw };
|
|
56
57
|
}
|
|
57
58
|
return { raw, kind: "branch", commits };
|
|
58
59
|
}
|
|
59
60
|
const sha = await resolveRef(gitTop, raw);
|
|
60
61
|
if (!sha) {
|
|
61
|
-
return { error:
|
|
62
|
+
return { error: ERROR_CODES.SOURCE_NOT_FOUND, raw };
|
|
62
63
|
}
|
|
63
64
|
return { raw, kind: "sha", commits: [sha] };
|
|
64
65
|
}
|
|
@@ -149,26 +150,26 @@ export function registerGitCherryPickTool(server) {
|
|
|
149
150
|
const startBranch = await getCurrentBranch(gitTop);
|
|
150
151
|
const onto = args.onto?.trim() || startBranch;
|
|
151
152
|
if (!onto)
|
|
152
|
-
return jsonRespond({ error:
|
|
153
|
+
return jsonRespond({ error: ERROR_CODES.ONTO_DETACHED_HEAD });
|
|
153
154
|
if (args.onto !== undefined && !isSafeGitRefToken(args.onto)) {
|
|
154
|
-
return jsonRespond({ error:
|
|
155
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.onto });
|
|
155
156
|
}
|
|
156
157
|
// --- Refuse dirty tree ---
|
|
157
158
|
if (!(await isWorkingTreeClean(gitTop))) {
|
|
158
|
-
return jsonRespond({ error:
|
|
159
|
+
return jsonRespond({ error: ERROR_CODES.WORKING_TREE_DIRTY });
|
|
159
160
|
}
|
|
160
161
|
// --- Ensure destination is checked out ---
|
|
161
162
|
if (onto !== startBranch) {
|
|
162
163
|
const co = await spawnGitAsync(gitTop, ["checkout", onto]);
|
|
163
164
|
if (!co.ok) {
|
|
164
165
|
return jsonRespond({
|
|
165
|
-
error:
|
|
166
|
+
error: ERROR_CODES.CHECKOUT_FAILED,
|
|
166
167
|
detail: (co.stderr || co.stdout).trim(),
|
|
167
168
|
});
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
171
|
if (!(await resolveRef(gitTop, onto))) {
|
|
171
|
-
return jsonRespond({ error:
|
|
172
|
+
return jsonRespond({ error: ERROR_CODES.DESTINATION_NOT_FOUND, ref: onto });
|
|
172
173
|
}
|
|
173
174
|
// --- Resolve each source ---
|
|
174
175
|
const resolved = [];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { matchesGlob } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
3
4
|
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
4
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
6
|
import { requireSingleRepo } from "./roots.js";
|
|
@@ -230,7 +231,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
230
231
|
const statResult = await spawnGitAsync(gitTop, ["diff", "--numstat", ...diffArgs]);
|
|
231
232
|
if (!statResult.ok) {
|
|
232
233
|
return jsonRespond({
|
|
233
|
-
error:
|
|
234
|
+
error: ERROR_CODES.GIT_DIFF_FAILED,
|
|
234
235
|
detail: (statResult.stderr || statResult.stdout).trim(),
|
|
235
236
|
});
|
|
236
237
|
}
|
|
@@ -239,7 +240,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
239
240
|
const diffResult = await spawnGitAsync(gitTop, ["diff", ...diffArgs]);
|
|
240
241
|
if (!diffResult.ok) {
|
|
241
242
|
return jsonRespond({
|
|
242
|
-
error:
|
|
243
|
+
error: ERROR_CODES.GIT_DIFF_FAILED,
|
|
243
244
|
detail: (diffResult.stderr || diffResult.stdout).trim(),
|
|
244
245
|
});
|
|
245
246
|
}
|