@rethunk/mcp-multi-root-git 2.9.1 → 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 +23 -19
- package/CHANGELOG.md +145 -39
- package/HUMANS.md +23 -42
- package/README.md +30 -13
- package/dist/repo-paths.js +57 -13
- package/dist/server/batch-commit-tool.js +203 -53
- package/dist/server/error-codes.js +31 -16
- package/dist/server/git-blame-tool.js +66 -19
- package/dist/server/git-branch-tool.js +151 -0
- package/dist/server/git-cherry-pick-tool.js +221 -12
- package/dist/server/git-conflicts-tool.js +230 -0
- package/dist/server/git-diff-summary-tool.js +39 -28
- package/dist/server/git-diff-tool.js +56 -31
- package/dist/server/git-grep-tool.js +188 -0
- package/dist/server/git-inventory-tool.js +30 -10
- package/dist/server/git-log-tool.js +37 -6
- package/dist/server/git-merge-tool.js +71 -13
- package/dist/server/git-parity-tool.js +15 -6
- package/dist/server/git-push-tool.js +4 -2
- package/dist/server/git-refs.js +48 -17
- package/dist/server/git-reset-soft-tool.js +1 -1
- package/dist/server/git-revert-tool.js +160 -0
- package/dist/server/git-show-tool.js +5 -10
- package/dist/server/git-stash-tool.js +112 -78
- package/dist/server/git-status-tool.js +2 -2
- package/dist/server/git-tag-tool.js +8 -13
- package/dist/server/git-worktree-tool.js +67 -59
- package/dist/server/git.js +116 -24
- package/dist/server/inventory.js +90 -32
- package/dist/server/list-presets-tool.js +2 -8
- package/dist/server/presets-resource.js +37 -20
- package/dist/server/presets.js +87 -15
- package/dist/server/roots.js +52 -79
- package/dist/server/schemas.js +18 -19
- package/dist/server/test-harness.js +11 -4
- package/dist/server/tool-parameter-schemas.js +47 -58
- package/dist/server/tools.js +36 -17
- package/dist/server.js +1 -1
- package/docs/install.md +52 -5
- package/docs/mcp-tools.md +472 -284
- package/package.json +6 -6
- package/schemas/batch_commit.json +5 -17
- package/schemas/git_blame.json +13 -11
- package/schemas/git_branch.json +54 -0
- package/schemas/git_cherry_pick.json +13 -15
- package/schemas/git_cherry_pick_continue.json +34 -0
- package/schemas/git_conflicts.json +38 -0
- package/schemas/git_diff.json +12 -10
- package/schemas/git_diff_summary.json +1 -15
- package/schemas/git_grep.json +85 -0
- package/schemas/git_inventory.json +32 -23
- package/schemas/git_log.json +20 -24
- package/schemas/git_merge.json +3 -15
- package/schemas/git_parity.json +13 -23
- package/schemas/git_push.json +1 -13
- package/schemas/git_reset_soft.json +1 -13
- package/schemas/git_revert.json +47 -0
- package/schemas/git_show.json +2 -8
- package/schemas/git_stash_apply.json +2 -8
- package/schemas/git_stash_push.json +47 -0
- package/schemas/git_status.json +13 -23
- package/schemas/git_tag.json +1 -7
- package/schemas/git_worktree_add.json +2 -14
- package/schemas/git_worktree_remove.json +2 -14
- package/schemas/index.json +28 -23
- package/schemas/list_presets.json +13 -23
- package/tool-parameters.schema.json +407 -423
- package/dist/server/git-branch-list-tool.js +0 -138
- package/dist/server/git-fetch-tool.js +0 -266
- package/dist/server/git-reflog-tool.js +0 -126
- package/schemas/git_branch_list.json +0 -36
- package/schemas/git_fetch.json +0 -52
- package/schemas/git_reflog.json +0 -44
- package/schemas/git_stash_list.json +0 -30
- package/schemas/git_worktree_list.json +0 -30
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
5
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
6
|
+
import { spawnGitAsync } from "./git.js";
|
|
7
|
+
import { conflictPaths } from "./git-refs.js";
|
|
8
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
9
|
+
import { requireSingleRepo } from "./roots.js";
|
|
10
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Operation-state detection
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
/** Resolve the repo's git directory (handles worktrees, where it is not literally `<top>/.git`). */
|
|
15
|
+
async function resolveGitDir(gitTop) {
|
|
16
|
+
const r = await spawnGitAsync(gitTop, ["rev-parse", "--git-dir"]);
|
|
17
|
+
if (!r.ok)
|
|
18
|
+
return null;
|
|
19
|
+
const raw = r.stdout.trim();
|
|
20
|
+
if (!raw)
|
|
21
|
+
return null;
|
|
22
|
+
return isAbsolute(raw) ? raw : join(gitTop, raw);
|
|
23
|
+
}
|
|
24
|
+
/** Detect the in-progress operation, if any, via marker files/dirs under the git dir. */
|
|
25
|
+
export async function detectConflictState(gitTop) {
|
|
26
|
+
const gitDir = await resolveGitDir(gitTop);
|
|
27
|
+
if (!gitDir)
|
|
28
|
+
return undefined;
|
|
29
|
+
if (existsSync(join(gitDir, "MERGE_HEAD")))
|
|
30
|
+
return "merge";
|
|
31
|
+
if (existsSync(join(gitDir, "CHERRY_PICK_HEAD")))
|
|
32
|
+
return "cherry-pick";
|
|
33
|
+
if (existsSync(join(gitDir, "REVERT_HEAD")))
|
|
34
|
+
return "revert";
|
|
35
|
+
if (existsSync(join(gitDir, "rebase-merge")) || existsSync(join(gitDir, "rebase-apply"))) {
|
|
36
|
+
return "rebase";
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Conflict marker parsing
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
const OURS_MARKER = "<<<<<<<";
|
|
44
|
+
const BASE_MARKER = "|||||||";
|
|
45
|
+
const SPLIT_MARKER = "=======";
|
|
46
|
+
const THEIRS_MARKER = ">>>>>>>";
|
|
47
|
+
function labelAfterMarker(line, marker) {
|
|
48
|
+
const rest = line.slice(marker.length).trim();
|
|
49
|
+
return rest.length > 0 ? rest : undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parse `<<<<<<<`/`|||||||`/`=======`/`>>>>>>>` conflict markers out of file text.
|
|
53
|
+
* Only the first `maxLinesPerFile` lines are scanned; when the file is longer,
|
|
54
|
+
* `truncated: true` is reported and any hunk still open at the cutoff is dropped
|
|
55
|
+
* rather than emitted half-formed. An incomplete hunk at EOF (corrupt/missing
|
|
56
|
+
* closing marker within the scan window) also sets `truncated: true`.
|
|
57
|
+
*/
|
|
58
|
+
export function parseConflictHunks(text, maxLinesPerFile) {
|
|
59
|
+
const allLines = text.split("\n");
|
|
60
|
+
const truncatedByCap = allLines.length > maxLinesPerFile;
|
|
61
|
+
const lines = truncatedByCap ? allLines.slice(0, maxLinesPerFile) : allLines;
|
|
62
|
+
const hunks = [];
|
|
63
|
+
let state = "outside";
|
|
64
|
+
let cur = null;
|
|
65
|
+
for (let i = 0; i < lines.length; i++) {
|
|
66
|
+
const line = lines[i] ?? "";
|
|
67
|
+
const lineNo = i + 1;
|
|
68
|
+
if (line.startsWith(OURS_MARKER)) {
|
|
69
|
+
cur = {
|
|
70
|
+
startLine: lineNo,
|
|
71
|
+
oursLines: [],
|
|
72
|
+
baseLines: [],
|
|
73
|
+
theirsLines: [],
|
|
74
|
+
oursLabel: labelAfterMarker(line, OURS_MARKER),
|
|
75
|
+
};
|
|
76
|
+
state = "ours";
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (state === "ours" && line.startsWith(BASE_MARKER)) {
|
|
80
|
+
state = "base";
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if ((state === "ours" || state === "base") && line.startsWith(SPLIT_MARKER)) {
|
|
84
|
+
state = "theirs";
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (state === "theirs" && line.startsWith(THEIRS_MARKER) && cur) {
|
|
88
|
+
cur.theirsLabel = labelAfterMarker(line, THEIRS_MARKER);
|
|
89
|
+
hunks.push({
|
|
90
|
+
startLine: cur.startLine,
|
|
91
|
+
ours: cur.oursLines.join("\n"),
|
|
92
|
+
theirs: cur.theirsLines.join("\n"),
|
|
93
|
+
...spreadWhen(cur.baseLines.length > 0, { base: cur.baseLines.join("\n") }),
|
|
94
|
+
...spreadDefined("oursLabel", cur.oursLabel),
|
|
95
|
+
...spreadDefined("theirsLabel", cur.theirsLabel),
|
|
96
|
+
});
|
|
97
|
+
cur = null;
|
|
98
|
+
state = "outside";
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!cur)
|
|
102
|
+
continue;
|
|
103
|
+
if (state === "ours")
|
|
104
|
+
cur.oursLines.push(line);
|
|
105
|
+
else if (state === "base")
|
|
106
|
+
cur.baseLines.push(line);
|
|
107
|
+
else if (state === "theirs")
|
|
108
|
+
cur.theirsLines.push(line);
|
|
109
|
+
}
|
|
110
|
+
// Incomplete open hunk at EOF (or at the line-cap) → flag truncated so callers
|
|
111
|
+
// know markers were present but not fully parsed.
|
|
112
|
+
const truncated = truncatedByCap || cur !== null;
|
|
113
|
+
return { hunks, truncated };
|
|
114
|
+
}
|
|
115
|
+
/** Conservative binary sniff: a NUL byte in the first 8000 bytes. */
|
|
116
|
+
function isLikelyBinary(buf) {
|
|
117
|
+
const len = Math.min(buf.length, 8000);
|
|
118
|
+
for (let i = 0; i < len; i++) {
|
|
119
|
+
if (buf[i] === 0)
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Per-file resolution
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
export function readConflictFile(gitTop, relPath, maxLinesPerFile) {
|
|
128
|
+
const resolved = resolvePathForRepo(relPath, gitTop);
|
|
129
|
+
if (!assertRelativePathUnderTop(relPath, resolved, gitTop)) {
|
|
130
|
+
return { path: relPath, error: ERROR_CODES.PATH_ESCAPES_REPO };
|
|
131
|
+
}
|
|
132
|
+
let buf;
|
|
133
|
+
try {
|
|
134
|
+
buf = readFileSync(resolved);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return { path: relPath };
|
|
138
|
+
}
|
|
139
|
+
if (isLikelyBinary(buf)) {
|
|
140
|
+
return { path: relPath };
|
|
141
|
+
}
|
|
142
|
+
const { hunks, truncated } = parseConflictHunks(buf.toString("utf8"), maxLinesPerFile);
|
|
143
|
+
return {
|
|
144
|
+
path: relPath,
|
|
145
|
+
...spreadWhen(hunks.length > 0, { hunks }),
|
|
146
|
+
...spreadWhen(truncated, { truncated: true }),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Markdown rendering
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
function renderConflictsMarkdown(result) {
|
|
153
|
+
const lines = ["# Git conflicts"];
|
|
154
|
+
if (result.state)
|
|
155
|
+
lines.push(`_state: ${result.state}_`);
|
|
156
|
+
lines.push("");
|
|
157
|
+
if (result.files.length === 0) {
|
|
158
|
+
lines.push("_(no conflicts)_");
|
|
159
|
+
return lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
for (const f of result.files) {
|
|
162
|
+
lines.push(`## ${f.path}`);
|
|
163
|
+
if (f.error)
|
|
164
|
+
lines.push(`_error: ${f.error}_`);
|
|
165
|
+
if (f.truncated)
|
|
166
|
+
lines.push("_(truncated)_");
|
|
167
|
+
if (!f.hunks || f.hunks.length === 0) {
|
|
168
|
+
lines.push("_(no parsed hunks — unreadable, binary, or no markers found)_", "");
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
for (const h of f.hunks) {
|
|
172
|
+
lines.push(`### hunk @ line ${h.startLine}`);
|
|
173
|
+
lines.push(`**ours${h.oursLabel ? ` (${h.oursLabel})` : ""}:**`, "```", h.ours, "```");
|
|
174
|
+
if (h.base !== undefined) {
|
|
175
|
+
lines.push("**base:**", "```", h.base, "```");
|
|
176
|
+
}
|
|
177
|
+
lines.push(`**theirs${h.theirsLabel ? ` (${h.theirsLabel})` : ""}:**`, "```", h.theirs, "```");
|
|
178
|
+
}
|
|
179
|
+
lines.push("");
|
|
180
|
+
}
|
|
181
|
+
return lines.join("\n").trimEnd();
|
|
182
|
+
}
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Tool registration
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
export function registerGitConflictsTool(server) {
|
|
187
|
+
server.addTool({
|
|
188
|
+
name: "git_conflicts",
|
|
189
|
+
description: "Inspect unresolved merge conflicts after git_merge/git_cherry_pick reports them. " +
|
|
190
|
+
"Reports the in-progress operation (merge/cherry-pick/revert/rebase, when detectable) and, " +
|
|
191
|
+
"per conflicted file, the parsed ours/theirs (and base, for diff3-style markers) hunks.",
|
|
192
|
+
annotations: {
|
|
193
|
+
readOnlyHint: true,
|
|
194
|
+
},
|
|
195
|
+
parameters: WorkspacePickSchema.extend({
|
|
196
|
+
withHunks: z
|
|
197
|
+
.boolean()
|
|
198
|
+
.optional()
|
|
199
|
+
.default(true)
|
|
200
|
+
.describe("Parse conflict-marker hunks per file. Set false for just the path list."),
|
|
201
|
+
maxLinesPerFile: z
|
|
202
|
+
.number()
|
|
203
|
+
.int()
|
|
204
|
+
.min(1)
|
|
205
|
+
.max(2000)
|
|
206
|
+
.optional()
|
|
207
|
+
.default(200)
|
|
208
|
+
.describe("Cap on lines scanned per file before marking `truncated: true`."),
|
|
209
|
+
}),
|
|
210
|
+
execute: async (args) => {
|
|
211
|
+
const pre = requireSingleRepo(server, args);
|
|
212
|
+
if (!pre.ok)
|
|
213
|
+
return jsonRespond(pre.error);
|
|
214
|
+
const gitTop = pre.gitTop;
|
|
215
|
+
const state = await detectConflictState(gitTop);
|
|
216
|
+
const paths = await conflictPaths(gitTop);
|
|
217
|
+
const withHunks = args.withHunks !== false;
|
|
218
|
+
const maxLinesPerFile = typeof args.maxLinesPerFile === "number" ? args.maxLinesPerFile : 200;
|
|
219
|
+
const files = paths.map((p) => withHunks ? readConflictFile(gitTop, p, maxLinesPerFile) : { path: p });
|
|
220
|
+
const result = {
|
|
221
|
+
...spreadDefined("state", state),
|
|
222
|
+
files,
|
|
223
|
+
};
|
|
224
|
+
if (args.format === "json") {
|
|
225
|
+
return jsonRespond(result);
|
|
226
|
+
}
|
|
227
|
+
return renderConflictsMarkdown(result);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
@@ -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,18 +28,26 @@ 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
|
-
function parseNumstatOutput(numstat) {
|
|
35
|
+
export function parseNumstatOutput(numstat) {
|
|
33
36
|
const result = new Map();
|
|
34
37
|
for (const line of numstat.split("\n")) {
|
|
35
38
|
const parts = line.split("\t");
|
|
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 @@ 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) {
|
|
@@ -184,7 +186,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
184
186
|
annotations: {
|
|
185
187
|
readOnlyHint: true,
|
|
186
188
|
},
|
|
187
|
-
parameters: WorkspacePickSchema.
|
|
189
|
+
parameters: WorkspacePickSchema.extend({
|
|
188
190
|
range: z
|
|
189
191
|
.string()
|
|
190
192
|
.optional()
|
|
@@ -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,
|
|
@@ -282,7 +293,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
282
293
|
additions: stat.additions,
|
|
283
294
|
deletions: stat.deletions,
|
|
284
295
|
...spreadDefined("oldPath", oldPath),
|
|
285
|
-
truncated,
|
|
296
|
+
...spreadWhen(truncated, { truncated: true }),
|
|
286
297
|
diff: diffText,
|
|
287
298
|
});
|
|
288
299
|
}
|
|
@@ -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,22 +75,20 @@ 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
|
},
|
|
69
|
-
parameters: WorkspacePickSchema.
|
|
70
|
-
absoluteGitRoots: true,
|
|
71
|
-
allWorkspaceRoots: true,
|
|
72
|
-
}).extend({
|
|
83
|
+
parameters: WorkspacePickSchema.extend({
|
|
73
84
|
base: z
|
|
74
85
|
.string()
|
|
75
86
|
.optional()
|
|
76
|
-
.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.'),
|
|
77
88
|
head: z
|
|
78
89
|
.string()
|
|
79
90
|
.optional()
|
|
80
|
-
.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.'),
|
|
81
92
|
path: z
|
|
82
93
|
.string()
|
|
83
94
|
.optional()
|
|
@@ -98,6 +109,14 @@ export function registerGitDiffTool(server) {
|
|
|
98
109
|
.max(100)
|
|
99
110
|
.optional()
|
|
100
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.`),
|
|
101
120
|
}),
|
|
102
121
|
execute: async (args) => {
|
|
103
122
|
const pre = requireSingleRepo(server, args);
|
|
@@ -144,6 +163,8 @@ export function registerGitDiffTool(server) {
|
|
|
144
163
|
detail: (result.stderr || result.stdout).trim(),
|
|
145
164
|
});
|
|
146
165
|
}
|
|
166
|
+
const maxBytes = typeof args.maxBytes === "number" ? args.maxBytes : GIT_DIFF_DEFAULT_MAX_BYTES;
|
|
167
|
+
const { text: diffText, truncated } = truncateDiffOutput(result.stdout, maxBytes);
|
|
147
168
|
const label = rangeLabel({
|
|
148
169
|
base: args.base,
|
|
149
170
|
head: args.head,
|
|
@@ -153,18 +174,22 @@ export function registerGitDiffTool(server) {
|
|
|
153
174
|
if (args.format === "json") {
|
|
154
175
|
return jsonRespond({
|
|
155
176
|
range: label,
|
|
156
|
-
diff:
|
|
177
|
+
diff: diffText,
|
|
178
|
+
...spreadWhen(truncated, { truncated: true }),
|
|
157
179
|
});
|
|
158
180
|
}
|
|
159
181
|
// Markdown output
|
|
160
182
|
const lines = [];
|
|
161
183
|
lines.push(`# Diff: ${label}`, "");
|
|
162
|
-
if (
|
|
163
|
-
lines.push("```diff",
|
|
184
|
+
if (diffText.trim()) {
|
|
185
|
+
lines.push("```diff", diffText.trimEnd(), "```");
|
|
164
186
|
}
|
|
165
187
|
else {
|
|
166
188
|
lines.push("_(no changes)_");
|
|
167
189
|
}
|
|
190
|
+
if (truncated) {
|
|
191
|
+
lines.push("", `_(diff truncated at ${maxBytes} bytes)_`);
|
|
192
|
+
}
|
|
168
193
|
return lines.join("\n");
|
|
169
194
|
},
|
|
170
195
|
});
|