@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
|
@@ -1,6 +1,8 @@
|
|
|
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";
|
|
5
|
+
import { isSafeGitAncestorRef } from "./git-refs.js";
|
|
4
6
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
7
|
import { requireGitAndRoots } from "./roots.js";
|
|
6
8
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -81,7 +83,7 @@ async function runGitLog(opts) {
|
|
|
81
83
|
const r = await spawnGitAsync(top, logArgs);
|
|
82
84
|
if (!r.ok) {
|
|
83
85
|
return {
|
|
84
|
-
error:
|
|
86
|
+
error: ERROR_CODES.GIT_LOG_FAILED,
|
|
85
87
|
path: top,
|
|
86
88
|
};
|
|
87
89
|
}
|
|
@@ -221,23 +223,31 @@ export function registerGitLogTool(server) {
|
|
|
221
223
|
// Validate `since` — reject obvious injection attempts (newlines, semicolons, shell chars).
|
|
222
224
|
const rawSince = (args.since?.trim() ?? DEFAULT_SINCE) || DEFAULT_SINCE;
|
|
223
225
|
if (/[\n\r;|&`$<>]/.test(rawSince)) {
|
|
224
|
-
return jsonRespond({ error:
|
|
226
|
+
return jsonRespond({ error: ERROR_CODES.INVALID_SINCE, since: rawSince });
|
|
225
227
|
}
|
|
226
228
|
// Validate paths — reject anything with null bytes or shell meta.
|
|
227
229
|
// Use charCodeAt(0) === 0 for the null byte to avoid a biome lint on control chars in regex.
|
|
228
230
|
const rawPaths = args.paths ?? [];
|
|
229
231
|
for (const p of rawPaths) {
|
|
230
232
|
if (p.split("").some((c) => c.charCodeAt(0) === 0) || /[\n\r;|&`$<>]/.test(p)) {
|
|
231
|
-
return jsonRespond({ error:
|
|
233
|
+
return jsonRespond({ error: ERROR_CODES.INVALID_PATHS, path: p });
|
|
232
234
|
}
|
|
233
235
|
}
|
|
236
|
+
// Validate branch — reject leading-dash and other injection attempts.
|
|
237
|
+
if (args.branch && !isSafeGitAncestorRef(args.branch)) {
|
|
238
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, branch: args.branch });
|
|
239
|
+
}
|
|
234
240
|
const maxCommits = Math.min(args.maxCommits ?? DEFAULT_MAX_COMMITS, MAX_COMMITS_HARD_CAP);
|
|
235
241
|
// Fan out across roots.
|
|
236
242
|
const jobs = pre.roots.map((rootInput) => ({ rootInput }));
|
|
237
243
|
const results = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
|
|
238
244
|
const top = gitTopLevel(rootInput);
|
|
239
245
|
if (!top) {
|
|
240
|
-
return {
|
|
246
|
+
return {
|
|
247
|
+
_error: true,
|
|
248
|
+
workspaceRoot: rootInput,
|
|
249
|
+
error: ERROR_CODES.NOT_A_GIT_REPOSITORY,
|
|
250
|
+
};
|
|
241
251
|
}
|
|
242
252
|
const r = await runGitLog({
|
|
243
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,11 +34,11 @@ 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({
|
|
40
|
-
|
|
41
|
+
workspaceRoot: workspaceRoot,
|
|
41
42
|
status: "MISMATCH",
|
|
42
43
|
pairs: [{ label: "—", leftPath: "", rightPath: "", match: false, error: err }],
|
|
43
44
|
});
|
|
@@ -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 = [];
|
|
@@ -114,7 +115,7 @@ export function registerGitParityTool(server) {
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
results.push({
|
|
117
|
-
|
|
118
|
+
workspaceRoot: top,
|
|
118
119
|
...spreadDefined("presetSchemaVersion", parityPresetSchemaVersion),
|
|
119
120
|
status: allOk ? "OK" : "MISMATCH",
|
|
120
121
|
pairs: 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
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
3
|
+
import { spawnGitAsync } from "./git.js";
|
|
4
|
+
import { isSafeGitRefToken } from "./git-refs.js";
|
|
5
|
+
import { jsonRespond } from "./json.js";
|
|
6
|
+
import { requireSingleRepo } from "./roots.js";
|
|
7
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Constants
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const MAX_ENTRIES_HARD_CAP = 200;
|
|
12
|
+
const DEFAULT_MAX_ENTRIES = 30;
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
/**
|
|
17
|
+
* Run git reflog for a single ref and return structured data.
|
|
18
|
+
* Uses NUL-delimited fields within each line for robust parsing.
|
|
19
|
+
*/
|
|
20
|
+
async function runGitReflog(opts) {
|
|
21
|
+
const { top, ref, maxEntries } = opts;
|
|
22
|
+
// %h = short sha, %H = full sha, %gd = selector (HEAD@{0}), %gs = reflog subject
|
|
23
|
+
const reflogArgs = [
|
|
24
|
+
"reflog",
|
|
25
|
+
"show",
|
|
26
|
+
ref,
|
|
27
|
+
"--format=%h%x00%H%x00%gd%x00%gs",
|
|
28
|
+
`-n`,
|
|
29
|
+
String(maxEntries),
|
|
30
|
+
];
|
|
31
|
+
const r = await spawnGitAsync(top, reflogArgs);
|
|
32
|
+
if (!r.ok) {
|
|
33
|
+
return {
|
|
34
|
+
error: ERROR_CODES.REFLOG_FAILED,
|
|
35
|
+
detail: (r.stderr || r.stdout || "git reflog failed").trim(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const entries = [];
|
|
39
|
+
const lines = r.stdout.split("\n");
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (!line.trim())
|
|
42
|
+
continue;
|
|
43
|
+
const parts = line.split("\x00");
|
|
44
|
+
// Expect 4 fields: shaShort, shaFull, selector, message
|
|
45
|
+
if (parts.length < 4)
|
|
46
|
+
continue;
|
|
47
|
+
const [, shaFull, selector, message] = parts;
|
|
48
|
+
if (!shaFull)
|
|
49
|
+
continue;
|
|
50
|
+
entries.push({
|
|
51
|
+
sha: shaFull.trim(),
|
|
52
|
+
selector: (selector ?? "").trim(),
|
|
53
|
+
message: (message ?? "").trim(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return { ref, entries };
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Markdown rendering
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
function renderReflogMarkdown(result) {
|
|
62
|
+
const lines = [];
|
|
63
|
+
lines.push(`## Reflog (${result.ref})`);
|
|
64
|
+
lines.push("");
|
|
65
|
+
if (result.entries.length === 0) {
|
|
66
|
+
lines.push("_(no reflog entries)_");
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
for (const entry of result.entries) {
|
|
70
|
+
lines.push(`${entry.selector} ${entry.sha.slice(0, 7)} ${entry.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return lines.join("\n");
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Tool registration
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
export function registerGitReflogTool(server) {
|
|
79
|
+
server.addTool({
|
|
80
|
+
name: "git_reflog",
|
|
81
|
+
description: "Show the reflog for a ref (default HEAD). Returns a list of recent HEAD movements with selector (e.g. HEAD@{0}), full SHA, and message. Useful for recovering lost commits or inspecting reset/checkout history.",
|
|
82
|
+
annotations: {
|
|
83
|
+
readOnlyHint: true,
|
|
84
|
+
},
|
|
85
|
+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
|
|
86
|
+
.pick({
|
|
87
|
+
workspaceRoot: true,
|
|
88
|
+
rootIndex: true,
|
|
89
|
+
format: true,
|
|
90
|
+
})
|
|
91
|
+
.extend({
|
|
92
|
+
ref: z
|
|
93
|
+
.string()
|
|
94
|
+
.optional()
|
|
95
|
+
.default("HEAD")
|
|
96
|
+
.describe("Ref whose reflog to show (branch name, HEAD, etc.). Default: HEAD."),
|
|
97
|
+
maxEntries: z
|
|
98
|
+
.number()
|
|
99
|
+
.int()
|
|
100
|
+
.min(1)
|
|
101
|
+
.max(MAX_ENTRIES_HARD_CAP)
|
|
102
|
+
.optional()
|
|
103
|
+
.default(DEFAULT_MAX_ENTRIES)
|
|
104
|
+
.describe(`Maximum reflog entries to return (hard cap ${MAX_ENTRIES_HARD_CAP}). Default ${DEFAULT_MAX_ENTRIES}.`),
|
|
105
|
+
}),
|
|
106
|
+
execute: async (args) => {
|
|
107
|
+
const pre = requireSingleRepo(server, args);
|
|
108
|
+
if (!pre.ok)
|
|
109
|
+
return jsonRespond(pre.error);
|
|
110
|
+
const top = pre.gitTop;
|
|
111
|
+
const ref = args.ref ?? "HEAD";
|
|
112
|
+
if (!isSafeGitRefToken(ref)) {
|
|
113
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref });
|
|
114
|
+
}
|
|
115
|
+
const maxEntries = Math.min(args.maxEntries ?? DEFAULT_MAX_ENTRIES, MAX_ENTRIES_HARD_CAP);
|
|
116
|
+
const result = await runGitReflog({ top, ref, maxEntries });
|
|
117
|
+
if ("error" in result) {
|
|
118
|
+
return jsonRespond(result);
|
|
119
|
+
}
|
|
120
|
+
if (args.format === "json") {
|
|
121
|
+
return jsonRespond(result);
|
|
122
|
+
}
|
|
123
|
+
return renderReflogMarkdown(result);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
package/dist/server/git-refs.js
CHANGED
|
@@ -25,17 +25,24 @@ const PROTECTED_EXACT = new Set([
|
|
|
25
25
|
"trunk",
|
|
26
26
|
"prod",
|
|
27
27
|
"production",
|
|
28
|
-
"
|
|
28
|
+
"head",
|
|
29
29
|
]);
|
|
30
30
|
const PROTECTED_PATTERN = /^(release|hotfix)[-/].+$/i;
|
|
31
31
|
/** True when a branch name is on the protected list and must not be auto-deleted. */
|
|
32
32
|
export function isProtectedBranch(name) {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// Normalize: trim whitespace, strip leading refs/heads/ prefix, then lowercase.
|
|
34
|
+
const trimmed = name.trim();
|
|
35
|
+
if (trimmed === "")
|
|
35
36
|
return true;
|
|
36
|
-
|
|
37
|
+
const stripped = trimmed.startsWith("refs/heads/")
|
|
38
|
+
? trimmed.slice("refs/heads/".length)
|
|
39
|
+
: trimmed;
|
|
40
|
+
const normalized = stripped.toLowerCase();
|
|
41
|
+
if (normalized === "")
|
|
37
42
|
return true;
|
|
38
|
-
|
|
43
|
+
if (PROTECTED_EXACT.has(normalized))
|
|
44
|
+
return true;
|
|
45
|
+
return PROTECTED_PATTERN.test(normalized);
|
|
39
46
|
}
|
|
40
47
|
// ---------------------------------------------------------------------------
|
|
41
48
|
// Ref/branch name validation (argv-safe subset of git's ref-format rules)
|
|
@@ -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 { isSafeGitAncestorRef, isWorkingTreeClean } from "./git-refs.js";
|
|
4
5
|
import { jsonRespond, spreadDefined } from "./json.js";
|
|
@@ -30,12 +31,12 @@ export function registerGitResetSoftTool(server) {
|
|
|
30
31
|
const { gitTop } = pre;
|
|
31
32
|
// Validate ref — allow ancestor notation (~N, ^N).
|
|
32
33
|
if (!isSafeGitAncestorRef(args.ref)) {
|
|
33
|
-
return jsonRespond({ error:
|
|
34
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
|
|
34
35
|
}
|
|
35
36
|
// Refuse when the working tree is dirty (unstaged or untracked changes).
|
|
36
37
|
if (!(await isWorkingTreeClean(gitTop))) {
|
|
37
38
|
return jsonRespond({
|
|
38
|
-
error:
|
|
39
|
+
error: ERROR_CODES.WORKING_TREE_DIRTY,
|
|
39
40
|
detail: "git_reset_soft requires a clean working tree. " +
|
|
40
41
|
"Commit or stash pending changes first.",
|
|
41
42
|
});
|
|
@@ -47,7 +48,7 @@ export function registerGitResetSoftTool(server) {
|
|
|
47
48
|
const r = await spawnGitAsync(gitTop, ["reset", "--soft", args.ref]);
|
|
48
49
|
if (!r.ok) {
|
|
49
50
|
return jsonRespond({
|
|
50
|
-
error:
|
|
51
|
+
error: ERROR_CODES.RESET_FAILED,
|
|
51
52
|
detail: (r.stderr || r.stdout).trim(),
|
|
52
53
|
});
|
|
53
54
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
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 { spawnGitAsync } from "./git.js";
|
|
5
|
+
import { isSafeGitAncestorRef } from "./git-refs.js";
|
|
3
6
|
import { jsonRespond } from "./json.js";
|
|
4
7
|
import { requireSingleRepo } from "./roots.js";
|
|
5
8
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -7,22 +10,35 @@ import { WorkspacePickSchema } from "./schemas.js";
|
|
|
7
10
|
// Helpers
|
|
8
11
|
// ---------------------------------------------------------------------------
|
|
9
12
|
/**
|
|
10
|
-
* Run git show for a single ref, optionally limiting to
|
|
11
|
-
*
|
|
13
|
+
* Run git show for a single ref, optionally limiting to specific paths and/or
|
|
14
|
+
* showing only the --stat diffstat rather than the full patch.
|
|
15
|
+
* Returns commit message and diff/stat output.
|
|
12
16
|
*/
|
|
13
17
|
async function runGitShow(opts) {
|
|
14
|
-
const { top, ref, path } = opts;
|
|
15
|
-
//
|
|
16
|
-
const
|
|
17
|
-
if (path)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
const { top, ref, path, paths, stat } = opts;
|
|
19
|
+
// Merge single path + paths array into a unified list (deduped, order preserved).
|
|
20
|
+
const effectivePaths = [];
|
|
21
|
+
if (path)
|
|
22
|
+
effectivePaths.push(path);
|
|
23
|
+
if (paths) {
|
|
24
|
+
for (const p of paths) {
|
|
25
|
+
if (!effectivePaths.includes(p))
|
|
26
|
+
effectivePaths.push(p);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Build git show args. Shows commit message + full diff (or --stat diffstat).
|
|
30
|
+
const showArgs = ["show"];
|
|
31
|
+
if (stat) {
|
|
32
|
+
showArgs.push("--stat");
|
|
33
|
+
}
|
|
34
|
+
showArgs.push(ref);
|
|
35
|
+
if (effectivePaths.length > 0) {
|
|
36
|
+
showArgs.push("--", ...effectivePaths);
|
|
21
37
|
}
|
|
22
38
|
const r = await spawnGitAsync(top, showArgs);
|
|
23
39
|
if (!r.ok) {
|
|
24
40
|
return {
|
|
25
|
-
error:
|
|
41
|
+
error: ERROR_CODES.GIT_SHOW_FAILED,
|
|
26
42
|
};
|
|
27
43
|
}
|
|
28
44
|
// Parse the output. For a commit, git show outputs:
|
|
@@ -30,10 +46,9 @@ async function runGitShow(opts) {
|
|
|
30
46
|
// - Blank line
|
|
31
47
|
// - Commit message (may contain multiple lines and blank lines)
|
|
32
48
|
// - Blank line (separator before diff)
|
|
33
|
-
// - Diff
|
|
49
|
+
// - Diff or --stat diffstat section
|
|
34
50
|
const output = r.stdout;
|
|
35
51
|
let message = "";
|
|
36
|
-
let diff = "";
|
|
37
52
|
const lines = output.split("\n");
|
|
38
53
|
let inHeader = true;
|
|
39
54
|
let inMessage = false;
|
|
@@ -48,9 +63,14 @@ async function runGitShow(opts) {
|
|
|
48
63
|
inMessage = true;
|
|
49
64
|
continue;
|
|
50
65
|
}
|
|
51
|
-
// In message: collect until we see "diff --git" which marks the start of the diff section
|
|
52
66
|
if (inMessage) {
|
|
53
|
-
|
|
67
|
+
// In stat mode: content starts at the first line that looks like a stat entry
|
|
68
|
+
// (indented file path) or the summary line "N files changed".
|
|
69
|
+
// In diff mode: content starts at "diff --git".
|
|
70
|
+
const isStatLine = stat &&
|
|
71
|
+
(line.match(/^\s+\S.*\|/) !== null || line.match(/^\s*\d+ files? changed/) !== null);
|
|
72
|
+
const isDiffLine = !stat && line.startsWith("diff --git");
|
|
73
|
+
if (isStatLine || isDiffLine) {
|
|
54
74
|
inMessage = false;
|
|
55
75
|
contentLines.push(line);
|
|
56
76
|
}
|
|
@@ -64,16 +84,26 @@ async function runGitShow(opts) {
|
|
|
64
84
|
}
|
|
65
85
|
}
|
|
66
86
|
message = messageLines.join("\n").trim();
|
|
67
|
-
|
|
87
|
+
const contentStr = contentLines.join("\n").trim();
|
|
68
88
|
const result = {
|
|
69
89
|
ref,
|
|
70
90
|
message,
|
|
71
91
|
};
|
|
72
|
-
|
|
92
|
+
// Reflect single legacy path (for backward-compat) and new paths[] in result.
|
|
93
|
+
if (path && !paths) {
|
|
73
94
|
result.path = path;
|
|
74
95
|
}
|
|
75
|
-
if (
|
|
76
|
-
result.
|
|
96
|
+
else if (effectivePaths.length > 0) {
|
|
97
|
+
result.paths = effectivePaths;
|
|
98
|
+
}
|
|
99
|
+
if (stat) {
|
|
100
|
+
result.stat = true;
|
|
101
|
+
if (contentStr) {
|
|
102
|
+
result.statOutput = contentStr;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else if (contentStr) {
|
|
106
|
+
result.diff = contentStr;
|
|
77
107
|
}
|
|
78
108
|
return result;
|
|
79
109
|
}
|
|
@@ -86,13 +116,27 @@ function renderShowMarkdown(result) {
|
|
|
86
116
|
if (result.path) {
|
|
87
117
|
lines.push(`_path: ${result.path}_`);
|
|
88
118
|
}
|
|
119
|
+
else if (result.paths && result.paths.length > 0) {
|
|
120
|
+
lines.push(`_paths: ${result.paths.join(", ")}_`);
|
|
121
|
+
}
|
|
122
|
+
if (result.stat) {
|
|
123
|
+
lines.push("_mode: stat_");
|
|
124
|
+
}
|
|
89
125
|
lines.push("");
|
|
90
126
|
lines.push("## Commit message");
|
|
91
127
|
lines.push("");
|
|
92
128
|
lines.push("```");
|
|
93
129
|
lines.push(result.message);
|
|
94
130
|
lines.push("```");
|
|
95
|
-
if (result.
|
|
131
|
+
if (result.stat && result.statOutput) {
|
|
132
|
+
lines.push("");
|
|
133
|
+
lines.push("## Stat");
|
|
134
|
+
lines.push("");
|
|
135
|
+
lines.push("```");
|
|
136
|
+
lines.push(result.statOutput);
|
|
137
|
+
lines.push("```");
|
|
138
|
+
}
|
|
139
|
+
else if (result.diff) {
|
|
96
140
|
lines.push("");
|
|
97
141
|
lines.push("## Diff");
|
|
98
142
|
lines.push("");
|
|
@@ -108,7 +152,7 @@ function renderShowMarkdown(result) {
|
|
|
108
152
|
export function registerGitShowTool(server) {
|
|
109
153
|
server.addTool({
|
|
110
154
|
name: "git_show",
|
|
111
|
-
description: "Inspect commit content by ref/SHA. Returns commit message and diff (or
|
|
155
|
+
description: "Inspect commit content by ref/SHA. Returns commit message and diff (or --stat diffstat when stat:true). Optionally filter to specific paths via path or paths[].",
|
|
112
156
|
annotations: {
|
|
113
157
|
readOnlyHint: true,
|
|
114
158
|
},
|
|
@@ -119,21 +163,51 @@ export function registerGitShowTool(server) {
|
|
|
119
163
|
format: true,
|
|
120
164
|
})
|
|
121
165
|
.extend({
|
|
122
|
-
ref: z
|
|
166
|
+
ref: z
|
|
167
|
+
.string()
|
|
168
|
+
.min(1)
|
|
169
|
+
.describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
|
|
123
170
|
path: z
|
|
124
171
|
.string()
|
|
125
172
|
.optional()
|
|
126
|
-
.describe("Optional file path to inspect at the ref.
|
|
173
|
+
.describe("Optional single file path to inspect at the ref. Merged with `paths` when both are provided."),
|
|
174
|
+
paths: z
|
|
175
|
+
.array(z.string())
|
|
176
|
+
.optional()
|
|
177
|
+
.describe("Optional list of file paths to filter the shown diff/stat. Merged with `path` when both are provided."),
|
|
178
|
+
stat: z
|
|
179
|
+
.boolean()
|
|
180
|
+
.optional()
|
|
181
|
+
.describe("When true, show --stat diffstat (files changed summary) instead of the full patch."),
|
|
127
182
|
}),
|
|
128
183
|
execute: async (args) => {
|
|
129
184
|
const pre = requireSingleRepo(server, args);
|
|
130
185
|
if (!pre.ok)
|
|
131
186
|
return jsonRespond(pre.error);
|
|
132
187
|
const top = pre.gitTop;
|
|
188
|
+
if (!isSafeGitAncestorRef(args.ref)) {
|
|
189
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
|
|
190
|
+
}
|
|
191
|
+
if (args.path !== undefined) {
|
|
192
|
+
const resolved = resolvePathForRepo(args.path, top);
|
|
193
|
+
if (!assertRelativePathUnderTop(args.path, resolved, top)) {
|
|
194
|
+
return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: args.path });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (Array.isArray(args.paths)) {
|
|
198
|
+
for (const p of args.paths) {
|
|
199
|
+
const resolved = resolvePathForRepo(p, top);
|
|
200
|
+
if (!assertRelativePathUnderTop(p, resolved, top)) {
|
|
201
|
+
return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: p });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
133
205
|
const result = await runGitShow({
|
|
134
206
|
top,
|
|
135
207
|
ref: args.ref,
|
|
136
208
|
path: args.path,
|
|
209
|
+
paths: args.paths,
|
|
210
|
+
stat: args.stat,
|
|
137
211
|
});
|
|
138
212
|
if ("error" in result) {
|
|
139
213
|
return jsonRespond(result);
|