@rethunk/mcp-multi-root-git 2.6.0 → 2.8.1
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 +2 -2
- package/CHANGELOG.md +21 -0
- package/HUMANS.md +1 -1
- package/README.md +1 -1
- package/dist/server/batch-commit-tool.js +16 -20
- 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 +138 -0
- package/dist/server/git-cherry-pick-tool.js +22 -31
- package/dist/server/git-diff-summary-tool.js +6 -6
- package/dist/server/git-diff-tool.js +54 -18
- 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 +18 -19
- package/dist/server/git-merge-tool.js +21 -24
- 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 +7 -9
- package/dist/server/git-show-tool.js +83 -23
- package/dist/server/git-stash-tool.js +5 -6
- package/dist/server/git-tag-tool.js +8 -7
- package/dist/server/git-worktree-tool.js +14 -16
- 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/schemas.js +4 -4
- package/dist/server/tool-parameter-schemas.js +9 -0
- package/dist/server/tools.js +6 -0
- package/docs/mcp-tools.md +120 -7
- package/package.json +10 -13
- 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 +236 -86
|
@@ -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
|
}
|
|
@@ -175,8 +176,7 @@ function renderLogMarkdown(group, filterSummary) {
|
|
|
175
176
|
export function registerGitLogTool(server) {
|
|
176
177
|
server.addTool({
|
|
177
178
|
name: "git_log",
|
|
178
|
-
description: "
|
|
179
|
-
"Returns structured commit history with author, date, subject, and optional diff stats.",
|
|
179
|
+
description: "Read-only `git log` across one or more roots. Returns author, date, subject, optional diff stats.",
|
|
180
180
|
annotations: {
|
|
181
181
|
readOnlyHint: true,
|
|
182
182
|
},
|
|
@@ -185,26 +185,21 @@ export function registerGitLogTool(server) {
|
|
|
185
185
|
.enum(["markdown", "json", "oneline"])
|
|
186
186
|
.optional()
|
|
187
187
|
.default("markdown")
|
|
188
|
-
.describe("`markdown` (default): headed sections per root. " +
|
|
189
|
-
"`
|
|
190
|
-
"`oneline`: `<sha7> <subject>` per line, no headers (single-root) or `### repo (branch)` separator per group (multi-root). Lowest-token option for post-commit verification."),
|
|
188
|
+
.describe("`markdown` (default): headed sections per root. `json`: groups array. " +
|
|
189
|
+
"`oneline`: `<sha7> <subject>` per line; lowest-token option for post-commit verification."),
|
|
191
190
|
since: z
|
|
192
191
|
.string()
|
|
193
192
|
.optional()
|
|
194
|
-
.describe("Passed to
|
|
195
|
-
"`48.hours` or `2.weeks.ago`. Default: `7.days`."),
|
|
193
|
+
.describe("Passed to `--since=`. ISO timestamp or git relative form (`48.hours`, `2.weeks.ago`). Default: `7.days`."),
|
|
196
194
|
paths: z
|
|
197
195
|
.array(z.string())
|
|
198
196
|
.optional()
|
|
199
|
-
.describe("Limit to commits touching these paths (
|
|
197
|
+
.describe("Limit to commits touching these paths (`-- <paths>`)."),
|
|
200
198
|
grep: z
|
|
201
199
|
.string()
|
|
202
200
|
.optional()
|
|
203
|
-
.describe("Filter
|
|
204
|
-
author: z
|
|
205
|
-
.string()
|
|
206
|
-
.optional()
|
|
207
|
-
.describe("Filter by author name or email (passed as `--author=`)."),
|
|
201
|
+
.describe("Filter by commit message regex (git `--grep`, case-insensitive)."),
|
|
202
|
+
author: z.string().optional().describe("Filter by author name or email (`--author=`)."),
|
|
208
203
|
maxCommits: z
|
|
209
204
|
.number()
|
|
210
205
|
.int()
|
|
@@ -212,7 +207,7 @@ export function registerGitLogTool(server) {
|
|
|
212
207
|
.max(MAX_COMMITS_HARD_CAP)
|
|
213
208
|
.optional()
|
|
214
209
|
.default(DEFAULT_MAX_COMMITS)
|
|
215
|
-
.describe(`
|
|
210
|
+
.describe(`Max commits per root (hard cap ${MAX_COMMITS_HARD_CAP}, default ${DEFAULT_MAX_COMMITS}).`),
|
|
216
211
|
branch: z.string().optional().describe("Ref/branch to log from. Default: HEAD."),
|
|
217
212
|
}),
|
|
218
213
|
execute: async (args) => {
|
|
@@ -222,19 +217,19 @@ export function registerGitLogTool(server) {
|
|
|
222
217
|
// Validate `since` — reject obvious injection attempts (newlines, semicolons, shell chars).
|
|
223
218
|
const rawSince = (args.since?.trim() ?? DEFAULT_SINCE) || DEFAULT_SINCE;
|
|
224
219
|
if (/[\n\r;|&`$<>]/.test(rawSince)) {
|
|
225
|
-
return jsonRespond({ error:
|
|
220
|
+
return jsonRespond({ error: ERROR_CODES.INVALID_SINCE, since: rawSince });
|
|
226
221
|
}
|
|
227
222
|
// Validate paths — reject anything with null bytes or shell meta.
|
|
228
223
|
// Use charCodeAt(0) === 0 for the null byte to avoid a biome lint on control chars in regex.
|
|
229
224
|
const rawPaths = args.paths ?? [];
|
|
230
225
|
for (const p of rawPaths) {
|
|
231
226
|
if (p.split("").some((c) => c.charCodeAt(0) === 0) || /[\n\r;|&`$<>]/.test(p)) {
|
|
232
|
-
return jsonRespond({ error:
|
|
227
|
+
return jsonRespond({ error: ERROR_CODES.INVALID_PATHS, path: p });
|
|
233
228
|
}
|
|
234
229
|
}
|
|
235
230
|
// Validate branch — reject leading-dash and other injection attempts.
|
|
236
231
|
if (args.branch && !isSafeGitAncestorRef(args.branch)) {
|
|
237
|
-
return jsonRespond({ error:
|
|
232
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, branch: args.branch });
|
|
238
233
|
}
|
|
239
234
|
const maxCommits = Math.min(args.maxCommits ?? DEFAULT_MAX_COMMITS, MAX_COMMITS_HARD_CAP);
|
|
240
235
|
// Fan out across roots.
|
|
@@ -242,7 +237,11 @@ export function registerGitLogTool(server) {
|
|
|
242
237
|
const results = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
|
|
243
238
|
const top = gitTopLevel(rootInput);
|
|
244
239
|
if (!top) {
|
|
245
|
-
return {
|
|
240
|
+
return {
|
|
241
|
+
_error: true,
|
|
242
|
+
workspaceRoot: rootInput,
|
|
243
|
+
error: ERROR_CODES.NOT_A_GIT_REPOSITORY,
|
|
244
|
+
};
|
|
246
245
|
}
|
|
247
246
|
const r = await runGitLog({
|
|
248
247
|
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
|
}
|
|
@@ -206,12 +207,10 @@ async function maybeDeleteBranch(gitTop, source, enabled, into) {
|
|
|
206
207
|
export function registerGitMergeTool(server) {
|
|
207
208
|
server.addTool({
|
|
208
209
|
name: "git_merge",
|
|
209
|
-
description: "Merge one or more source branches into a destination.
|
|
210
|
-
"
|
|
211
|
-
"Refuses
|
|
212
|
-
"
|
|
213
|
-
"skipping protected names (main, master, dev, develop, stable, trunk, prod, " +
|
|
214
|
-
"production, release/*, hotfix/*).",
|
|
210
|
+
description: "Merge one or more source branches into a destination. `auto` cascades " +
|
|
211
|
+
"fast-forward → rebase → merge-commit, preferring linear history. " +
|
|
212
|
+
"Refuses on dirty tree; stops on first conflict. Optional flags delete merged branches/worktrees " +
|
|
213
|
+
"(protected names skipped: main, master, dev, develop, stable, trunk, prod, production, release/*, hotfix/*).",
|
|
215
214
|
annotations: {
|
|
216
215
|
readOnlyHint: false,
|
|
217
216
|
destructiveHint: false,
|
|
@@ -236,14 +235,12 @@ export function registerGitMergeTool(server) {
|
|
|
236
235
|
.boolean()
|
|
237
236
|
.optional()
|
|
238
237
|
.default(false)
|
|
239
|
-
.describe("
|
|
240
|
-
"Protected names always skipped. Never affects remote branches."),
|
|
238
|
+
.describe("Delete each source branch locally after clean merge (`git branch -d`). Protected names and remote refs unaffected."),
|
|
241
239
|
deleteMergedWorktrees: z
|
|
242
240
|
.boolean()
|
|
243
241
|
.optional()
|
|
244
242
|
.default(false)
|
|
245
|
-
.describe("
|
|
246
|
-
"on a source branch (`git worktree remove`). Protected tails always skipped."),
|
|
243
|
+
.describe("Remove local worktrees on source branches after clean merge (`git worktree remove`). Protected tails skipped."),
|
|
247
244
|
}),
|
|
248
245
|
execute: async (args) => {
|
|
249
246
|
const pre = requireSingleRepo(server, args);
|
|
@@ -253,28 +250,28 @@ export function registerGitMergeTool(server) {
|
|
|
253
250
|
// --- Validate ref tokens early ---
|
|
254
251
|
for (const s of args.sources) {
|
|
255
252
|
if (!isSafeGitRefToken(s)) {
|
|
256
|
-
return jsonRespond({ error:
|
|
253
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: s });
|
|
257
254
|
}
|
|
258
255
|
}
|
|
259
256
|
if (args.into !== undefined && !isSafeGitRefToken(args.into)) {
|
|
260
|
-
return jsonRespond({ error:
|
|
257
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.into });
|
|
261
258
|
}
|
|
262
259
|
// --- Resolve destination ---
|
|
263
260
|
const startBranch = await getCurrentBranch(gitTop);
|
|
264
261
|
const into = args.into?.trim() || startBranch;
|
|
265
262
|
if (!into) {
|
|
266
|
-
return jsonRespond({ error:
|
|
263
|
+
return jsonRespond({ error: ERROR_CODES.INTO_DETACHED_HEAD });
|
|
267
264
|
}
|
|
268
265
|
// --- Refuse dirty tree ---
|
|
269
266
|
if (!(await isWorkingTreeClean(gitTop))) {
|
|
270
|
-
return jsonRespond({ error:
|
|
267
|
+
return jsonRespond({ error: ERROR_CODES.WORKING_TREE_DIRTY });
|
|
271
268
|
}
|
|
272
269
|
// --- Ensure destination is checked out ---
|
|
273
270
|
if (into !== startBranch) {
|
|
274
271
|
const co = await spawnGitAsync(gitTop, ["checkout", into]);
|
|
275
272
|
if (!co.ok) {
|
|
276
273
|
return jsonRespond({
|
|
277
|
-
error:
|
|
274
|
+
error: ERROR_CODES.CHECKOUT_FAILED,
|
|
278
275
|
detail: (co.stderr || co.stdout).trim(),
|
|
279
276
|
});
|
|
280
277
|
}
|
|
@@ -282,7 +279,7 @@ export function registerGitMergeTool(server) {
|
|
|
282
279
|
// Verify destination exists after checkout.
|
|
283
280
|
const intoShaProbe = await resolveRef(gitTop, into);
|
|
284
281
|
if (!intoShaProbe) {
|
|
285
|
-
return jsonRespond({ error:
|
|
282
|
+
return jsonRespond({ error: ERROR_CODES.DESTINATION_NOT_FOUND, ref: into });
|
|
286
283
|
}
|
|
287
284
|
// --- Merge each source sequentially ---
|
|
288
285
|
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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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";
|
|
@@ -7,10 +8,8 @@ import { WorkspacePickSchema } from "./schemas.js";
|
|
|
7
8
|
export function registerGitResetSoftTool(server) {
|
|
8
9
|
server.addTool({
|
|
9
10
|
name: "git_reset_soft",
|
|
10
|
-
description: "
|
|
11
|
-
"
|
|
12
|
-
"in the staging index — use this to re-split an already-committed chunk. " +
|
|
13
|
-
"Refuses when the working tree has any uncommitted or unstaged changes (run on a clean tree).",
|
|
11
|
+
description: "`git reset --soft <ref>`: moves HEAD back while keeping rewound changes staged. " +
|
|
12
|
+
"Use to re-split committed work. Refuses on a dirty tree.",
|
|
14
13
|
annotations: {
|
|
15
14
|
readOnlyHint: false,
|
|
16
15
|
destructiveHint: false,
|
|
@@ -20,8 +19,7 @@ export function registerGitResetSoftTool(server) {
|
|
|
20
19
|
ref: z
|
|
21
20
|
.string()
|
|
22
21
|
.min(1)
|
|
23
|
-
.describe("Commit to reset to
|
|
24
|
-
"branch names, or full SHAs."),
|
|
22
|
+
.describe("Commit to reset to: ancestor notation (`HEAD~1`, `HEAD~3`), branch name, or SHA."),
|
|
25
23
|
}),
|
|
26
24
|
execute: async (args) => {
|
|
27
25
|
const pre = requireSingleRepo(server, args);
|
|
@@ -30,12 +28,12 @@ export function registerGitResetSoftTool(server) {
|
|
|
30
28
|
const { gitTop } = pre;
|
|
31
29
|
// Validate ref — allow ancestor notation (~N, ^N).
|
|
32
30
|
if (!isSafeGitAncestorRef(args.ref)) {
|
|
33
|
-
return jsonRespond({ error:
|
|
31
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
|
|
34
32
|
}
|
|
35
33
|
// Refuse when the working tree is dirty (unstaged or untracked changes).
|
|
36
34
|
if (!(await isWorkingTreeClean(gitTop))) {
|
|
37
35
|
return jsonRespond({
|
|
38
|
-
error:
|
|
36
|
+
error: ERROR_CODES.WORKING_TREE_DIRTY,
|
|
39
37
|
detail: "git_reset_soft requires a clean working tree. " +
|
|
40
38
|
"Commit or stash pending changes first.",
|
|
41
39
|
});
|
|
@@ -47,7 +45,7 @@ export function registerGitResetSoftTool(server) {
|
|
|
47
45
|
const r = await spawnGitAsync(gitTop, ["reset", "--soft", args.ref]);
|
|
48
46
|
if (!r.ok) {
|
|
49
47
|
return jsonRespond({
|
|
50
|
-
error:
|
|
48
|
+
error: ERROR_CODES.RESET_FAILED,
|
|
51
49
|
detail: (r.stderr || r.stdout).trim(),
|
|
52
50
|
});
|
|
53
51
|
}
|