@rethunk/mcp-multi-root-git 3.1.0 → 4.0.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 +21 -15
- package/CHANGELOG.md +114 -39
- package/HUMANS.md +20 -39
- package/README.md +30 -13
- package/dist/repo-paths.js +57 -13
- package/dist/server/batch-commit-tool.js +187 -46
- package/dist/server/error-codes.js +25 -7
- package/dist/server/git-blame-tool.js +6 -3
- package/dist/server/git-branch-tool.js +151 -0
- package/dist/server/git-cherry-pick-tool.js +220 -11
- package/dist/server/git-conflicts-tool.js +230 -0
- package/dist/server/git-diff-summary-tool.js +36 -25
- package/dist/server/git-diff-tool.js +55 -27
- package/dist/server/git-grep-tool.js +188 -0
- package/dist/server/git-inventory-tool.js +26 -6
- package/dist/server/git-log-tool.js +35 -4
- package/dist/server/git-merge-tool.js +70 -12
- package/dist/server/git-parity-tool.js +13 -4
- package/dist/server/git-push-tool.js +3 -1
- package/dist/server/git-refs.js +48 -17
- package/dist/server/git-revert-tool.js +160 -0
- package/dist/server/git-show-tool.js +7 -3
- package/dist/server/git-stash-tool.js +110 -67
- package/dist/server/git-tag-tool.js +7 -6
- package/dist/server/git-worktree-tool.js +65 -53
- package/dist/server/git.js +116 -24
- package/dist/server/inventory.js +90 -32
- package/dist/server/presets-resource.js +37 -20
- package/dist/server/presets.js +87 -15
- package/dist/server/roots.js +13 -1
- package/dist/server/schemas.js +9 -2
- package/dist/server/test-harness.js +11 -4
- package/dist/server/tool-parameter-schemas.js +44 -55
- package/dist/server/tools.js +36 -17
- package/dist/server.js +1 -1
- package/docs/install.md +52 -5
- package/docs/mcp-tools.md +385 -178
- package/package.json +6 -6
- package/schemas/batch_commit.json +2 -2
- package/schemas/git_blame.json +1 -1
- package/schemas/git_branch.json +54 -0
- package/schemas/git_cherry_pick.json +12 -2
- package/schemas/git_cherry_pick_continue.json +34 -0
- package/schemas/{git_reflog.json → git_conflicts.json} +12 -12
- package/schemas/git_diff.json +11 -3
- package/schemas/git_grep.json +85 -0
- package/schemas/git_inventory.json +19 -5
- package/schemas/git_log.json +7 -6
- package/schemas/git_merge.json +2 -2
- package/schemas/git_parity.json +0 -5
- package/schemas/git_revert.json +47 -0
- package/schemas/git_show.json +1 -1
- package/schemas/git_stash_push.json +47 -0
- package/schemas/git_status.json +0 -5
- package/schemas/git_worktree_add.json +1 -1
- package/schemas/git_worktree_remove.json +1 -1
- package/schemas/index.json +28 -23
- package/schemas/list_presets.json +0 -5
- package/tool-parameters.schema.json +326 -167
- package/dist/server/git-branch-list-tool.js +0 -132
- package/dist/server/git-fetch-tool.js +0 -257
- package/dist/server/git-reflog-tool.js +0 -120
- package/schemas/git_branch_list.json +0 -30
- package/schemas/git_fetch.json +0 -46
- package/schemas/git_stash_list.json +0 -24
- package/schemas/git_worktree_list.json +0 -24
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { matchesGlob } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { ERROR_CODES } from "./error-codes.js";
|
|
4
|
-
import {
|
|
4
|
+
import { spawnGitAsync } from "./git.js";
|
|
5
|
+
import { isSafeGitRangeToken } from "./git-refs.js";
|
|
5
6
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
6
7
|
import { requireSingleRepo } from "./roots.js";
|
|
7
8
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -27,6 +28,8 @@ const DEFAULT_EXCLUDE_PATTERNS = [
|
|
|
27
28
|
/**
|
|
28
29
|
* Parse `git diff --numstat` output into exact per-file counts.
|
|
29
30
|
* Format per line: "<additions>\t<deletions>\t<path>"
|
|
31
|
+
* Renames emit "<additions>\t<deletions>\told => new" — keyed by the new path
|
|
32
|
+
* so lookups from extractFileInfo (which uses the destination path) succeed.
|
|
30
33
|
* Binary files emit "-\t-\t<path>" and are recorded as 0/0.
|
|
31
34
|
*/
|
|
32
35
|
export function parseNumstatOutput(numstat) {
|
|
@@ -36,9 +39,15 @@ export function parseNumstatOutput(numstat) {
|
|
|
36
39
|
if (parts.length < 3)
|
|
37
40
|
continue;
|
|
38
41
|
const [addStr, delStr, ...pathParts] = parts;
|
|
39
|
-
|
|
42
|
+
let filePath = pathParts.join("\t");
|
|
40
43
|
if (!filePath)
|
|
41
44
|
continue;
|
|
45
|
+
// Rename numstat: "old/path => new/path" — index under the new path.
|
|
46
|
+
const renameSep = " => ";
|
|
47
|
+
const renameIdx = filePath.indexOf(renameSep);
|
|
48
|
+
if (renameIdx >= 0) {
|
|
49
|
+
filePath = filePath.slice(renameIdx + renameSep.length);
|
|
50
|
+
}
|
|
42
51
|
const additions = addStr === "-" ? 0 : Number.parseInt(addStr ?? "0", 10);
|
|
43
52
|
const deletions = delStr === "-" ? 0 : Number.parseInt(delStr ?? "0", 10);
|
|
44
53
|
if (!Number.isNaN(additions) && !Number.isNaN(deletions)) {
|
|
@@ -51,7 +60,7 @@ export function parseNumstatOutput(numstat) {
|
|
|
51
60
|
* Parse `git diff` output into per-file chunks.
|
|
52
61
|
* Splits on "diff --git a/..." lines.
|
|
53
62
|
*/
|
|
54
|
-
function parseDiffOutput(diff) {
|
|
63
|
+
export function parseDiffOutput(diff) {
|
|
55
64
|
const chunks = [];
|
|
56
65
|
// Each file section starts with "diff --git"
|
|
57
66
|
const parts = diff.split(/(?=^diff --git )/m);
|
|
@@ -70,7 +79,7 @@ function parseDiffOutput(diff) {
|
|
|
70
79
|
* Header: "diff --git a/old b/new"
|
|
71
80
|
* Body may contain "rename from", "rename to", "new file mode", "deleted file mode".
|
|
72
81
|
*/
|
|
73
|
-
function extractFileInfo(header, body) {
|
|
82
|
+
export function extractFileInfo(header, body) {
|
|
74
83
|
// Parse "diff --git a/X b/Y". For non-renames X === Y; use midpoint split so
|
|
75
84
|
// paths containing " b/" (e.g. "src/b/file.ts") are not mis-parsed by a greedy regex.
|
|
76
85
|
const prefix = "diff --git a/";
|
|
@@ -114,10 +123,11 @@ function extractFileInfo(header, body) {
|
|
|
114
123
|
return { path, oldPath, status };
|
|
115
124
|
}
|
|
116
125
|
/**
|
|
117
|
-
* Truncate diff body to at most `maxLines` lines
|
|
126
|
+
* Truncate diff body to at most `maxLines` lines of the raw body text
|
|
127
|
+
* (includes index/---/+++/@@ headers — coarser than hunk-content-only counting).
|
|
118
128
|
* Returns { text, truncated }.
|
|
119
129
|
*/
|
|
120
|
-
function truncateDiffBody(body, maxLines) {
|
|
130
|
+
export function truncateDiffBody(body, maxLines) {
|
|
121
131
|
const lines = body.split("\n");
|
|
122
132
|
if (lines.length <= maxLines) {
|
|
123
133
|
return { text: body, truncated: false };
|
|
@@ -125,7 +135,7 @@ function truncateDiffBody(body, maxLines) {
|
|
|
125
135
|
return { text: lines.slice(0, maxLines).join("\n"), truncated: true };
|
|
126
136
|
}
|
|
127
137
|
/** Check whether a file path matches any of the given glob patterns. */
|
|
128
|
-
function matchesAnyPattern(filePath, patterns) {
|
|
138
|
+
export function matchesAnyPattern(filePath, patterns) {
|
|
129
139
|
const normalized = filePath.replace(/\\/g, "/");
|
|
130
140
|
for (const pattern of patterns) {
|
|
131
141
|
if (matchesGlob(normalized, pattern))
|
|
@@ -138,7 +148,7 @@ function matchesAnyPattern(filePath, patterns) {
|
|
|
138
148
|
return false;
|
|
139
149
|
}
|
|
140
150
|
/** Build the diff args array from the `range` parameter. */
|
|
141
|
-
function buildDiffArgs(range) {
|
|
151
|
+
export function buildDiffArgs(range) {
|
|
142
152
|
if (range === undefined || range === "") {
|
|
143
153
|
return { ok: true, args: [] };
|
|
144
154
|
}
|
|
@@ -149,21 +159,13 @@ function buildDiffArgs(range) {
|
|
|
149
159
|
if (normalized === "head") {
|
|
150
160
|
return { ok: true, args: ["HEAD"] };
|
|
151
161
|
}
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
const
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
if (!isSafeGitUpstreamToken(left ?? "") || !isSafeGitUpstreamToken(right ?? "")) {
|
|
158
|
-
return { ok: false, error: `unsafe_range_token: ${range}` };
|
|
159
|
-
}
|
|
160
|
-
return { ok: true, args: [`${left}${sep}${right}`] };
|
|
161
|
-
}
|
|
162
|
-
// Single ref
|
|
163
|
-
if (!isSafeGitUpstreamToken(range.trim())) {
|
|
164
|
-
return { ok: false, error: `unsafe_range_token: ${range}` };
|
|
162
|
+
// "A..B", "A...B", or a single ref (ancestor notation like "HEAD~3" accepted
|
|
163
|
+
// on any endpoint) — delegates to the shared range validator.
|
|
164
|
+
const trimmed = range.trim();
|
|
165
|
+
if (!isSafeGitRangeToken(trimmed)) {
|
|
166
|
+
return { ok: false, error: ERROR_CODES.UNSAFE_RANGE_TOKEN };
|
|
165
167
|
}
|
|
166
|
-
return { ok: true, args: [
|
|
168
|
+
return { ok: true, args: [trimmed] };
|
|
167
169
|
}
|
|
168
170
|
/** Human-readable label for the range. */
|
|
169
171
|
function rangeLabel(range, diffArgs) {
|
|
@@ -245,8 +247,9 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
245
247
|
}
|
|
246
248
|
// --- Parse diff chunks ---
|
|
247
249
|
const chunks = parseDiffOutput(diffResult.stdout);
|
|
248
|
-
const totalFiles = chunks.length;
|
|
249
250
|
// --- Apply excludePatterns and fileFilter ---
|
|
251
|
+
// totalFiles / totals count the post-filter set (before maxFiles truncation)
|
|
252
|
+
// so they stay consistent with each other; truncatedFiles covers display caps.
|
|
250
253
|
const excludePatterns = args.excludePatterns !== undefined ? args.excludePatterns : DEFAULT_EXCLUDE_PATTERNS;
|
|
251
254
|
const excludedFiles = [];
|
|
252
255
|
const includedChunks = [];
|
|
@@ -257,24 +260,32 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
257
260
|
continue;
|
|
258
261
|
}
|
|
259
262
|
if (args.fileFilter && !matchesAnyPattern(filePath, [args.fileFilter])) {
|
|
263
|
+
excludedFiles.push(filePath);
|
|
260
264
|
continue;
|
|
261
265
|
}
|
|
262
266
|
includedChunks.push(chunk);
|
|
263
267
|
}
|
|
268
|
+
const totalFiles = includedChunks.length;
|
|
264
269
|
// --- Truncate to maxFiles ---
|
|
265
270
|
const maxFiles = args.maxFiles ?? 30;
|
|
266
271
|
const maxLinesPerFile = args.maxLinesPerFile ?? 50;
|
|
267
272
|
const truncatedFileCount = includedChunks.length > maxFiles ? includedChunks.length - maxFiles : 0;
|
|
268
273
|
const processedChunks = includedChunks.slice(0, maxFiles);
|
|
269
274
|
// --- Build FileDiff entries ---
|
|
275
|
+
// Sum stats over the full filtered set (includedChunks), not only the
|
|
276
|
+
// maxFiles-truncated display slice, so totals match totalFiles.
|
|
270
277
|
let totalAdditions = 0;
|
|
271
278
|
let totalDeletions = 0;
|
|
279
|
+
for (const chunk of includedChunks) {
|
|
280
|
+
const { path: filePath } = extractFileInfo(chunk.header, chunk.body);
|
|
281
|
+
const stat = statMap.get(filePath) ?? { additions: 0, deletions: 0 };
|
|
282
|
+
totalAdditions += stat.additions;
|
|
283
|
+
totalDeletions += stat.deletions;
|
|
284
|
+
}
|
|
272
285
|
const files = [];
|
|
273
286
|
for (const chunk of processedChunks) {
|
|
274
287
|
const { path: filePath, oldPath, status } = extractFileInfo(chunk.header, chunk.body);
|
|
275
288
|
const stat = statMap.get(filePath) ?? { additions: 0, deletions: 0 };
|
|
276
|
-
totalAdditions += stat.additions;
|
|
277
|
-
totalDeletions += stat.deletions;
|
|
278
289
|
const { text: diffText, truncated } = truncateDiffBody(chunk.body, maxLinesPerFile);
|
|
279
290
|
files.push({
|
|
280
291
|
path: filePath,
|
|
@@ -1,31 +1,36 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
3
|
import { ERROR_CODES } from "./error-codes.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { spawnGitAsync } from "./git.js";
|
|
5
|
+
import { isSafeGitCommitIsh } from "./git-refs.js";
|
|
6
|
+
import { jsonRespond, spreadWhen } from "./json.js";
|
|
6
7
|
import { requireSingleRepo } from "./roots.js";
|
|
7
8
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
10
|
+
// Constants
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
/** Default byte cap on raw diff stdout to keep agent context bounded. */
|
|
13
|
+
export const GIT_DIFF_DEFAULT_MAX_BYTES = 512_000;
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
9
15
|
// Helpers
|
|
10
16
|
// ---------------------------------------------------------------------------
|
|
11
17
|
/** Build the diff args array from parameters. */
|
|
12
|
-
function buildDiffArgs(opts) {
|
|
18
|
+
export function buildDiffArgs(opts) {
|
|
13
19
|
const args = ["diff"];
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// If only base is given, use base~0..HEAD (implicit HEAD)
|
|
21
|
-
const baseStr = opts.base?.trim() ?? "HEAD";
|
|
22
|
-
const headStr = opts.head?.trim() ?? "HEAD";
|
|
23
|
-
if (!isSafeGitUpstreamToken(baseStr) || !isSafeGitUpstreamToken(headStr)) {
|
|
20
|
+
// Docs: `staged` is ignored when `base` is provided; `head` is used only
|
|
21
|
+
// when `base` is set. Prefer base..head over --staged whenever base is set.
|
|
22
|
+
if (opts.base) {
|
|
23
|
+
const baseStr = opts.base.trim();
|
|
24
|
+
const headStr = opts.head?.trim() || "HEAD";
|
|
25
|
+
if (!isSafeGitCommitIsh(baseStr) || !isSafeGitCommitIsh(headStr)) {
|
|
24
26
|
return { ok: false, error: ERROR_CODES.UNSAFE_RANGE_TOKEN };
|
|
25
27
|
}
|
|
26
|
-
// Use two-dot range: base..head
|
|
27
28
|
args.push(`${baseStr}..${headStr}`);
|
|
28
29
|
}
|
|
30
|
+
else if (opts.staged === true) {
|
|
31
|
+
args.push("--staged");
|
|
32
|
+
}
|
|
33
|
+
// head without base is ignored (matches docs: head used only when base is set)
|
|
29
34
|
// Apply unified context width if specified
|
|
30
35
|
if (typeof opts.unified === "number") {
|
|
31
36
|
args.push(`-U${opts.unified}`);
|
|
@@ -37,16 +42,16 @@ function buildDiffArgs(opts) {
|
|
|
37
42
|
return { ok: true, args };
|
|
38
43
|
}
|
|
39
44
|
/** Human-readable label for the range. */
|
|
40
|
-
function rangeLabel(opts) {
|
|
45
|
+
export function rangeLabel(opts) {
|
|
41
46
|
let label = "";
|
|
42
|
-
if (opts.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
else if (opts.base || opts.head) {
|
|
46
|
-
const baseStr = opts.base?.trim() ?? "HEAD";
|
|
47
|
-
const headStr = opts.head?.trim() ?? "HEAD";
|
|
47
|
+
if (opts.base) {
|
|
48
|
+
const baseStr = opts.base.trim();
|
|
49
|
+
const headStr = opts.head?.trim() || "HEAD";
|
|
48
50
|
label = `${baseStr}..${headStr}`;
|
|
49
51
|
}
|
|
52
|
+
else if (opts.staged === true) {
|
|
53
|
+
label = "staged changes";
|
|
54
|
+
}
|
|
50
55
|
else {
|
|
51
56
|
label = "unstaged changes";
|
|
52
57
|
}
|
|
@@ -55,6 +60,14 @@ function rangeLabel(opts) {
|
|
|
55
60
|
}
|
|
56
61
|
return label;
|
|
57
62
|
}
|
|
63
|
+
/** Cap diff text at maxBytes; UTF-8 safe via Buffer slice. */
|
|
64
|
+
export function truncateDiffOutput(diff, maxBytes) {
|
|
65
|
+
const buf = Buffer.from(diff, "utf8");
|
|
66
|
+
if (buf.length <= maxBytes) {
|
|
67
|
+
return { text: diff, truncated: false };
|
|
68
|
+
}
|
|
69
|
+
return { text: buf.subarray(0, maxBytes).toString("utf8"), truncated: true };
|
|
70
|
+
}
|
|
58
71
|
// ---------------------------------------------------------------------------
|
|
59
72
|
// Tool registration
|
|
60
73
|
// ---------------------------------------------------------------------------
|
|
@@ -62,7 +75,8 @@ export function registerGitDiffTool(server) {
|
|
|
62
75
|
server.addTool({
|
|
63
76
|
name: "git_diff",
|
|
64
77
|
description: "Raw diff text for scoped file(s) or range. `staged: true` for staged changes, " +
|
|
65
|
-
"`base`/`head` for revision ranges, `path`/`paths` to scope, `unified` for context lines."
|
|
78
|
+
"`base`/`head` for revision ranges, `path`/`paths` to scope, `unified` for context lines. " +
|
|
79
|
+
"Output is capped by `maxBytes` (default 512000) to bound agent context.",
|
|
66
80
|
annotations: {
|
|
67
81
|
readOnlyHint: true,
|
|
68
82
|
},
|
|
@@ -70,11 +84,11 @@ export function registerGitDiffTool(server) {
|
|
|
70
84
|
base: z
|
|
71
85
|
.string()
|
|
72
86
|
.optional()
|
|
73
|
-
.describe('Base ref (e.g. "main"
|
|
87
|
+
.describe('Base ref (e.g. "main"). Ancestor notation is accepted (e.g. "HEAD~3", "main^2"). Omit for unstaged changes.'),
|
|
74
88
|
head: z
|
|
75
89
|
.string()
|
|
76
90
|
.optional()
|
|
77
|
-
.describe('Head ref (e.g. "feature-branch"). Defaults to HEAD. Used only when `base` is set.'),
|
|
91
|
+
.describe('Head ref (e.g. "feature-branch"). Ancestor notation is accepted (e.g. "HEAD~3", "main^2"). Defaults to HEAD. Used only when `base` is set.'),
|
|
78
92
|
path: z
|
|
79
93
|
.string()
|
|
80
94
|
.optional()
|
|
@@ -95,6 +109,14 @@ export function registerGitDiffTool(server) {
|
|
|
95
109
|
.max(100)
|
|
96
110
|
.optional()
|
|
97
111
|
.describe("Context lines around each change (`-U<n>`). Default: 3. Use 0 for no context."),
|
|
112
|
+
maxBytes: z
|
|
113
|
+
.number()
|
|
114
|
+
.int()
|
|
115
|
+
.min(1024)
|
|
116
|
+
.max(10_000_000)
|
|
117
|
+
.optional()
|
|
118
|
+
.default(GIT_DIFF_DEFAULT_MAX_BYTES)
|
|
119
|
+
.describe(`Max UTF-8 bytes of diff text to return (default ${GIT_DIFF_DEFAULT_MAX_BYTES}). Oversized output is truncated with truncated:true.`),
|
|
98
120
|
}),
|
|
99
121
|
execute: async (args) => {
|
|
100
122
|
const pre = requireSingleRepo(server, args);
|
|
@@ -141,6 +163,8 @@ export function registerGitDiffTool(server) {
|
|
|
141
163
|
detail: (result.stderr || result.stdout).trim(),
|
|
142
164
|
});
|
|
143
165
|
}
|
|
166
|
+
const maxBytes = typeof args.maxBytes === "number" ? args.maxBytes : GIT_DIFF_DEFAULT_MAX_BYTES;
|
|
167
|
+
const { text: diffText, truncated } = truncateDiffOutput(result.stdout, maxBytes);
|
|
144
168
|
const label = rangeLabel({
|
|
145
169
|
base: args.base,
|
|
146
170
|
head: args.head,
|
|
@@ -150,18 +174,22 @@ export function registerGitDiffTool(server) {
|
|
|
150
174
|
if (args.format === "json") {
|
|
151
175
|
return jsonRespond({
|
|
152
176
|
range: label,
|
|
153
|
-
diff:
|
|
177
|
+
diff: diffText,
|
|
178
|
+
...spreadWhen(truncated, { truncated: true }),
|
|
154
179
|
});
|
|
155
180
|
}
|
|
156
181
|
// Markdown output
|
|
157
182
|
const lines = [];
|
|
158
183
|
lines.push(`# Diff: ${label}`, "");
|
|
159
|
-
if (
|
|
160
|
-
lines.push("```diff",
|
|
184
|
+
if (diffText.trim()) {
|
|
185
|
+
lines.push("```diff", diffText.trimEnd(), "```");
|
|
161
186
|
}
|
|
162
187
|
else {
|
|
163
188
|
lines.push("_(no changes)_");
|
|
164
189
|
}
|
|
190
|
+
if (truncated) {
|
|
191
|
+
lines.push("", `_(diff truncated at ${maxBytes} bytes)_`);
|
|
192
|
+
}
|
|
165
193
|
return lines.join("\n");
|
|
166
194
|
},
|
|
167
195
|
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
4
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
5
|
+
import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitTopLevel, spawnGitAsync } from "./git.js";
|
|
6
|
+
import { isSafeGitAncestorRef } from "./git-refs.js";
|
|
7
|
+
import { jsonRespond, spreadWhen } from "./json.js";
|
|
8
|
+
import { requireGitAndRoots } from "./roots.js";
|
|
9
|
+
import { RootPickSchema } from "./schemas.js";
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Constants
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const MAX_MATCHES_HARD_CAP = 1000;
|
|
14
|
+
const DEFAULT_MAX_MATCHES = 200;
|
|
15
|
+
/** Run `git log -S` / `-G` pickaxe history search for a single repo root. */
|
|
16
|
+
async function runPickaxe(opts) {
|
|
17
|
+
const { top, mode, term, ref, paths, ignoreCase, maxMatches } = opts;
|
|
18
|
+
const args = [
|
|
19
|
+
"log",
|
|
20
|
+
"--pretty=format:%H%x01%s",
|
|
21
|
+
"-n",
|
|
22
|
+
String(maxMatches + 1),
|
|
23
|
+
mode === "S" ? "-S" : "-G",
|
|
24
|
+
term,
|
|
25
|
+
];
|
|
26
|
+
// `-i` / `--regexp-ignore-case` affects `-G` (and `--grep`); harmless on `-S`.
|
|
27
|
+
if (ignoreCase)
|
|
28
|
+
args.push("-i");
|
|
29
|
+
if (ref)
|
|
30
|
+
args.push(ref);
|
|
31
|
+
if (paths.length > 0)
|
|
32
|
+
args.push("--", ...paths);
|
|
33
|
+
const r = await spawnGitAsync(top, args);
|
|
34
|
+
if (!r.ok) {
|
|
35
|
+
return {
|
|
36
|
+
error: ERROR_CODES.GIT_GREP_FAILED,
|
|
37
|
+
detail: r.stderr.trim() || r.stdout.trim(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const commits = [];
|
|
41
|
+
for (const line of r.stdout.split("\n")) {
|
|
42
|
+
if (!line.trim())
|
|
43
|
+
continue;
|
|
44
|
+
const sep = line.indexOf("\x01");
|
|
45
|
+
if (sep < 0)
|
|
46
|
+
continue;
|
|
47
|
+
const sha = line.slice(0, sep).trim();
|
|
48
|
+
const subject = line.slice(sep + 1).trim();
|
|
49
|
+
if (sha)
|
|
50
|
+
commits.push({ sha, subject });
|
|
51
|
+
}
|
|
52
|
+
const truncated = commits.length > maxMatches;
|
|
53
|
+
return {
|
|
54
|
+
commits: truncated ? commits.slice(0, maxMatches) : commits,
|
|
55
|
+
truncated,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Markdown rendering
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
function renderGrepMarkdown(group) {
|
|
62
|
+
const lines = [`### ${group.repo}`, `_root: ${group.root}_`, ""];
|
|
63
|
+
if (group.error) {
|
|
64
|
+
lines.push(`_error: ${group.error}${group.detail ? ` — ${group.detail}` : ""}_`);
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
const commits = group.commits ?? [];
|
|
68
|
+
if (commits.length === 0) {
|
|
69
|
+
lines.push("_(no pickaxe hits)_");
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
for (const c of commits) {
|
|
73
|
+
lines.push(`- \`${c.sha.slice(0, 7)}\` ${c.subject}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (group.truncated) {
|
|
77
|
+
lines.push("", "_(truncated — raise `maxMatches` for the full result set)_");
|
|
78
|
+
}
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Tool registration
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
export function registerGitGrepTool(server) {
|
|
85
|
+
server.addTool({
|
|
86
|
+
name: "git_grep",
|
|
87
|
+
description: "Pickaxe history search across one or more roots: which commits added or removed a term " +
|
|
88
|
+
'(`git log -S`) or changed lines matching a regex (`git log -G`), via `pickaxe: { mode: "S"|"G", term }`. ' +
|
|
89
|
+
"Returns `commits[]` (sha + subject) per root. Set `ref` to limit history to that tip. " +
|
|
90
|
+
"For working-tree content search use the client's native grep/rg tooling instead — " +
|
|
91
|
+
"content mode was removed in v6.",
|
|
92
|
+
annotations: {
|
|
93
|
+
readOnlyHint: true,
|
|
94
|
+
},
|
|
95
|
+
parameters: RootPickSchema.extend({
|
|
96
|
+
pickaxe: z
|
|
97
|
+
.object({
|
|
98
|
+
mode: z
|
|
99
|
+
.enum(["S", "G"])
|
|
100
|
+
.describe("`S` = pickaxe string (`git log -S`); `G` = pickaxe regex (`git log -G`)."),
|
|
101
|
+
term: z.string().min(1).max(500).describe("Search term / regex for pickaxe history."),
|
|
102
|
+
})
|
|
103
|
+
.describe("Pickaxe history search. Returns `commits[]` (sha + subject) per root."),
|
|
104
|
+
ref: z
|
|
105
|
+
.string()
|
|
106
|
+
.optional()
|
|
107
|
+
.describe("Commit/branch/tag to use as the history tip. Must be a safe ref token."),
|
|
108
|
+
paths: z
|
|
109
|
+
.array(z.string())
|
|
110
|
+
.optional()
|
|
111
|
+
.describe("Limit history to these paths (must resolve within the repo root)."),
|
|
112
|
+
ignoreCase: z
|
|
113
|
+
.boolean()
|
|
114
|
+
.optional()
|
|
115
|
+
.default(false)
|
|
116
|
+
.describe("Case-insensitive match (`-i`; affects `G` mode regexes)."),
|
|
117
|
+
maxMatches: z
|
|
118
|
+
.number()
|
|
119
|
+
.int()
|
|
120
|
+
.min(1)
|
|
121
|
+
.max(MAX_MATCHES_HARD_CAP)
|
|
122
|
+
.optional()
|
|
123
|
+
.default(DEFAULT_MAX_MATCHES)
|
|
124
|
+
.describe(`Max commits per root (hard cap ${MAX_MATCHES_HARD_CAP}, default ${DEFAULT_MAX_MATCHES}).`),
|
|
125
|
+
}),
|
|
126
|
+
execute: async (args) => {
|
|
127
|
+
const pre = requireGitAndRoots(server, args, undefined);
|
|
128
|
+
if (!pre.ok)
|
|
129
|
+
return jsonRespond(pre.error);
|
|
130
|
+
const pickaxe = args.pickaxe;
|
|
131
|
+
if (args.ref !== undefined && !isSafeGitAncestorRef(args.ref)) {
|
|
132
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
|
|
133
|
+
}
|
|
134
|
+
const ref = args.ref?.trim() || undefined;
|
|
135
|
+
const rawPaths = Array.isArray(args.paths) ? args.paths : [];
|
|
136
|
+
const ignoreCase = args.ignoreCase ?? false;
|
|
137
|
+
const maxMatches = Math.min(args.maxMatches ?? DEFAULT_MAX_MATCHES, MAX_MATCHES_HARD_CAP);
|
|
138
|
+
// Fan out across roots. Path confinement is per-root since each root has
|
|
139
|
+
// its own git toplevel.
|
|
140
|
+
const jobs = pre.roots.map((rootInput) => ({ rootInput }));
|
|
141
|
+
const groups = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
|
|
142
|
+
const top = gitTopLevel(rootInput);
|
|
143
|
+
if (!top) {
|
|
144
|
+
return {
|
|
145
|
+
root: rootInput,
|
|
146
|
+
repo: basename(rootInput),
|
|
147
|
+
error: ERROR_CODES.NOT_A_GIT_REPOSITORY,
|
|
148
|
+
detail: rootInput,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
for (const p of rawPaths) {
|
|
152
|
+
const resolved = resolvePathForRepo(p, top);
|
|
153
|
+
if (!assertRelativePathUnderTop(p, resolved, top)) {
|
|
154
|
+
return {
|
|
155
|
+
root: top,
|
|
156
|
+
repo: basename(top),
|
|
157
|
+
error: ERROR_CODES.PATH_ESCAPES_REPO,
|
|
158
|
+
detail: p,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const result = await runPickaxe({
|
|
163
|
+
top,
|
|
164
|
+
mode: pickaxe.mode,
|
|
165
|
+
term: pickaxe.term,
|
|
166
|
+
ref,
|
|
167
|
+
paths: rawPaths,
|
|
168
|
+
ignoreCase,
|
|
169
|
+
maxMatches,
|
|
170
|
+
});
|
|
171
|
+
if ("error" in result) {
|
|
172
|
+
return { root: top, repo: basename(top), error: result.error, detail: result.detail };
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
root: top,
|
|
176
|
+
repo: basename(top),
|
|
177
|
+
commits: result.commits,
|
|
178
|
+
...spreadWhen(result.truncated, { truncated: true }),
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
if (args.format === "json") {
|
|
182
|
+
return jsonRespond({ results: groups });
|
|
183
|
+
}
|
|
184
|
+
const mdChunks = ["# git grep", ...groups.map((g) => renderGrepMarkdown(g))];
|
|
185
|
+
return mdChunks.join("\n\n");
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ERROR_CODES } from "./error-codes.js";
|
|
3
3
|
import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitRevParseGitDir, gitTopLevel, isSafeGitUpstreamToken, } from "./git.js";
|
|
4
|
+
import { isSafeGitAncestorRef } from "./git-refs.js";
|
|
4
5
|
import { buildInventorySectionMarkdown, collectInventoryEntry, makeSkipEntry, validateRepoPath, } from "./inventory.js";
|
|
5
6
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
6
7
|
import { applyPresetNestedRoots } from "./presets.js";
|
|
@@ -9,7 +10,7 @@ import { MAX_INVENTORY_ROOTS_DEFAULT, RootPickSchema } from "./schemas.js";
|
|
|
9
10
|
export function registerGitInventoryTool(server) {
|
|
10
11
|
server.addTool({
|
|
11
12
|
name: "git_inventory",
|
|
12
|
-
description: "Read-only status + ahead/behind per root.",
|
|
13
|
+
description: "Read-only status + ahead/behind per root. Optional `compareRefs` adds ahead/behind between two local refs (independent of upstream).",
|
|
13
14
|
annotations: {
|
|
14
15
|
readOnlyHint: true,
|
|
15
16
|
},
|
|
@@ -23,6 +24,13 @@ export function registerGitInventoryTool(server) {
|
|
|
23
24
|
.describe("Merge with preset instead of replacing."),
|
|
24
25
|
remote: z.string().optional().describe("Pair with `branch`."),
|
|
25
26
|
branch: z.string().optional().describe("Pair with `remote`."),
|
|
27
|
+
compareRefs: z
|
|
28
|
+
.object({
|
|
29
|
+
left: z.string().describe("Base ref (ahead = commits in right not in left)."),
|
|
30
|
+
right: z.string().describe("Other ref (behind = commits in left not in right)."),
|
|
31
|
+
})
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("Ahead/behind between two local refs (e.g. main vs a feature branch), independent of upstream tracking."),
|
|
26
34
|
maxRoots: z.number().int().min(1).max(256).optional().default(MAX_INVENTORY_ROOTS_DEFAULT),
|
|
27
35
|
}),
|
|
28
36
|
execute: async (args) => {
|
|
@@ -49,24 +57,36 @@ export function registerGitInventoryTool(server) {
|
|
|
49
57
|
}
|
|
50
58
|
upstream = { mode: "fixed", remote: rawRemote, branch: rawBranch };
|
|
51
59
|
}
|
|
60
|
+
let compareRefs;
|
|
61
|
+
if (args.compareRefs) {
|
|
62
|
+
const left = args.compareRefs.left.trim();
|
|
63
|
+
const right = args.compareRefs.right.trim();
|
|
64
|
+
if (!isSafeGitAncestorRef(left) || !isSafeGitAncestorRef(right)) {
|
|
65
|
+
return jsonRespond({
|
|
66
|
+
error: ERROR_CODES.UNSAFE_REF_TOKEN,
|
|
67
|
+
left: args.compareRefs.left,
|
|
68
|
+
right: args.compareRefs.right,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
compareRefs = { left, right };
|
|
72
|
+
}
|
|
52
73
|
const useFixed = upstream.mode === "fixed";
|
|
53
74
|
const allJson = [];
|
|
54
75
|
const mdChunks = [];
|
|
55
76
|
for (const workspaceRoot of pre.roots) {
|
|
56
77
|
const top = gitTopLevel(workspaceRoot);
|
|
57
78
|
if (!top) {
|
|
58
|
-
const err = { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: workspaceRoot };
|
|
59
79
|
if (args.format === "json") {
|
|
60
80
|
allJson.push({
|
|
61
81
|
workspaceRoot: workspaceRoot,
|
|
62
82
|
...(upstream.mode === "fixed" ? { upstream } : {}),
|
|
63
83
|
entries: [
|
|
64
|
-
makeSkipEntry(workspaceRoot, workspaceRoot, upstream.mode,
|
|
84
|
+
makeSkipEntry(workspaceRoot, workspaceRoot, upstream.mode, "(not a git repository)"),
|
|
65
85
|
],
|
|
66
86
|
});
|
|
67
87
|
}
|
|
68
88
|
else {
|
|
69
|
-
mdChunks.push(`### ${workspaceRoot}\n
|
|
89
|
+
mdChunks.push(`### ${workspaceRoot}\n(not a git repository)`);
|
|
70
90
|
}
|
|
71
91
|
continue;
|
|
72
92
|
}
|
|
@@ -106,14 +126,14 @@ export function registerGitInventoryTool(server) {
|
|
|
106
126
|
}
|
|
107
127
|
jobs.push({ label: rel, abs });
|
|
108
128
|
}
|
|
109
|
-
const computed = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, (j) => collectInventoryEntry(j.label, j.abs, upstream.remote, upstream.branch));
|
|
129
|
+
const computed = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, (j) => collectInventoryEntry(j.label, j.abs, upstream.remote, upstream.branch, compareRefs));
|
|
110
130
|
entries.push(...computed);
|
|
111
131
|
}
|
|
112
132
|
else if (!gitRevParseGitDir(top)) {
|
|
113
133
|
entries.push(makeSkipEntry(".", top, upstream.mode, "(not a git work tree — unexpected)"));
|
|
114
134
|
}
|
|
115
135
|
else {
|
|
116
|
-
const one = await collectInventoryEntry(".", top, upstream.remote, upstream.branch);
|
|
136
|
+
const one = await collectInventoryEntry(".", top, upstream.remote, upstream.branch, compareRefs);
|
|
117
137
|
entries.push(one);
|
|
118
138
|
}
|
|
119
139
|
if (args.format === "json") {
|