@rethunk/mcp-multi-root-git 2.5.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -4
- package/CHANGELOG.md +45 -1
- package/README.md +1 -1
- package/dist/server/batch-commit-tool.js +32 -10
- package/dist/server/error-codes.js +95 -0
- package/dist/server/git-blame-tool.js +227 -0
- package/dist/server/git-branch-list-tool.js +140 -0
- package/dist/server/git-cherry-pick-tool.js +11 -10
- package/dist/server/git-diff-summary-tool.js +10 -3
- package/dist/server/git-diff-tool.js +60 -13
- package/dist/server/git-fetch-tool.js +148 -13
- package/dist/server/git-inventory-tool.js +7 -6
- package/dist/server/git-log-tool.js +14 -4
- package/dist/server/git-merge-tool.js +15 -14
- package/dist/server/git-parity-tool.js +5 -4
- package/dist/server/git-push-tool.js +9 -5
- package/dist/server/git-reflog-tool.js +126 -0
- package/dist/server/git-refs.js +12 -5
- package/dist/server/git-reset-soft-tool.js +4 -3
- package/dist/server/git-show-tool.js +96 -22
- package/dist/server/git-stash-tool.js +17 -15
- package/dist/server/git-tag-tool.js +8 -7
- package/dist/server/git-worktree-tool.js +8 -7
- package/dist/server/git.js +86 -7
- package/dist/server/list-presets-tool.js +2 -1
- package/dist/server/presets-resource.js +3 -2
- package/dist/server/presets.js +11 -5
- package/dist/server/roots.js +11 -10
- package/dist/server/tool-parameter-schemas.js +9 -0
- package/dist/server/tools.js +6 -0
- package/dist/server.js +7 -0
- package/docs/mcp-tools.md +134 -12
- package/package.json +4 -3
- package/schemas/git_blame.json +52 -0
- package/schemas/git_branch_list.json +36 -0
- package/schemas/git_diff.json +14 -15
- package/schemas/git_diff_summary.json +0 -6
- package/schemas/git_reflog.json +44 -0
- package/schemas/git_show.json +13 -1
- package/schemas/index.json +15 -0
- package/tool-parameters.schema.json +153 -22
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
3
|
+
import { spawnGitAsync } from "./git.js";
|
|
4
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
|
+
import { requireSingleRepo } from "./roots.js";
|
|
6
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
async function runBranchList(opts) {
|
|
11
|
+
const { top, includeRemotes } = opts;
|
|
12
|
+
// Local branches: name, full SHA, upstream (may be empty), HEAD marker (* or space)
|
|
13
|
+
const localR = await spawnGitAsync(top, [
|
|
14
|
+
"for-each-ref",
|
|
15
|
+
"--format=%(refname:short)%00%(objectname)%00%(upstream:short)%00%(HEAD)",
|
|
16
|
+
"refs/heads",
|
|
17
|
+
]);
|
|
18
|
+
if (!localR.ok) {
|
|
19
|
+
return {
|
|
20
|
+
error: ERROR_CODES.BRANCH_LIST_FAILED,
|
|
21
|
+
detail: (localR.stderr || localR.stdout).trim(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const branches = [];
|
|
25
|
+
const localLines = (localR.stdout || "").split("\n").filter((l) => l.length > 0);
|
|
26
|
+
for (const line of localLines) {
|
|
27
|
+
const parts = line.split("\x00");
|
|
28
|
+
const name = parts[0] ?? "";
|
|
29
|
+
const sha = parts[1] ?? "";
|
|
30
|
+
const upstream = parts[2] ?? "";
|
|
31
|
+
const headMarker = parts[3] ?? "";
|
|
32
|
+
if (!name || !sha)
|
|
33
|
+
continue;
|
|
34
|
+
branches.push({
|
|
35
|
+
name,
|
|
36
|
+
sha,
|
|
37
|
+
current: headMarker === "*",
|
|
38
|
+
...spreadDefined("upstream", upstream || undefined),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (!includeRemotes) {
|
|
42
|
+
return { branches };
|
|
43
|
+
}
|
|
44
|
+
// Remote branches: name, full SHA — skip symbolic origin/HEAD refs
|
|
45
|
+
const remoteR = await spawnGitAsync(top, [
|
|
46
|
+
"for-each-ref",
|
|
47
|
+
"--format=%(refname:short)%00%(objectname)",
|
|
48
|
+
"refs/remotes",
|
|
49
|
+
]);
|
|
50
|
+
if (!remoteR.ok) {
|
|
51
|
+
return {
|
|
52
|
+
error: ERROR_CODES.BRANCH_LIST_FAILED,
|
|
53
|
+
detail: (remoteR.stderr || remoteR.stdout).trim(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const remotes = [];
|
|
57
|
+
const remoteLines = (remoteR.stdout || "").split("\n").filter((l) => l.length > 0);
|
|
58
|
+
for (const line of remoteLines) {
|
|
59
|
+
const parts = line.split("\x00");
|
|
60
|
+
const name = parts[0] ?? "";
|
|
61
|
+
const sha = parts[1] ?? "";
|
|
62
|
+
// Skip symbolic origin/HEAD
|
|
63
|
+
if (!name || !sha || name.endsWith("/HEAD"))
|
|
64
|
+
continue;
|
|
65
|
+
remotes.push({ name, sha });
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
branches,
|
|
69
|
+
...spreadWhen(remotes.length > 0, { remotes }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Markdown rendering
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
function renderBranchListMarkdown(result) {
|
|
76
|
+
const lines = ["## Branches", ""];
|
|
77
|
+
if (result.branches.length === 0) {
|
|
78
|
+
lines.push("_(none)_");
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
for (const b of result.branches) {
|
|
82
|
+
const prefix = b.current ? "* " : " ";
|
|
83
|
+
const upstream = b.upstream ? ` → ${b.upstream}` : "";
|
|
84
|
+
lines.push(`${prefix}**${b.name}**${upstream} (\`${b.sha}\`)`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (result.remotes && result.remotes.length > 0) {
|
|
88
|
+
lines.push("");
|
|
89
|
+
lines.push("## Remote branches");
|
|
90
|
+
lines.push("");
|
|
91
|
+
for (const r of result.remotes) {
|
|
92
|
+
lines.push(`- **${r.name}** (\`${r.sha}\`)`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Tool registration
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
export function registerGitBranchListTool(server) {
|
|
101
|
+
server.addTool({
|
|
102
|
+
name: "git_branch_list",
|
|
103
|
+
description: "List local branches (and optionally remote-tracking branches) for a single git repository. " +
|
|
104
|
+
"Returns `{ branches: [{ name, sha, current, upstream? }], remotes?: [{ name, sha }] }`. " +
|
|
105
|
+
"The current branch is marked with `current: true`. Remote branches are included when `includeRemotes: true`.",
|
|
106
|
+
annotations: {
|
|
107
|
+
readOnlyHint: true,
|
|
108
|
+
},
|
|
109
|
+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
|
|
110
|
+
.pick({
|
|
111
|
+
workspaceRoot: true,
|
|
112
|
+
rootIndex: true,
|
|
113
|
+
format: true,
|
|
114
|
+
})
|
|
115
|
+
.extend({
|
|
116
|
+
includeRemotes: z
|
|
117
|
+
.boolean()
|
|
118
|
+
.optional()
|
|
119
|
+
.default(false)
|
|
120
|
+
.describe("When true, also return remote-tracking branches from refs/remotes (excluding symbolic origin/HEAD)."),
|
|
121
|
+
}),
|
|
122
|
+
execute: async (args) => {
|
|
123
|
+
const pre = requireSingleRepo(server, args);
|
|
124
|
+
if (!pre.ok)
|
|
125
|
+
return jsonRespond(pre.error);
|
|
126
|
+
const { gitTop } = pre;
|
|
127
|
+
const result = await runBranchList({
|
|
128
|
+
top: gitTop,
|
|
129
|
+
includeRemotes: args.includeRemotes ?? false,
|
|
130
|
+
});
|
|
131
|
+
if ("error" in result) {
|
|
132
|
+
return jsonRespond(result);
|
|
133
|
+
}
|
|
134
|
+
if (args.format === "json") {
|
|
135
|
+
return jsonRespond(result);
|
|
136
|
+
}
|
|
137
|
+
return renderBranchListMarkdown(result);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
3
|
import { spawnGitAsync } from "./git.js";
|
|
3
4
|
import { commitListBetween, conflictPaths, getCurrentBranch, isContentEquivalentlyMergedInto, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
4
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
@@ -30,12 +31,12 @@ async function branchExists(gitTop, name) {
|
|
|
30
31
|
async function resolveSource(gitTop, onto, raw) {
|
|
31
32
|
if (raw.includes("..")) {
|
|
32
33
|
if (!isSafeGitRangeToken(raw)) {
|
|
33
|
-
return { error:
|
|
34
|
+
return { error: ERROR_CODES.UNSAFE_REF_TOKEN, raw };
|
|
34
35
|
}
|
|
35
36
|
const r = await spawnGitAsync(gitTop, ["rev-list", "--reverse", raw]);
|
|
36
37
|
if (!r.ok) {
|
|
37
38
|
return {
|
|
38
|
-
error:
|
|
39
|
+
error: ERROR_CODES.RANGE_RESOLUTION_FAILED,
|
|
39
40
|
detail: (r.stderr || r.stdout).trim(),
|
|
40
41
|
raw,
|
|
41
42
|
};
|
|
@@ -47,18 +48,18 @@ async function resolveSource(gitTop, onto, raw) {
|
|
|
47
48
|
return { raw, kind: "range", commits };
|
|
48
49
|
}
|
|
49
50
|
if (!isSafeGitRefToken(raw)) {
|
|
50
|
-
return { error:
|
|
51
|
+
return { error: ERROR_CODES.UNSAFE_REF_TOKEN, raw };
|
|
51
52
|
}
|
|
52
53
|
if (await branchExists(gitTop, raw)) {
|
|
53
54
|
const commits = await commitListBetween(gitTop, onto, raw);
|
|
54
55
|
if (commits === null) {
|
|
55
|
-
return { error:
|
|
56
|
+
return { error: ERROR_CODES.RANGE_RESOLUTION_FAILED, raw };
|
|
56
57
|
}
|
|
57
58
|
return { raw, kind: "branch", commits };
|
|
58
59
|
}
|
|
59
60
|
const sha = await resolveRef(gitTop, raw);
|
|
60
61
|
if (!sha) {
|
|
61
|
-
return { error:
|
|
62
|
+
return { error: ERROR_CODES.SOURCE_NOT_FOUND, raw };
|
|
62
63
|
}
|
|
63
64
|
return { raw, kind: "sha", commits: [sha] };
|
|
64
65
|
}
|
|
@@ -149,26 +150,26 @@ export function registerGitCherryPickTool(server) {
|
|
|
149
150
|
const startBranch = await getCurrentBranch(gitTop);
|
|
150
151
|
const onto = args.onto?.trim() || startBranch;
|
|
151
152
|
if (!onto)
|
|
152
|
-
return jsonRespond({ error:
|
|
153
|
+
return jsonRespond({ error: ERROR_CODES.ONTO_DETACHED_HEAD });
|
|
153
154
|
if (args.onto !== undefined && !isSafeGitRefToken(args.onto)) {
|
|
154
|
-
return jsonRespond({ error:
|
|
155
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.onto });
|
|
155
156
|
}
|
|
156
157
|
// --- Refuse dirty tree ---
|
|
157
158
|
if (!(await isWorkingTreeClean(gitTop))) {
|
|
158
|
-
return jsonRespond({ error:
|
|
159
|
+
return jsonRespond({ error: ERROR_CODES.WORKING_TREE_DIRTY });
|
|
159
160
|
}
|
|
160
161
|
// --- Ensure destination is checked out ---
|
|
161
162
|
if (onto !== startBranch) {
|
|
162
163
|
const co = await spawnGitAsync(gitTop, ["checkout", onto]);
|
|
163
164
|
if (!co.ok) {
|
|
164
165
|
return jsonRespond({
|
|
165
|
-
error:
|
|
166
|
+
error: ERROR_CODES.CHECKOUT_FAILED,
|
|
166
167
|
detail: (co.stderr || co.stdout).trim(),
|
|
167
168
|
});
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
171
|
if (!(await resolveRef(gitTop, onto))) {
|
|
171
|
-
return jsonRespond({ error:
|
|
172
|
+
return jsonRespond({ error: ERROR_CODES.DESTINATION_NOT_FOUND, ref: onto });
|
|
172
173
|
}
|
|
173
174
|
// --- Resolve each source ---
|
|
174
175
|
const resolved = [];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { matchesGlob } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
3
4
|
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
4
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
6
|
import { requireSingleRepo } from "./roots.js";
|
|
@@ -102,6 +103,12 @@ function extractFileInfo(header, body) {
|
|
|
102
103
|
status = "renamed";
|
|
103
104
|
const fromMatch = /^rename from (.+)$/m.exec(body);
|
|
104
105
|
oldPath = fromMatch?.[1];
|
|
106
|
+
// Prefer the authoritative "rename to <path>" body line over the greedy
|
|
107
|
+
// header regex split, which mis-parses paths containing the literal " b/".
|
|
108
|
+
const toMatch = /^rename to (.+)$/m.exec(body);
|
|
109
|
+
if (toMatch?.[1]) {
|
|
110
|
+
bPath = toMatch[1];
|
|
111
|
+
}
|
|
105
112
|
}
|
|
106
113
|
const path = status === "deleted" ? aPath : bPath;
|
|
107
114
|
return { path, oldPath, status };
|
|
@@ -178,7 +185,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
178
185
|
annotations: {
|
|
179
186
|
readOnlyHint: true,
|
|
180
187
|
},
|
|
181
|
-
parameters: WorkspacePickSchema.extend({
|
|
188
|
+
parameters: WorkspacePickSchema.omit({ allWorkspaceRoots: true }).extend({
|
|
182
189
|
range: z
|
|
183
190
|
.string()
|
|
184
191
|
.optional()
|
|
@@ -224,7 +231,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
224
231
|
const statResult = await spawnGitAsync(gitTop, ["diff", "--numstat", ...diffArgs]);
|
|
225
232
|
if (!statResult.ok) {
|
|
226
233
|
return jsonRespond({
|
|
227
|
-
error:
|
|
234
|
+
error: ERROR_CODES.GIT_DIFF_FAILED,
|
|
228
235
|
detail: (statResult.stderr || statResult.stdout).trim(),
|
|
229
236
|
});
|
|
230
237
|
}
|
|
@@ -233,7 +240,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
233
240
|
const diffResult = await spawnGitAsync(gitTop, ["diff", ...diffArgs]);
|
|
234
241
|
if (!diffResult.ok) {
|
|
235
242
|
return jsonRespond({
|
|
236
|
-
error:
|
|
243
|
+
error: ERROR_CODES.GIT_DIFF_FAILED,
|
|
237
244
|
detail: (diffResult.stderr || diffResult.stdout).trim(),
|
|
238
245
|
});
|
|
239
246
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
4
|
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
3
5
|
import { jsonRespond } from "./json.js";
|
|
4
6
|
import { requireSingleRepo } from "./roots.js";
|
|
@@ -19,14 +21,18 @@ function buildDiffArgs(opts) {
|
|
|
19
21
|
const baseStr = opts.base?.trim() ?? "HEAD";
|
|
20
22
|
const headStr = opts.head?.trim() ?? "HEAD";
|
|
21
23
|
if (!isSafeGitUpstreamToken(baseStr) || !isSafeGitUpstreamToken(headStr)) {
|
|
22
|
-
return { ok: false, error:
|
|
24
|
+
return { ok: false, error: ERROR_CODES.UNSAFE_RANGE_TOKEN };
|
|
23
25
|
}
|
|
24
26
|
// Use two-dot range: base..head
|
|
25
27
|
args.push(`${baseStr}..${headStr}`);
|
|
26
28
|
}
|
|
27
|
-
//
|
|
28
|
-
if (opts.
|
|
29
|
-
args.push(
|
|
29
|
+
// Apply unified context width if specified
|
|
30
|
+
if (typeof opts.unified === "number") {
|
|
31
|
+
args.push(`-U${opts.unified}`);
|
|
32
|
+
}
|
|
33
|
+
// Scope to paths if provided
|
|
34
|
+
if (opts.paths && opts.paths.length > 0) {
|
|
35
|
+
args.push("--", ...opts.paths);
|
|
30
36
|
}
|
|
31
37
|
return { ok: true, args };
|
|
32
38
|
}
|
|
@@ -44,8 +50,8 @@ function rangeLabel(opts) {
|
|
|
44
50
|
else {
|
|
45
51
|
label = "unstaged changes";
|
|
46
52
|
}
|
|
47
|
-
if (opts.
|
|
48
|
-
label += ` (${opts.
|
|
53
|
+
if (opts.paths && opts.paths.length > 0) {
|
|
54
|
+
label += ` (${opts.paths.join(", ")})`;
|
|
49
55
|
}
|
|
50
56
|
return label;
|
|
51
57
|
}
|
|
@@ -55,13 +61,17 @@ function rangeLabel(opts) {
|
|
|
55
61
|
export function registerGitDiffTool(server) {
|
|
56
62
|
server.addTool({
|
|
57
63
|
name: "git_diff",
|
|
58
|
-
description: "Get diff text for scoped file or range. Returns the raw diff output. " +
|
|
64
|
+
description: "Get diff text for scoped file(s) or range. Returns the raw diff output. " +
|
|
59
65
|
"Use `staged: true` for staged changes, `base`/`head` for revision ranges, " +
|
|
60
|
-
"
|
|
66
|
+
"`path` to scope to a single file, `paths` to scope to multiple files, " +
|
|
67
|
+
"and `unified` to control the number of context lines.",
|
|
61
68
|
annotations: {
|
|
62
69
|
readOnlyHint: true,
|
|
63
70
|
},
|
|
64
|
-
parameters: WorkspacePickSchema.
|
|
71
|
+
parameters: WorkspacePickSchema.omit({
|
|
72
|
+
absoluteGitRoots: true,
|
|
73
|
+
allWorkspaceRoots: true,
|
|
74
|
+
}).extend({
|
|
65
75
|
base: z
|
|
66
76
|
.string()
|
|
67
77
|
.optional()
|
|
@@ -75,23 +85,60 @@ export function registerGitDiffTool(server) {
|
|
|
75
85
|
path: z
|
|
76
86
|
.string()
|
|
77
87
|
.optional()
|
|
78
|
-
.describe('Scope diff to a single file path (e.g. "src/main.ts").'
|
|
88
|
+
.describe('Scope diff to a single file path (e.g. "src/main.ts"). ' +
|
|
89
|
+
"For multiple files, prefer `paths`. If both `path` and `paths` are given, they are unioned."),
|
|
90
|
+
paths: z
|
|
91
|
+
.array(z.string())
|
|
92
|
+
.optional()
|
|
93
|
+
.describe('Scope diff to multiple file paths (e.g. ["src/a.ts", "src/b.ts"]). ' +
|
|
94
|
+
"Each path is validated and must lie within the repository root. " +
|
|
95
|
+
"If both `path` and `paths` are given, they are unioned."),
|
|
79
96
|
staged: z
|
|
80
97
|
.boolean()
|
|
81
98
|
.optional()
|
|
82
99
|
.default(false)
|
|
83
100
|
.describe("If true, show staged changes (git diff --staged). " + "Ignored if `base` is provided."),
|
|
101
|
+
unified: z
|
|
102
|
+
.number()
|
|
103
|
+
.int()
|
|
104
|
+
.min(0)
|
|
105
|
+
.max(100)
|
|
106
|
+
.optional()
|
|
107
|
+
.describe("Number of context lines to show around each change (passed as -U<n> to git diff). " +
|
|
108
|
+
"Defaults to git's built-in default (3). Use 0 for no context."),
|
|
84
109
|
}),
|
|
85
110
|
execute: async (args) => {
|
|
86
111
|
const pre = requireSingleRepo(server, args);
|
|
87
112
|
if (!pre.ok)
|
|
88
113
|
return jsonRespond(pre.error);
|
|
89
114
|
const gitTop = pre.gitTop;
|
|
115
|
+
// Union path + paths, trim, dedup
|
|
116
|
+
const rawPaths = [];
|
|
117
|
+
if (args.path && typeof args.path === "string" && args.path.trim()) {
|
|
118
|
+
rawPaths.push(args.path.trim());
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(args.paths)) {
|
|
121
|
+
for (const p of args.paths) {
|
|
122
|
+
if (typeof p === "string" && p.trim()) {
|
|
123
|
+
rawPaths.push(p.trim());
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Dedup preserving order
|
|
128
|
+
const dedupedPaths = [...new Set(rawPaths)];
|
|
129
|
+
// Confine each path within the repo
|
|
130
|
+
for (const p of dedupedPaths) {
|
|
131
|
+
const resolved = resolvePathForRepo(p, gitTop);
|
|
132
|
+
if (!assertRelativePathUnderTop(p, resolved, gitTop)) {
|
|
133
|
+
return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: p });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
90
136
|
// Build git diff args
|
|
91
137
|
const diffArgsResult = buildDiffArgs({
|
|
92
138
|
base: args.base,
|
|
93
139
|
head: args.head,
|
|
94
|
-
|
|
140
|
+
paths: dedupedPaths.length > 0 ? dedupedPaths : undefined,
|
|
141
|
+
unified: typeof args.unified === "number" ? args.unified : undefined,
|
|
95
142
|
staged: args.staged,
|
|
96
143
|
});
|
|
97
144
|
if (!diffArgsResult.ok) {
|
|
@@ -101,14 +148,14 @@ export function registerGitDiffTool(server) {
|
|
|
101
148
|
const result = await spawnGitAsync(gitTop, diffArgsResult.args);
|
|
102
149
|
if (!result.ok) {
|
|
103
150
|
return jsonRespond({
|
|
104
|
-
error:
|
|
151
|
+
error: ERROR_CODES.GIT_DIFF_FAILED,
|
|
105
152
|
detail: (result.stderr || result.stdout).trim(),
|
|
106
153
|
});
|
|
107
154
|
}
|
|
108
155
|
const label = rangeLabel({
|
|
109
156
|
base: args.base,
|
|
110
157
|
head: args.head,
|
|
111
|
-
|
|
158
|
+
paths: dedupedPaths.length > 0 ? dedupedPaths : undefined,
|
|
112
159
|
staged: args.staged,
|
|
113
160
|
});
|
|
114
161
|
if (args.format === "json") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
3
|
+
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
4
|
+
import { jsonRespond, spreadWhen } from "./json.js";
|
|
4
5
|
import { requireSingleRepo } from "./roots.js";
|
|
5
6
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -30,6 +31,61 @@ function parseGitFetchOutput(output) {
|
|
|
30
31
|
}
|
|
31
32
|
return { updatedRefs, newRefs };
|
|
32
33
|
}
|
|
34
|
+
const ZEROS_SHA = "0000000000000000000000000000000000000000";
|
|
35
|
+
/**
|
|
36
|
+
* Parse `git fetch --porcelain` stdout.
|
|
37
|
+
*
|
|
38
|
+
* Machine-readable lines have the form:
|
|
39
|
+
* <flag><SP><old-sha><SP><new-sha><SP><local-ref>
|
|
40
|
+
*
|
|
41
|
+
* Flags:
|
|
42
|
+
* ' ' = fast-forward update
|
|
43
|
+
* '+' = forced update
|
|
44
|
+
* '*' = new ref
|
|
45
|
+
* '-' = pruned (deleted)
|
|
46
|
+
* '!' = rejected
|
|
47
|
+
* '=' = up-to-date (no change)
|
|
48
|
+
* 't' = tag update
|
|
49
|
+
*/
|
|
50
|
+
function parsePorcelainOutput(stdout) {
|
|
51
|
+
const updated = [];
|
|
52
|
+
const created = [];
|
|
53
|
+
const pruned = [];
|
|
54
|
+
for (const line of stdout.split("\n")) {
|
|
55
|
+
if (!line)
|
|
56
|
+
continue;
|
|
57
|
+
// Porcelain lines: flag(1) + space(1) + old-sha + space + new-sha + space + ref
|
|
58
|
+
// Minimum viable line: 1 flag + 1 space + at least some content
|
|
59
|
+
if (line.length < 3)
|
|
60
|
+
continue;
|
|
61
|
+
const flag = line[0];
|
|
62
|
+
const rest = line.slice(2); // skip "flag " prefix
|
|
63
|
+
const parts = rest.split(" ");
|
|
64
|
+
// Expected: [old-sha, new-sha, ref...]
|
|
65
|
+
if (parts.length < 3)
|
|
66
|
+
continue;
|
|
67
|
+
const oldSha = parts[0] ?? "";
|
|
68
|
+
const newSha = parts[1] ?? "";
|
|
69
|
+
const ref = parts.slice(2).join(" ");
|
|
70
|
+
if (!ref)
|
|
71
|
+
continue;
|
|
72
|
+
if (flag === "-") {
|
|
73
|
+
pruned.push({ ref });
|
|
74
|
+
}
|
|
75
|
+
else if (flag === "*" || oldSha === ZEROS_SHA) {
|
|
76
|
+
// New ref: flag is '*' OR old-sha is all-zeros
|
|
77
|
+
created.push({ ref, newSha, flag: flag ?? "*" });
|
|
78
|
+
}
|
|
79
|
+
else if (flag === " " || flag === "+" || flag === "t") {
|
|
80
|
+
// Update: old and new differ
|
|
81
|
+
if (oldSha !== newSha) {
|
|
82
|
+
updated.push({ ref, oldSha, newSha, flag: flag ?? " " });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// '!' (rejected) and '=' (up-to-date) are intentionally ignored
|
|
86
|
+
}
|
|
87
|
+
return { updated, created, pruned };
|
|
88
|
+
}
|
|
33
89
|
// ---------------------------------------------------------------------------
|
|
34
90
|
// Tool registration
|
|
35
91
|
// ---------------------------------------------------------------------------
|
|
@@ -78,26 +134,82 @@ export function registerGitFetchTool(server) {
|
|
|
78
134
|
const branch = args.branch?.trim();
|
|
79
135
|
const prune = args.prune === true;
|
|
80
136
|
const tags = args.tags === true;
|
|
81
|
-
//
|
|
82
|
-
|
|
137
|
+
// Validate remote and branch to prevent argument injection.
|
|
138
|
+
if (!isSafeGitUpstreamToken(remote)) {
|
|
139
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REMOTE_TOKEN, remote });
|
|
140
|
+
}
|
|
141
|
+
if (branch && !isSafeGitUpstreamToken(branch)) {
|
|
142
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, branch });
|
|
143
|
+
}
|
|
144
|
+
// Build base fetch args (without --porcelain for now)
|
|
145
|
+
const baseArgs = ["fetch"];
|
|
83
146
|
if (prune) {
|
|
84
|
-
|
|
147
|
+
baseArgs.push("--prune");
|
|
85
148
|
}
|
|
86
149
|
if (tags) {
|
|
87
|
-
|
|
150
|
+
baseArgs.push("--tags");
|
|
88
151
|
}
|
|
89
|
-
|
|
152
|
+
baseArgs.push(remote);
|
|
90
153
|
if (branch) {
|
|
91
|
-
|
|
154
|
+
baseArgs.push(branch);
|
|
155
|
+
}
|
|
156
|
+
// --- Attempt structured fetch with --porcelain (requires git >= 2.41) ---
|
|
157
|
+
let structured = null;
|
|
158
|
+
let result;
|
|
159
|
+
const porcelainArgs = ["fetch", "--porcelain"];
|
|
160
|
+
if (prune)
|
|
161
|
+
porcelainArgs.push("--prune");
|
|
162
|
+
if (tags)
|
|
163
|
+
porcelainArgs.push("--tags");
|
|
164
|
+
porcelainArgs.push(remote);
|
|
165
|
+
if (branch)
|
|
166
|
+
porcelainArgs.push(branch);
|
|
167
|
+
const porcelainResult = await spawnGitAsync(gitTop, porcelainArgs);
|
|
168
|
+
if (!porcelainResult.ok &&
|
|
169
|
+
(porcelainResult.stderr.includes("unknown option") ||
|
|
170
|
+
porcelainResult.stderr.includes("unknown switch") ||
|
|
171
|
+
porcelainResult.stderr.includes("invalid option"))) {
|
|
172
|
+
// --porcelain not supported: fall back to plain fetch
|
|
173
|
+
result = await spawnGitAsync(gitTop, baseArgs);
|
|
174
|
+
structured = null;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Use porcelain result (ok or actual fetch error)
|
|
178
|
+
result = porcelainResult;
|
|
179
|
+
if (porcelainResult.ok || !porcelainResult.stderr.includes("unknown option")) {
|
|
180
|
+
structured = parsePorcelainOutput(porcelainResult.stdout);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Legacy string-line parse: use combined output when porcelain is unavailable.
|
|
184
|
+
// When porcelain succeeded, derive legacy fields from structured data so
|
|
185
|
+
// that callers who rely on updatedRefs/newRefs still get useful values.
|
|
186
|
+
let updatedRefs;
|
|
187
|
+
let newRefs;
|
|
188
|
+
if (structured !== null) {
|
|
189
|
+
// Derive legacy string arrays from structured deltas
|
|
190
|
+
updatedRefs = structured.updated.map((d) => `${d.oldSha.slice(0, 7)}..${d.newSha.slice(0, 7)} ${d.ref}`);
|
|
191
|
+
newRefs = structured.created.map((d) => `[new ref] ${d.ref} -> ${d.newSha.slice(0, 7)}`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const parsed = parseGitFetchOutput(result.stdout + result.stderr);
|
|
195
|
+
updatedRefs = parsed.updatedRefs;
|
|
196
|
+
newRefs = parsed.newRefs;
|
|
92
197
|
}
|
|
93
|
-
const result = await spawnGitAsync(gitTop, fetchArgs);
|
|
94
|
-
const { updatedRefs, newRefs } = parseGitFetchOutput(result.stdout + result.stderr);
|
|
95
198
|
const fetchResult = {
|
|
96
199
|
ok: result.ok,
|
|
97
200
|
remote,
|
|
98
201
|
updatedRefs,
|
|
99
202
|
newRefs,
|
|
100
203
|
output: (result.stdout + result.stderr).trim(),
|
|
204
|
+
...spreadWhen(structured !== null && structured.updated.length > 0, {
|
|
205
|
+
updated: structured?.updated ?? [],
|
|
206
|
+
}),
|
|
207
|
+
...spreadWhen(structured !== null && structured.created.length > 0, {
|
|
208
|
+
created: structured?.created ?? [],
|
|
209
|
+
}),
|
|
210
|
+
...spreadWhen(structured !== null && structured.pruned.length > 0, {
|
|
211
|
+
pruned: structured?.pruned ?? [],
|
|
212
|
+
}),
|
|
101
213
|
};
|
|
102
214
|
if (args.format === "json") {
|
|
103
215
|
return jsonRespond(fetchResult);
|
|
@@ -110,19 +222,42 @@ export function registerGitFetchTool(server) {
|
|
|
110
222
|
return lines.join("\n");
|
|
111
223
|
}
|
|
112
224
|
lines.push("", "**Status**: Success", "");
|
|
113
|
-
|
|
225
|
+
// Prefer structured deltas in markdown when available
|
|
226
|
+
if (structured !== null && structured.updated.length > 0) {
|
|
227
|
+
lines.push("## Updated refs", "");
|
|
228
|
+
for (const d of structured.updated) {
|
|
229
|
+
lines.push(`- \`${d.ref}\` ${d.oldSha.slice(0, 7)}→${d.newSha.slice(0, 7)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (updatedRefs.length > 0) {
|
|
114
233
|
lines.push("## Updated refs", "");
|
|
115
234
|
for (const ref of updatedRefs) {
|
|
116
235
|
lines.push(`- ${ref}`);
|
|
117
236
|
}
|
|
118
237
|
}
|
|
119
|
-
if (
|
|
238
|
+
if (structured !== null && structured.created.length > 0) {
|
|
239
|
+
lines.push("", "## New refs", "");
|
|
240
|
+
for (const d of structured.created) {
|
|
241
|
+
lines.push(`- \`${d.ref}\` (new, ${d.newSha.slice(0, 7)})`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (newRefs.length > 0) {
|
|
120
245
|
lines.push("", "## New refs", "");
|
|
121
246
|
for (const ref of newRefs) {
|
|
122
247
|
lines.push(`- ${ref}`);
|
|
123
248
|
}
|
|
124
249
|
}
|
|
125
|
-
if (
|
|
250
|
+
if (structured !== null && structured.pruned.length > 0) {
|
|
251
|
+
lines.push("", "## Pruned refs", "");
|
|
252
|
+
for (const d of structured.pruned) {
|
|
253
|
+
lines.push(`- \`${d.ref}\` (deleted)`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (updatedRefs.length === 0 &&
|
|
257
|
+
newRefs.length === 0 &&
|
|
258
|
+
(structured === null ||
|
|
259
|
+
(structured.updated.length === 0 && structured.created.length === 0)) &&
|
|
260
|
+
result.stdout.trim()) {
|
|
126
261
|
lines.push("", "## Output", "", "```", result.stdout.trim(), "```");
|
|
127
262
|
}
|
|
128
263
|
return lines.join("\n");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
3
|
import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitRevParseGitDir, gitTopLevel, isSafeGitUpstreamToken, } from "./git.js";
|
|
3
4
|
import { buildInventorySectionMarkdown, collectInventoryEntry, makeSkipEntry, validateRepoPath, } from "./inventory.js";
|
|
4
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
@@ -27,7 +28,7 @@ export function registerGitInventoryTool(server) {
|
|
|
27
28
|
execute: async (args) => {
|
|
28
29
|
if (args.absoluteGitRoots != null && args.absoluteGitRoots.length > 0) {
|
|
29
30
|
if (args.preset || (args.nestedRoots?.length ?? 0) > 0) {
|
|
30
|
-
return jsonRespond({ error:
|
|
31
|
+
return jsonRespond({ error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_NESTED_OR_PRESET_CONFLICT });
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
const pre = requireGitAndRoots(server, args, args.preset);
|
|
@@ -39,12 +40,12 @@ export function registerGitInventoryTool(server) {
|
|
|
39
40
|
const hasRemote = rawRemote !== undefined && rawRemote !== "";
|
|
40
41
|
const hasBranch = rawBranch !== undefined && rawBranch !== "";
|
|
41
42
|
if (hasRemote !== hasBranch) {
|
|
42
|
-
return jsonRespond({ error:
|
|
43
|
+
return jsonRespond({ error: ERROR_CODES.REMOTE_BRANCH_MISMATCH });
|
|
43
44
|
}
|
|
44
45
|
let upstream = { mode: "auto" };
|
|
45
46
|
if (hasRemote && hasBranch && rawRemote && rawBranch) {
|
|
46
47
|
if (!isSafeGitUpstreamToken(rawRemote) || !isSafeGitUpstreamToken(rawBranch)) {
|
|
47
|
-
return jsonRespond({ error:
|
|
48
|
+
return jsonRespond({ error: ERROR_CODES.INVALID_REMOTE_OR_BRANCH });
|
|
48
49
|
}
|
|
49
50
|
upstream = { mode: "fixed", remote: rawRemote, branch: rawBranch };
|
|
50
51
|
}
|
|
@@ -54,10 +55,10 @@ export function registerGitInventoryTool(server) {
|
|
|
54
55
|
for (const workspaceRoot of pre.roots) {
|
|
55
56
|
const top = gitTopLevel(workspaceRoot);
|
|
56
57
|
if (!top) {
|
|
57
|
-
const err = { error:
|
|
58
|
+
const err = { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: workspaceRoot };
|
|
58
59
|
if (args.format === "json") {
|
|
59
60
|
allJson.push({
|
|
60
|
-
|
|
61
|
+
workspaceRoot: workspaceRoot,
|
|
61
62
|
...(upstream.mode === "fixed" ? { upstream } : {}),
|
|
62
63
|
entries: [
|
|
63
64
|
makeSkipEntry(workspaceRoot, workspaceRoot, upstream.mode, JSON.stringify(err)),
|
|
@@ -117,7 +118,7 @@ export function registerGitInventoryTool(server) {
|
|
|
117
118
|
}
|
|
118
119
|
if (args.format === "json") {
|
|
119
120
|
allJson.push({
|
|
120
|
-
|
|
121
|
+
workspaceRoot: top,
|
|
121
122
|
...spreadDefined("presetSchemaVersion", presetSchemaVersion),
|
|
122
123
|
...spreadWhen(nestedRootsTruncated, {
|
|
123
124
|
nestedRootsTruncated: true,
|