@rethunk/mcp-multi-root-git 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/dist/server/batch-commit-tool.js +7 -6
- package/dist/server/error-codes.js +95 -0
- package/dist/server/git-blame-tool.js +227 -0
- package/dist/server/git-branch-list-tool.js +140 -0
- package/dist/server/git-cherry-pick-tool.js +11 -10
- package/dist/server/git-diff-summary-tool.js +3 -2
- package/dist/server/git-diff-tool.js +56 -12
- package/dist/server/git-fetch-tool.js +142 -14
- package/dist/server/git-inventory-tool.js +5 -4
- package/dist/server/git-log-tool.js +10 -5
- package/dist/server/git-merge-tool.js +15 -14
- package/dist/server/git-parity-tool.js +3 -2
- package/dist/server/git-push-tool.js +9 -5
- package/dist/server/git-reflog-tool.js +126 -0
- package/dist/server/git-reset-soft-tool.js +4 -3
- package/dist/server/git-show-tool.js +83 -23
- package/dist/server/git-stash-tool.js +2 -1
- package/dist/server/git-tag-tool.js +8 -7
- package/dist/server/git-worktree-tool.js +8 -7
- package/dist/server/git.js +70 -4
- package/dist/server/list-presets-tool.js +2 -1
- package/dist/server/presets-resource.js +3 -2
- package/dist/server/presets.js +11 -5
- package/dist/server/roots.js +11 -10
- package/dist/server/tool-parameter-schemas.js +9 -0
- package/dist/server/tools.js +6 -0
- package/docs/mcp-tools.md +119 -6
- package/package.json +2 -2
- package/schemas/git_blame.json +52 -0
- package/schemas/git_branch_list.json +36 -0
- package/schemas/git_diff.json +14 -1
- package/schemas/git_reflog.json +44 -0
- package/schemas/git_show.json +12 -1
- package/schemas/index.json +15 -0
- package/tool-parameters.schema.json +152 -2
|
@@ -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,9 +61,10 @@ 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
|
},
|
|
@@ -78,23 +85,60 @@ export function registerGitDiffTool(server) {
|
|
|
78
85
|
path: z
|
|
79
86
|
.string()
|
|
80
87
|
.optional()
|
|
81
|
-
.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."),
|
|
82
96
|
staged: z
|
|
83
97
|
.boolean()
|
|
84
98
|
.optional()
|
|
85
99
|
.default(false)
|
|
86
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."),
|
|
87
109
|
}),
|
|
88
110
|
execute: async (args) => {
|
|
89
111
|
const pre = requireSingleRepo(server, args);
|
|
90
112
|
if (!pre.ok)
|
|
91
113
|
return jsonRespond(pre.error);
|
|
92
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
|
+
}
|
|
93
136
|
// Build git diff args
|
|
94
137
|
const diffArgsResult = buildDiffArgs({
|
|
95
138
|
base: args.base,
|
|
96
139
|
head: args.head,
|
|
97
|
-
|
|
140
|
+
paths: dedupedPaths.length > 0 ? dedupedPaths : undefined,
|
|
141
|
+
unified: typeof args.unified === "number" ? args.unified : undefined,
|
|
98
142
|
staged: args.staged,
|
|
99
143
|
});
|
|
100
144
|
if (!diffArgsResult.ok) {
|
|
@@ -104,14 +148,14 @@ export function registerGitDiffTool(server) {
|
|
|
104
148
|
const result = await spawnGitAsync(gitTop, diffArgsResult.args);
|
|
105
149
|
if (!result.ok) {
|
|
106
150
|
return jsonRespond({
|
|
107
|
-
error:
|
|
151
|
+
error: ERROR_CODES.GIT_DIFF_FAILED,
|
|
108
152
|
detail: (result.stderr || result.stdout).trim(),
|
|
109
153
|
});
|
|
110
154
|
}
|
|
111
155
|
const label = rangeLabel({
|
|
112
156
|
base: args.base,
|
|
113
157
|
head: args.head,
|
|
114
|
-
|
|
158
|
+
paths: dedupedPaths.length > 0 ? dedupedPaths : undefined,
|
|
115
159
|
staged: args.staged,
|
|
116
160
|
});
|
|
117
161
|
if (args.format === "json") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
3
|
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
3
|
-
import { jsonRespond } from "./json.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
|
// ---------------------------------------------------------------------------
|
|
@@ -80,31 +136,80 @@ export function registerGitFetchTool(server) {
|
|
|
80
136
|
const tags = args.tags === true;
|
|
81
137
|
// Validate remote and branch to prevent argument injection.
|
|
82
138
|
if (!isSafeGitUpstreamToken(remote)) {
|
|
83
|
-
return jsonRespond({ error:
|
|
139
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REMOTE_TOKEN, remote });
|
|
84
140
|
}
|
|
85
141
|
if (branch && !isSafeGitUpstreamToken(branch)) {
|
|
86
|
-
return jsonRespond({ error:
|
|
142
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, branch });
|
|
87
143
|
}
|
|
88
|
-
// Build
|
|
89
|
-
const
|
|
144
|
+
// Build base fetch args (without --porcelain for now)
|
|
145
|
+
const baseArgs = ["fetch"];
|
|
90
146
|
if (prune) {
|
|
91
|
-
|
|
147
|
+
baseArgs.push("--prune");
|
|
92
148
|
}
|
|
93
149
|
if (tags) {
|
|
94
|
-
|
|
150
|
+
baseArgs.push("--tags");
|
|
95
151
|
}
|
|
96
|
-
|
|
152
|
+
baseArgs.push(remote);
|
|
97
153
|
if (branch) {
|
|
98
|
-
|
|
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;
|
|
99
197
|
}
|
|
100
|
-
const result = await spawnGitAsync(gitTop, fetchArgs);
|
|
101
|
-
const { updatedRefs, newRefs } = parseGitFetchOutput(result.stdout + result.stderr);
|
|
102
198
|
const fetchResult = {
|
|
103
199
|
ok: result.ok,
|
|
104
200
|
remote,
|
|
105
201
|
updatedRefs,
|
|
106
202
|
newRefs,
|
|
107
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
|
+
}),
|
|
108
213
|
};
|
|
109
214
|
if (args.format === "json") {
|
|
110
215
|
return jsonRespond(fetchResult);
|
|
@@ -117,19 +222,42 @@ export function registerGitFetchTool(server) {
|
|
|
117
222
|
return lines.join("\n");
|
|
118
223
|
}
|
|
119
224
|
lines.push("", "**Status**: Success", "");
|
|
120
|
-
|
|
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) {
|
|
121
233
|
lines.push("## Updated refs", "");
|
|
122
234
|
for (const ref of updatedRefs) {
|
|
123
235
|
lines.push(`- ${ref}`);
|
|
124
236
|
}
|
|
125
237
|
}
|
|
126
|
-
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) {
|
|
127
245
|
lines.push("", "## New refs", "");
|
|
128
246
|
for (const ref of newRefs) {
|
|
129
247
|
lines.push(`- ${ref}`);
|
|
130
248
|
}
|
|
131
249
|
}
|
|
132
|
-
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()) {
|
|
133
261
|
lines.push("", "## Output", "", "```", result.stdout.trim(), "```");
|
|
134
262
|
}
|
|
135
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,7 +55,7 @@ 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,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
3
4
|
import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitTopLevel, spawnGitAsync } from "./git.js";
|
|
4
5
|
import { isSafeGitAncestorRef } from "./git-refs.js";
|
|
5
6
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
@@ -82,7 +83,7 @@ async function runGitLog(opts) {
|
|
|
82
83
|
const r = await spawnGitAsync(top, logArgs);
|
|
83
84
|
if (!r.ok) {
|
|
84
85
|
return {
|
|
85
|
-
error:
|
|
86
|
+
error: ERROR_CODES.GIT_LOG_FAILED,
|
|
86
87
|
path: top,
|
|
87
88
|
};
|
|
88
89
|
}
|
|
@@ -222,19 +223,19 @@ export function registerGitLogTool(server) {
|
|
|
222
223
|
// Validate `since` — reject obvious injection attempts (newlines, semicolons, shell chars).
|
|
223
224
|
const rawSince = (args.since?.trim() ?? DEFAULT_SINCE) || DEFAULT_SINCE;
|
|
224
225
|
if (/[\n\r;|&`$<>]/.test(rawSince)) {
|
|
225
|
-
return jsonRespond({ error:
|
|
226
|
+
return jsonRespond({ error: ERROR_CODES.INVALID_SINCE, since: rawSince });
|
|
226
227
|
}
|
|
227
228
|
// Validate paths — reject anything with null bytes or shell meta.
|
|
228
229
|
// Use charCodeAt(0) === 0 for the null byte to avoid a biome lint on control chars in regex.
|
|
229
230
|
const rawPaths = args.paths ?? [];
|
|
230
231
|
for (const p of rawPaths) {
|
|
231
232
|
if (p.split("").some((c) => c.charCodeAt(0) === 0) || /[\n\r;|&`$<>]/.test(p)) {
|
|
232
|
-
return jsonRespond({ error:
|
|
233
|
+
return jsonRespond({ error: ERROR_CODES.INVALID_PATHS, path: p });
|
|
233
234
|
}
|
|
234
235
|
}
|
|
235
236
|
// Validate branch — reject leading-dash and other injection attempts.
|
|
236
237
|
if (args.branch && !isSafeGitAncestorRef(args.branch)) {
|
|
237
|
-
return jsonRespond({ error:
|
|
238
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, branch: args.branch });
|
|
238
239
|
}
|
|
239
240
|
const maxCommits = Math.min(args.maxCommits ?? DEFAULT_MAX_COMMITS, MAX_COMMITS_HARD_CAP);
|
|
240
241
|
// Fan out across roots.
|
|
@@ -242,7 +243,11 @@ export function registerGitLogTool(server) {
|
|
|
242
243
|
const results = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
|
|
243
244
|
const top = gitTopLevel(rootInput);
|
|
244
245
|
if (!top) {
|
|
245
|
-
return {
|
|
246
|
+
return {
|
|
247
|
+
_error: true,
|
|
248
|
+
workspaceRoot: rootInput,
|
|
249
|
+
error: ERROR_CODES.NOT_A_GIT_REPOSITORY,
|
|
250
|
+
};
|
|
246
251
|
}
|
|
247
252
|
const r = await runGitLog({
|
|
248
253
|
top,
|
|
@@ -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 { conflictPaths, getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
4
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
@@ -36,17 +37,17 @@ async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
|
|
|
36
37
|
const intoSha = await resolveRef(gitTop, into);
|
|
37
38
|
const sourceSha = await resolveRef(gitTop, source);
|
|
38
39
|
if (!sourceSha) {
|
|
39
|
-
return { source, ok: false, error:
|
|
40
|
+
return { source, ok: false, error: ERROR_CODES.SOURCE_NOT_FOUND };
|
|
40
41
|
}
|
|
41
42
|
if (!intoSha) {
|
|
42
|
-
return { source, ok: false, error:
|
|
43
|
+
return { source, ok: false, error: ERROR_CODES.DESTINATION_NOT_FOUND };
|
|
43
44
|
}
|
|
44
45
|
const mb = await spawnGitAsync(gitTop, ["merge-base", into, source]);
|
|
45
46
|
if (!mb.ok) {
|
|
46
47
|
return {
|
|
47
48
|
source,
|
|
48
49
|
ok: false,
|
|
49
|
-
error:
|
|
50
|
+
error: ERROR_CODES.MERGE_BASE_FAILED,
|
|
50
51
|
detail: (mb.stderr || mb.stdout).trim(),
|
|
51
52
|
};
|
|
52
53
|
}
|
|
@@ -63,7 +64,7 @@ async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
|
|
|
63
64
|
return {
|
|
64
65
|
source,
|
|
65
66
|
ok: false,
|
|
66
|
-
error:
|
|
67
|
+
error: ERROR_CODES.CANNOT_FAST_FORWARD,
|
|
67
68
|
detail: "destination and source have diverged; retry with strategy: rebase, merge, or auto",
|
|
68
69
|
};
|
|
69
70
|
}
|
|
@@ -102,7 +103,7 @@ async function fastForward(gitTop, source) {
|
|
|
102
103
|
outcome: "conflicts",
|
|
103
104
|
conflictStage: "merge",
|
|
104
105
|
conflictPaths: [],
|
|
105
|
-
error:
|
|
106
|
+
error: ERROR_CODES.MERGE_FAILED,
|
|
106
107
|
detail: (r.stderr || r.stdout).trim(),
|
|
107
108
|
};
|
|
108
109
|
}
|
|
@@ -126,7 +127,7 @@ async function mergeCommit(gitTop, source, message, into) {
|
|
|
126
127
|
outcome: "conflicts",
|
|
127
128
|
conflictStage: "merge",
|
|
128
129
|
conflictPaths: paths,
|
|
129
|
-
error:
|
|
130
|
+
error: ERROR_CODES.MERGE_CONFLICTS,
|
|
130
131
|
detail: (r.stderr || r.stdout).trim(),
|
|
131
132
|
};
|
|
132
133
|
}
|
|
@@ -156,7 +157,7 @@ async function rebaseSourceOntoInto(gitTop, into, source) {
|
|
|
156
157
|
outcome: "conflicts",
|
|
157
158
|
conflictStage: "rebase",
|
|
158
159
|
conflictPaths: paths,
|
|
159
|
-
error:
|
|
160
|
+
error: ERROR_CODES.REBASE_CONFLICTS,
|
|
160
161
|
detail: (r.stderr || r.stdout).trim(),
|
|
161
162
|
};
|
|
162
163
|
}
|
|
@@ -166,7 +167,7 @@ async function rebaseSourceOntoInto(gitTop, into, source) {
|
|
|
166
167
|
return {
|
|
167
168
|
source,
|
|
168
169
|
ok: false,
|
|
169
|
-
error:
|
|
170
|
+
error: ERROR_CODES.CHECKOUT_FAILED,
|
|
170
171
|
detail: (co.stderr || co.stdout).trim(),
|
|
171
172
|
};
|
|
172
173
|
}
|
|
@@ -253,28 +254,28 @@ export function registerGitMergeTool(server) {
|
|
|
253
254
|
// --- Validate ref tokens early ---
|
|
254
255
|
for (const s of args.sources) {
|
|
255
256
|
if (!isSafeGitRefToken(s)) {
|
|
256
|
-
return jsonRespond({ error:
|
|
257
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: s });
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
if (args.into !== undefined && !isSafeGitRefToken(args.into)) {
|
|
260
|
-
return jsonRespond({ error:
|
|
261
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.into });
|
|
261
262
|
}
|
|
262
263
|
// --- Resolve destination ---
|
|
263
264
|
const startBranch = await getCurrentBranch(gitTop);
|
|
264
265
|
const into = args.into?.trim() || startBranch;
|
|
265
266
|
if (!into) {
|
|
266
|
-
return jsonRespond({ error:
|
|
267
|
+
return jsonRespond({ error: ERROR_CODES.INTO_DETACHED_HEAD });
|
|
267
268
|
}
|
|
268
269
|
// --- Refuse dirty tree ---
|
|
269
270
|
if (!(await isWorkingTreeClean(gitTop))) {
|
|
270
|
-
return jsonRespond({ error:
|
|
271
|
+
return jsonRespond({ error: ERROR_CODES.WORKING_TREE_DIRTY });
|
|
271
272
|
}
|
|
272
273
|
// --- Ensure destination is checked out ---
|
|
273
274
|
if (into !== startBranch) {
|
|
274
275
|
const co = await spawnGitAsync(gitTop, ["checkout", into]);
|
|
275
276
|
if (!co.ok) {
|
|
276
277
|
return jsonRespond({
|
|
277
|
-
error:
|
|
278
|
+
error: ERROR_CODES.CHECKOUT_FAILED,
|
|
278
279
|
detail: (co.stderr || co.stdout).trim(),
|
|
279
280
|
});
|
|
280
281
|
}
|
|
@@ -282,7 +283,7 @@ export function registerGitMergeTool(server) {
|
|
|
282
283
|
// Verify destination exists after checkout.
|
|
283
284
|
const intoShaProbe = await resolveRef(gitTop, into);
|
|
284
285
|
if (!intoShaProbe) {
|
|
285
|
-
return jsonRespond({ error:
|
|
286
|
+
return jsonRespond({ error: ERROR_CODES.DESTINATION_NOT_FOUND, ref: into });
|
|
286
287
|
}
|
|
287
288
|
// --- Merge each source sequentially ---
|
|
288
289
|
const strategy = args.strategy ?? "auto";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
3
|
import { gitRevParseHead, gitTopLevel } from "./git.js";
|
|
3
4
|
import { validateRepoPath } from "./inventory.js";
|
|
4
5
|
import { jsonRespond, spreadDefined } from "./json.js";
|
|
@@ -33,7 +34,7 @@ export function registerGitParityTool(server) {
|
|
|
33
34
|
for (const workspaceRoot of pre.roots) {
|
|
34
35
|
const top = gitTopLevel(workspaceRoot);
|
|
35
36
|
if (!top) {
|
|
36
|
-
const errPayload = { error:
|
|
37
|
+
const errPayload = { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: workspaceRoot };
|
|
37
38
|
const err = jsonRespond(errPayload);
|
|
38
39
|
if (args.format === "json") {
|
|
39
40
|
results.push({
|
|
@@ -58,7 +59,7 @@ export function registerGitParityTool(server) {
|
|
|
58
59
|
parityPresetSchemaVersion = applied.presetSchemaVersion;
|
|
59
60
|
}
|
|
60
61
|
if (!pairs?.length) {
|
|
61
|
-
return jsonRespond({ error:
|
|
62
|
+
return jsonRespond({ error: ERROR_CODES.NO_PAIRS });
|
|
62
63
|
}
|
|
63
64
|
let allOk = true;
|
|
64
65
|
const pairResults = [];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
3
|
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
3
4
|
import { getCurrentBranch, inferRemoteFromUpstream, isSafeGitRefToken } from "./git-refs.js";
|
|
4
5
|
import { jsonRespond, spreadDefined } from "./json.js";
|
|
@@ -42,16 +43,19 @@ export function registerGitPushTool(server) {
|
|
|
42
43
|
const currentBranch = await getCurrentBranch(gitTop);
|
|
43
44
|
const branch = args.branch?.trim() || currentBranch;
|
|
44
45
|
if (!branch) {
|
|
45
|
-
return jsonRespond({ error:
|
|
46
|
+
return jsonRespond({ error: ERROR_CODES.PUSH_DETACHED_HEAD });
|
|
46
47
|
}
|
|
47
48
|
if (!isSafeGitRefToken(branch)) {
|
|
48
|
-
return jsonRespond({ error:
|
|
49
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: branch });
|
|
49
50
|
}
|
|
50
51
|
// --- Resolve remote ---
|
|
51
52
|
let remote;
|
|
52
53
|
if (args.remote?.trim()) {
|
|
53
54
|
if (!isSafeGitUpstreamToken(args.remote.trim())) {
|
|
54
|
-
return jsonRespond({
|
|
55
|
+
return jsonRespond({
|
|
56
|
+
error: ERROR_CODES.UNSAFE_REMOTE_TOKEN,
|
|
57
|
+
remote: args.remote.trim(),
|
|
58
|
+
});
|
|
55
59
|
}
|
|
56
60
|
remote = args.remote.trim();
|
|
57
61
|
}
|
|
@@ -64,7 +68,7 @@ export function registerGitPushTool(server) {
|
|
|
64
68
|
const t = await inferRemoteFromUpstream(gitTop);
|
|
65
69
|
if (!t.ok) {
|
|
66
70
|
return jsonRespond({
|
|
67
|
-
error:
|
|
71
|
+
error: ERROR_CODES.PUSH_NO_UPSTREAM,
|
|
68
72
|
branch,
|
|
69
73
|
detail: t.detail,
|
|
70
74
|
});
|
|
@@ -82,7 +86,7 @@ export function registerGitPushTool(server) {
|
|
|
82
86
|
ok: false,
|
|
83
87
|
branch,
|
|
84
88
|
remote,
|
|
85
|
-
error:
|
|
89
|
+
error: ERROR_CODES.PUSH_FAILED,
|
|
86
90
|
detail: (pushResult.stderr || pushResult.stdout).trim(),
|
|
87
91
|
});
|
|
88
92
|
}
|