@rethunk/mcp-multi-root-git 2.4.0 → 2.6.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 +55 -1
- package/dist/server/batch-commit-tool.js +49 -6
- package/dist/server/git-cherry-pick-tool.js +32 -8
- package/dist/server/git-diff-summary-tool.js +46 -22
- package/dist/server/git-diff-tool.js +4 -1
- package/dist/server/git-fetch-tool.js +8 -1
- package/dist/server/git-inventory-tool.js +2 -2
- package/dist/server/git-log-tool.js +42 -0
- package/dist/server/git-parity-tool.js +2 -2
- package/dist/server/git-refs.js +84 -5
- package/dist/server/git-show-tool.js +16 -2
- package/dist/server/git-stash-tool.js +17 -16
- package/dist/server/git-tag-tool.js +3 -3
- package/dist/server/git.js +33 -5
- package/dist/server.js +7 -0
- package/docs/mcp-tools.md +246 -13
- package/package.json +14 -3
- package/schemas/batch_commit.json +1 -1
- package/schemas/git_cherry_pick.json +7 -1
- package/schemas/git_diff.json +0 -14
- package/schemas/git_diff_summary.json +0 -6
- package/schemas/git_log.json +3 -1
- package/schemas/git_show.json +1 -0
- package/tool-parameters.schema.json +12 -23
package/AGENTS.md
CHANGED
|
@@ -22,7 +22,7 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
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 |
|
|
24
24
|
| [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
|
|
25
|
-
| [`src/server/git-refs.ts`](src/server/git-refs.ts) | `isProtectedBranch`, `isSafeGitRefToken`, `isSafeGitRangeToken`, `isSafeGitAncestorRef`; `getCurrentBranch`, `resolveRef`, `isWorkingTreeClean`, `isFullyMergedInto`, `commitListBetween`; `listWorktrees`, `worktreeForBranch`; `conflictPaths` |
|
|
25
|
+
| [`src/server/git-refs.ts`](src/server/git-refs.ts) | `isProtectedBranch`, `isSafeGitRefToken`, `isSafeGitRangeToken`, `isSafeGitAncestorRef`; `getCurrentBranch`, `resolveRef`, `isWorkingTreeClean`, `isFullyMergedInto`, `isContentEquivalentlyMergedInto`, `commitListBetween`; `listWorktrees`, `worktreeForBranch`; `inferRemoteFromUpstream`; `conflictPaths` |
|
|
26
26
|
| [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below |
|
|
27
27
|
| [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `git_status` |
|
|
28
28
|
| [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `git_inventory` |
|
|
@@ -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,61 @@
|
|
|
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.6.0] — 2026-05-22
|
|
6
|
+
|
|
7
|
+
Security-hardening and correctness release surfaced by a full-repo critical review.
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- **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.
|
|
12
|
+
- **`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.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **`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.
|
|
17
|
+
- **`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.
|
|
18
|
+
- **`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.
|
|
19
|
+
- **`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.
|
|
20
|
+
- **`.gitmodules` parser** — `parseGitSubmodulePaths` matched any `path =` line regardless of INI section and ignored comments; it now tracks `[submodule]` sections and strips `;`/`#` comments.
|
|
21
|
+
- **`git_inventory` / `git_parity` v3 JSON** — `workspace_root` field corrected to `workspaceRoot` (camelCase), completing the v3 contract rename already applied to `git_log`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **`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.
|
|
26
|
+
- **`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`.
|
|
27
|
+
- **`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.
|
|
28
|
+
- **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.
|
|
29
|
+
|
|
30
|
+
## [2.5.0] — 2026-05-15
|
|
31
|
+
|
|
32
|
+
Bug-fix and documentation release; includes one new `git_log` output format.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **`git_log` `format: "oneline"`** — minimal `<sha7> <subject>` output per commit for low-token post-commit verification. Multi-root output uses `### repo (branch)` separators; single-root output emits one line per commit with no headers.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **`git_stash_list`**: format string used for-each-ref placeholders (`%(subject)`, `%(objectname:short)`) instead of git-log format (`%s`, `%h`), printing literal placeholder text as the stash message.
|
|
41
|
+
- **`git_stash_list`**: stash message containing `|` caused the SHA field to be parsed as a mid-message fragment; SHA is now always the last pipe-separated field.
|
|
42
|
+
- **`batch_commit`**: deleted tracked files (missing on disk) could not be staged; now staged via `git rm --cached`. Combining `{ path, lines }` with a deleted file is validated as an error.
|
|
43
|
+
- **`batch_commit` hunk-level staging**: `newCount=0` pure-deletion hunks were excluded from line ranges that included the deletion point (`hunkEnd` was `newStart-1`); now `hunkEnd = newStart` for zero-count hunks.
|
|
44
|
+
- **`git_diff_summary`**: diff header regex mis-parsed file paths containing ` b/` (e.g. `src/b/file.ts`), reporting wrong path and zero stats; now uses midpoint-symmetry split for non-renames, falls back to regex for renames.
|
|
45
|
+
- **`git_diff_summary`**: `git diff --stat` bar-graph character counting is scaled to terminal width and under-reports for large files; replaced with `git diff --numstat` for exact addition/deletion counts.
|
|
46
|
+
- **`git_cherry_pick`**: branch deletion after cherry-pick now uses patch-id equivalence by default so source branches with differing SHAs but identical content are correctly detected as merged. Pass `strictMergedRefEquality: true` for strict SHA-reachability semantics.
|
|
47
|
+
- **`parseGitSubmodulePaths`**: non-regular files at `.gitmodules` (character devices, sockets — common in Claude Code / sandbox environments) are now rejected via `lstatSync().isFile()` before `readFileSync`, preventing `EACCES` errors.
|
|
48
|
+
|
|
49
|
+
### Tests
|
|
50
|
+
|
|
51
|
+
- Integration tests added for `git_stash_list`, `git_stash_apply`, `git_fetch`, `git_status`, `git_inventory`, and `git_parity` execute paths.
|
|
52
|
+
- `isContentEquivalentlyMergedInto` integration tests via real git repos with cherry-picked and diverged histories.
|
|
53
|
+
- `batch_commit` dryRun tests covering deleted-file unstaging.
|
|
54
|
+
|
|
55
|
+
### Documentation
|
|
56
|
+
|
|
57
|
+
- **`git_status`, `git_inventory`, `git_parity`, `list_presets`** now have complete parameter tables, JSON shape examples, and error code tables in `docs/mcp-tools.md`.
|
|
58
|
+
- **`git_cherry_pick`** `strictMergedRefEquality` parameter added to docs; `deleteMergedBranches` description corrected (default is patch-id equivalence, not SHA-reachability).
|
|
59
|
+
- **`batch_commit`** `files` parameter and `stage_failed` error code updated to document deleted-file staging path.
|
|
6
60
|
|
|
7
61
|
## [2.4.0] — 2026-05-07
|
|
8
62
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
4
|
import { spawnGitAsync } from "./git.js";
|
|
@@ -22,7 +23,9 @@ const CommitEntrySchema = z.object({
|
|
|
22
23
|
files: z
|
|
23
24
|
.array(FileEntrySchema)
|
|
24
25
|
.min(1)
|
|
25
|
-
.describe("Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging."
|
|
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."),
|
|
26
29
|
});
|
|
27
30
|
const PushModeSchema = z
|
|
28
31
|
.enum(["never", "after"])
|
|
@@ -74,7 +77,9 @@ function extractOverlappingHunks(diffContent, fromLine, toLine) {
|
|
|
74
77
|
if (hunkMatch) {
|
|
75
78
|
const newStart = Number.parseInt(hunkMatch[1] || "0", 10);
|
|
76
79
|
const newCount = Number.parseInt(hunkMatch[2] || "1", 10);
|
|
77
|
-
|
|
80
|
+
// newCount=0 means pure deletion; hunkEnd must equal newStart so ranges
|
|
81
|
+
// that include newStart correctly capture the deletion.
|
|
82
|
+
const hunkEnd = newCount === 0 ? newStart : newStart + newCount - 1;
|
|
78
83
|
// Check if hunk overlaps with requested line range
|
|
79
84
|
const hasOverlap = !(hunkEnd < fromLine || newStart > toLine);
|
|
80
85
|
if (hasOverlap) {
|
|
@@ -123,6 +128,23 @@ function extractOverlappingHunks(diffContent, fromLine, toLine) {
|
|
|
123
128
|
* overlapping the range are staged via a partial patch. Otherwise, stages the whole file.
|
|
124
129
|
*/
|
|
125
130
|
async function stageFile(gitTop, filePath, lines) {
|
|
131
|
+
const absPath = resolvePathForRepo(filePath, gitTop);
|
|
132
|
+
const fileOnDisk = existsSync(absPath);
|
|
133
|
+
if (!fileOnDisk) {
|
|
134
|
+
if (lines) {
|
|
135
|
+
return { ok: false, error: "cannot stage line range for deleted file" };
|
|
136
|
+
}
|
|
137
|
+
// File missing on disk — stage as removal if tracked in HEAD
|
|
138
|
+
const lsResult = await spawnGitAsync(gitTop, ["ls-files", "--error-unmatch", "--", filePath]);
|
|
139
|
+
if (!lsResult.ok) {
|
|
140
|
+
return { ok: false, error: `pathspec '${filePath}' did not match any files` };
|
|
141
|
+
}
|
|
142
|
+
const rmResult = await spawnGitAsync(gitTop, ["rm", "--cached", "--", filePath]);
|
|
143
|
+
return {
|
|
144
|
+
ok: rmResult.ok,
|
|
145
|
+
error: rmResult.ok ? undefined : (rmResult.stderr || rmResult.stdout).trim(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
126
148
|
if (!lines) {
|
|
127
149
|
// Simple case: stage the whole file
|
|
128
150
|
const addResult = await spawnGitAsync(gitTop, ["add", "--", filePath]);
|
|
@@ -140,8 +162,14 @@ async function stageFile(gitTop, filePath, lines) {
|
|
|
140
162
|
if (!partialPatch) {
|
|
141
163
|
return { ok: false, error: "No hunks found in line range" };
|
|
142
164
|
}
|
|
143
|
-
//
|
|
144
|
-
const
|
|
165
|
+
// Resolve the real git dir (works for linked worktrees where .git is a file, not a dir)
|
|
166
|
+
const gitDirResult = await spawnGitAsync(gitTop, ["rev-parse", "--absolute-git-dir"]);
|
|
167
|
+
if (!gitDirResult.ok) {
|
|
168
|
+
return { ok: false, error: (gitDirResult.stderr || gitDirResult.stdout).trim() };
|
|
169
|
+
}
|
|
170
|
+
const gitDir = gitDirResult.stdout.trim();
|
|
171
|
+
// Write partial patch to temp file in the git dir and apply it to the index
|
|
172
|
+
const tempPatchFile = `${gitDir}/.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
|
|
145
173
|
const { writeFileSync, unlinkSync } = await import("node:fs");
|
|
146
174
|
writeFileSync(tempPatchFile, partialPatch, "utf8");
|
|
147
175
|
const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
|
|
@@ -217,6 +245,18 @@ export function registerBatchCommitTool(server) {
|
|
|
217
245
|
const gitTop = pre.gitTop;
|
|
218
246
|
const results = [];
|
|
219
247
|
const stagedFilesForCleanup = new Set();
|
|
248
|
+
// Snapshot already-staged paths BEFORE the preview loop so dry-run cleanup
|
|
249
|
+
// doesn't unstage files the caller had staged before invoking.
|
|
250
|
+
let preStagedPaths = new Set();
|
|
251
|
+
if (args.dryRun) {
|
|
252
|
+
const snapResult = await spawnGitAsync(gitTop, ["diff", "--cached", "--name-only"]);
|
|
253
|
+
if (snapResult.ok) {
|
|
254
|
+
preStagedPaths = new Set(snapResult.stdout
|
|
255
|
+
.split("\n")
|
|
256
|
+
.map((s) => s.trim())
|
|
257
|
+
.filter(Boolean));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
220
260
|
for (let i = 0; i < args.commits.length; i++) {
|
|
221
261
|
const entry = args.commits[i];
|
|
222
262
|
if (!entry)
|
|
@@ -276,10 +316,13 @@ export function registerBatchCommitTool(server) {
|
|
|
276
316
|
});
|
|
277
317
|
break;
|
|
278
318
|
}
|
|
279
|
-
// Track staged files for cleanup in dry-run
|
|
319
|
+
// Track staged files for cleanup in dry-run, excluding paths that were
|
|
320
|
+
// already staged before this call (so pre-existing staged state survives).
|
|
280
321
|
if (args.dryRun) {
|
|
281
322
|
for (const path of filePaths) {
|
|
282
|
-
|
|
323
|
+
if (!preStagedPaths.has(path)) {
|
|
324
|
+
stagedFilesForCleanup.add(path);
|
|
325
|
+
}
|
|
283
326
|
}
|
|
284
327
|
}
|
|
285
328
|
// --- Dry-run mode: collect preview and unstage ---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { spawnGitAsync } from "./git.js";
|
|
3
|
-
import { commitListBetween, conflictPaths, getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
3
|
+
import { commitListBetween, conflictPaths, getCurrentBranch, isContentEquivalentlyMergedInto, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
4
4
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
5
|
import { requireSingleRepo } from "./roots.js";
|
|
6
6
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -96,8 +96,11 @@ export function registerGitCherryPickTool(server) {
|
|
|
96
96
|
description: "Play commits from one or more sources onto a destination. Sources may be SHAs, " +
|
|
97
97
|
"`A..B` ranges, or branch names (expanded to `onto..<branch>`, oldest-first). " +
|
|
98
98
|
"Commits already reachable from the destination are skipped. Refuses on dirty tree; " +
|
|
99
|
-
"stops on the first conflict and reports paths. Optional flags auto-delete
|
|
100
|
-
"
|
|
99
|
+
"stops on the first conflict and reports paths. Optional flags auto-delete source " +
|
|
100
|
+
"branches and worktrees after success; deletion uses patch-id equivalence by default " +
|
|
101
|
+
"(content-identical commits with different SHAs are treated as merged, which is the " +
|
|
102
|
+
"normal cherry-pick outcome). Pass `strictMergedRefEquality: true` for strict `git branch -d` " +
|
|
103
|
+
"ancestry semantics. Protected branch names always skipped.",
|
|
101
104
|
annotations: {
|
|
102
105
|
readOnlyHint: false,
|
|
103
106
|
destructiveHint: false,
|
|
@@ -127,6 +130,15 @@ export function registerGitCherryPickTool(server) {
|
|
|
127
130
|
.default(false)
|
|
128
131
|
.describe("After success, remove any local worktree attached to a branch-kind source " +
|
|
129
132
|
"(`git worktree remove`). Protected tails always skipped."),
|
|
133
|
+
strictMergedRefEquality: z
|
|
134
|
+
.boolean()
|
|
135
|
+
.optional()
|
|
136
|
+
.default(false)
|
|
137
|
+
.describe("When false (default), branch deletion uses patch-id equivalence: a source branch " +
|
|
138
|
+
"is deleted when every commit it contains has a content-equivalent commit on the " +
|
|
139
|
+
"destination (same diff, different SHA — the normal cherry-pick outcome). " +
|
|
140
|
+
"Set to true to require strict ref ancestry (`git branch -d` semantics), which " +
|
|
141
|
+
"will refuse deletion after a cherry-pick because the SHA differs."),
|
|
130
142
|
}),
|
|
131
143
|
execute: async (args) => {
|
|
132
144
|
const pre = requireSingleRepo(server, args);
|
|
@@ -221,11 +233,23 @@ export function registerGitCherryPickTool(server) {
|
|
|
221
233
|
}
|
|
222
234
|
}
|
|
223
235
|
if (args.deleteMergedBranches) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
236
|
+
if (args.strictMergedRefEquality) {
|
|
237
|
+
const merged = await isFullyMergedInto(gitTop, src.raw, onto);
|
|
238
|
+
if (merged) {
|
|
239
|
+
const r = await spawnGitAsync(gitTop, ["branch", "-d", src.raw]);
|
|
240
|
+
if (r.ok)
|
|
241
|
+
src.branchDeleted = true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
const merged = await isContentEquivalentlyMergedInto(gitTop, src.raw, onto);
|
|
246
|
+
if (merged) {
|
|
247
|
+
// -D required: git branch -d checks ref ancestry (fails after cherry-pick),
|
|
248
|
+
// but we've already verified content equivalence via patch-id.
|
|
249
|
+
const r = await spawnGitAsync(gitTop, ["branch", "-D", src.raw]);
|
|
250
|
+
if (r.ok)
|
|
251
|
+
src.branchDeleted = true;
|
|
252
|
+
}
|
|
229
253
|
}
|
|
230
254
|
}
|
|
231
255
|
}
|
|
@@ -24,23 +24,25 @@ const DEFAULT_EXCLUDE_PATTERNS = [
|
|
|
24
24
|
// Helpers
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
/**
|
|
27
|
-
* Parse `git diff --
|
|
28
|
-
* Format: "
|
|
29
|
-
*
|
|
27
|
+
* Parse `git diff --numstat` output into exact per-file counts.
|
|
28
|
+
* Format per line: "<additions>\t<deletions>\t<path>"
|
|
29
|
+
* Binary files emit "-\t-\t<path>" and are recorded as 0/0.
|
|
30
30
|
*/
|
|
31
|
-
function
|
|
31
|
+
function parseNumstatOutput(numstat) {
|
|
32
32
|
const result = new Map();
|
|
33
|
-
for (const line of
|
|
34
|
-
|
|
35
|
-
if (
|
|
33
|
+
for (const line of numstat.split("\n")) {
|
|
34
|
+
const parts = line.split("\t");
|
|
35
|
+
if (parts.length < 3)
|
|
36
36
|
continue;
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const additions =
|
|
42
|
-
const deletions =
|
|
43
|
-
|
|
37
|
+
const [addStr, delStr, ...pathParts] = parts;
|
|
38
|
+
const filePath = pathParts.join("\t");
|
|
39
|
+
if (!filePath)
|
|
40
|
+
continue;
|
|
41
|
+
const additions = addStr === "-" ? 0 : Number.parseInt(addStr ?? "0", 10);
|
|
42
|
+
const deletions = delStr === "-" ? 0 : Number.parseInt(delStr ?? "0", 10);
|
|
43
|
+
if (!Number.isNaN(additions) && !Number.isNaN(deletions)) {
|
|
44
|
+
result.set(filePath, { additions, deletions });
|
|
45
|
+
}
|
|
44
46
|
}
|
|
45
47
|
return result;
|
|
46
48
|
}
|
|
@@ -68,10 +70,26 @@ function parseDiffOutput(diff) {
|
|
|
68
70
|
* Body may contain "rename from", "rename to", "new file mode", "deleted file mode".
|
|
69
71
|
*/
|
|
70
72
|
function extractFileInfo(header, body) {
|
|
71
|
-
// Parse "diff --git a/X b/Y"
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const
|
|
73
|
+
// Parse "diff --git a/X b/Y". For non-renames X === Y; use midpoint split so
|
|
74
|
+
// paths containing " b/" (e.g. "src/b/file.ts") are not mis-parsed by a greedy regex.
|
|
75
|
+
const prefix = "diff --git a/";
|
|
76
|
+
const raw = header.startsWith(prefix) ? header.slice(prefix.length) : "";
|
|
77
|
+
const midLen = (raw.length - " b/".length) / 2;
|
|
78
|
+
let aPath = "";
|
|
79
|
+
let bPath = "";
|
|
80
|
+
if (Number.isInteger(midLen) && midLen > 0) {
|
|
81
|
+
const candidate = raw.slice(0, midLen);
|
|
82
|
+
if (raw.slice(midLen) === ` b/${candidate}`) {
|
|
83
|
+
aPath = candidate;
|
|
84
|
+
bPath = candidate;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!aPath) {
|
|
88
|
+
// Fall back for renames (aPath ≠ bPath); rename paths also come from body lines.
|
|
89
|
+
const headerMatch = /^diff --git a\/(.+) b\/(.+)$/.exec(header);
|
|
90
|
+
aPath = headerMatch?.[1] ?? "";
|
|
91
|
+
bPath = headerMatch?.[2] ?? aPath;
|
|
92
|
+
}
|
|
75
93
|
let status = "modified";
|
|
76
94
|
let oldPath;
|
|
77
95
|
if (/^new file mode/m.test(body)) {
|
|
@@ -84,6 +102,12 @@ function extractFileInfo(header, body) {
|
|
|
84
102
|
status = "renamed";
|
|
85
103
|
const fromMatch = /^rename from (.+)$/m.exec(body);
|
|
86
104
|
oldPath = fromMatch?.[1];
|
|
105
|
+
// Prefer the authoritative "rename to <path>" body line over the greedy
|
|
106
|
+
// header regex split, which mis-parses paths containing the literal " b/".
|
|
107
|
+
const toMatch = /^rename to (.+)$/m.exec(body);
|
|
108
|
+
if (toMatch?.[1]) {
|
|
109
|
+
bPath = toMatch[1];
|
|
110
|
+
}
|
|
87
111
|
}
|
|
88
112
|
const path = status === "deleted" ? aPath : bPath;
|
|
89
113
|
return { path, oldPath, status };
|
|
@@ -160,7 +184,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
160
184
|
annotations: {
|
|
161
185
|
readOnlyHint: true,
|
|
162
186
|
},
|
|
163
|
-
parameters: WorkspacePickSchema.extend({
|
|
187
|
+
parameters: WorkspacePickSchema.omit({ allWorkspaceRoots: true }).extend({
|
|
164
188
|
range: z
|
|
165
189
|
.string()
|
|
166
190
|
.optional()
|
|
@@ -202,15 +226,15 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
202
226
|
return jsonRespond({ error: diffArgsResult.error });
|
|
203
227
|
}
|
|
204
228
|
const diffArgs = diffArgsResult.args;
|
|
205
|
-
// --- Run git diff --
|
|
206
|
-
const statResult = await spawnGitAsync(gitTop, ["diff", "--
|
|
229
|
+
// --- Run git diff --numstat for exact addition/deletion counts ---
|
|
230
|
+
const statResult = await spawnGitAsync(gitTop, ["diff", "--numstat", ...diffArgs]);
|
|
207
231
|
if (!statResult.ok) {
|
|
208
232
|
return jsonRespond({
|
|
209
233
|
error: "git_diff_failed",
|
|
210
234
|
detail: (statResult.stderr || statResult.stdout).trim(),
|
|
211
235
|
});
|
|
212
236
|
}
|
|
213
|
-
const statMap =
|
|
237
|
+
const statMap = parseNumstatOutput(statResult.stdout);
|
|
214
238
|
// --- Run git diff ---
|
|
215
239
|
const diffResult = await spawnGitAsync(gitTop, ["diff", ...diffArgs]);
|
|
216
240
|
if (!diffResult.ok) {
|
|
@@ -61,7 +61,10 @@ export function registerGitDiffTool(server) {
|
|
|
61
61
|
annotations: {
|
|
62
62
|
readOnlyHint: true,
|
|
63
63
|
},
|
|
64
|
-
parameters: WorkspacePickSchema.
|
|
64
|
+
parameters: WorkspacePickSchema.omit({
|
|
65
|
+
absoluteGitRoots: true,
|
|
66
|
+
allWorkspaceRoots: true,
|
|
67
|
+
}).extend({
|
|
65
68
|
base: z
|
|
66
69
|
.string()
|
|
67
70
|
.optional()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { spawnGitAsync } from "./git.js";
|
|
2
|
+
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
3
3
|
import { jsonRespond } from "./json.js";
|
|
4
4
|
import { requireSingleRepo } from "./roots.js";
|
|
5
5
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -78,6 +78,13 @@ export function registerGitFetchTool(server) {
|
|
|
78
78
|
const branch = args.branch?.trim();
|
|
79
79
|
const prune = args.prune === true;
|
|
80
80
|
const tags = args.tags === true;
|
|
81
|
+
// Validate remote and branch to prevent argument injection.
|
|
82
|
+
if (!isSafeGitUpstreamToken(remote)) {
|
|
83
|
+
return jsonRespond({ error: "unsafe_remote_token", remote });
|
|
84
|
+
}
|
|
85
|
+
if (branch && !isSafeGitUpstreamToken(branch)) {
|
|
86
|
+
return jsonRespond({ error: "unsafe_ref_token", branch });
|
|
87
|
+
}
|
|
81
88
|
// Build git fetch command
|
|
82
89
|
const fetchArgs = ["fetch"];
|
|
83
90
|
if (prune) {
|
|
@@ -57,7 +57,7 @@ export function registerGitInventoryTool(server) {
|
|
|
57
57
|
const err = { error: "not_a_git_repository", path: workspaceRoot };
|
|
58
58
|
if (args.format === "json") {
|
|
59
59
|
allJson.push({
|
|
60
|
-
|
|
60
|
+
workspaceRoot: workspaceRoot,
|
|
61
61
|
...(upstream.mode === "fixed" ? { upstream } : {}),
|
|
62
62
|
entries: [
|
|
63
63
|
makeSkipEntry(workspaceRoot, workspaceRoot, upstream.mode, JSON.stringify(err)),
|
|
@@ -117,7 +117,7 @@ export function registerGitInventoryTool(server) {
|
|
|
117
117
|
}
|
|
118
118
|
if (args.format === "json") {
|
|
119
119
|
allJson.push({
|
|
120
|
-
|
|
120
|
+
workspaceRoot: top,
|
|
121
121
|
...spreadDefined("presetSchemaVersion", presetSchemaVersion),
|
|
122
122
|
...spreadWhen(nestedRootsTruncated, {
|
|
123
123
|
nestedRootsTruncated: true,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitTopLevel, spawnGitAsync } from "./git.js";
|
|
4
|
+
import { isSafeGitAncestorRef } from "./git-refs.js";
|
|
4
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
6
|
import { requireGitAndRoots } from "./roots.js";
|
|
6
7
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -133,6 +134,22 @@ async function runGitLog(opts) {
|
|
|
133
134
|
// ---------------------------------------------------------------------------
|
|
134
135
|
// Markdown rendering
|
|
135
136
|
// ---------------------------------------------------------------------------
|
|
137
|
+
function renderLogOneline(group, multiRoot) {
|
|
138
|
+
const lines = [];
|
|
139
|
+
if (multiRoot) {
|
|
140
|
+
lines.push(`### ${group.repo} (${group.branch})`);
|
|
141
|
+
}
|
|
142
|
+
for (const c of group.commits) {
|
|
143
|
+
lines.push(`${c.sha.slice(0, 7)} ${c.subject}`);
|
|
144
|
+
}
|
|
145
|
+
if (group.commits.length === 0) {
|
|
146
|
+
lines.push("(no commits)");
|
|
147
|
+
}
|
|
148
|
+
if (group.truncated) {
|
|
149
|
+
lines.push(`(+${group.omittedCount} more)`);
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
136
153
|
function renderLogMarkdown(group, filterSummary) {
|
|
137
154
|
const lines = [];
|
|
138
155
|
lines.push(`### ${group.repo} (${group.branch})${filterSummary ? ` — ${filterSummary}` : ""}`);
|
|
@@ -164,6 +181,13 @@ export function registerGitLogTool(server) {
|
|
|
164
181
|
readOnlyHint: true,
|
|
165
182
|
},
|
|
166
183
|
parameters: WorkspacePickSchema.extend({
|
|
184
|
+
format: z
|
|
185
|
+
.enum(["markdown", "json", "oneline"])
|
|
186
|
+
.optional()
|
|
187
|
+
.default("markdown")
|
|
188
|
+
.describe("`markdown` (default): headed sections per root. " +
|
|
189
|
+
"`json`: structured groups array. " +
|
|
190
|
+
"`oneline`: `<sha7> <subject>` per line, no headers (single-root) or `### repo (branch)` separator per group (multi-root). Lowest-token option for post-commit verification."),
|
|
167
191
|
since: z
|
|
168
192
|
.string()
|
|
169
193
|
.optional()
|
|
@@ -208,6 +232,10 @@ export function registerGitLogTool(server) {
|
|
|
208
232
|
return jsonRespond({ error: "invalid_paths", path: p });
|
|
209
233
|
}
|
|
210
234
|
}
|
|
235
|
+
// Validate branch — reject leading-dash and other injection attempts.
|
|
236
|
+
if (args.branch && !isSafeGitAncestorRef(args.branch)) {
|
|
237
|
+
return jsonRespond({ error: "unsafe_ref_token", branch: args.branch });
|
|
238
|
+
}
|
|
211
239
|
const maxCommits = Math.min(args.maxCommits ?? DEFAULT_MAX_COMMITS, MAX_COMMITS_HARD_CAP);
|
|
212
240
|
// Fan out across roots.
|
|
213
241
|
const jobs = pre.roots.map((rootInput) => ({ rootInput }));
|
|
@@ -258,6 +286,20 @@ export function registerGitLogTool(server) {
|
|
|
258
286
|
});
|
|
259
287
|
return jsonRespond({ groups });
|
|
260
288
|
}
|
|
289
|
+
// Oneline
|
|
290
|
+
if (args.format === "oneline") {
|
|
291
|
+
const multiRoot = results.length > 1;
|
|
292
|
+
const chunks = [];
|
|
293
|
+
for (const r of results) {
|
|
294
|
+
if (r._error) {
|
|
295
|
+
chunks.push(multiRoot ? `### ${r.workspaceRoot}\n(error: ${r.error})` : `(error: ${r.error})`);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const { _error: _e, ...group } = r;
|
|
299
|
+
chunks.push(renderLogOneline(group, multiRoot));
|
|
300
|
+
}
|
|
301
|
+
return chunks.join("\n\n");
|
|
302
|
+
}
|
|
261
303
|
// Markdown
|
|
262
304
|
const mdChunks = ["# Git log"];
|
|
263
305
|
for (const r of results) {
|
|
@@ -37,7 +37,7 @@ export function registerGitParityTool(server) {
|
|
|
37
37
|
const err = jsonRespond(errPayload);
|
|
38
38
|
if (args.format === "json") {
|
|
39
39
|
results.push({
|
|
40
|
-
|
|
40
|
+
workspaceRoot: workspaceRoot,
|
|
41
41
|
status: "MISMATCH",
|
|
42
42
|
pairs: [{ label: "—", leftPath: "", rightPath: "", match: false, error: err }],
|
|
43
43
|
});
|
|
@@ -114,7 +114,7 @@ export function registerGitParityTool(server) {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
results.push({
|
|
117
|
-
|
|
117
|
+
workspaceRoot: top,
|
|
118
118
|
...spreadDefined("presetSchemaVersion", parityPresetSchemaVersion),
|
|
119
119
|
status: allOk ? "OK" : "MISMATCH",
|
|
120
120
|
pairs: pairResults,
|