@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.
Files changed (40) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +21 -0
  3. package/HUMANS.md +1 -1
  4. package/README.md +1 -1
  5. package/dist/server/batch-commit-tool.js +16 -20
  6. package/dist/server/error-codes.js +95 -0
  7. package/dist/server/git-blame-tool.js +227 -0
  8. package/dist/server/git-branch-list-tool.js +138 -0
  9. package/dist/server/git-cherry-pick-tool.js +22 -31
  10. package/dist/server/git-diff-summary-tool.js +6 -6
  11. package/dist/server/git-diff-tool.js +54 -18
  12. package/dist/server/git-fetch-tool.js +142 -14
  13. package/dist/server/git-inventory-tool.js +5 -4
  14. package/dist/server/git-log-tool.js +18 -19
  15. package/dist/server/git-merge-tool.js +21 -24
  16. package/dist/server/git-parity-tool.js +3 -2
  17. package/dist/server/git-push-tool.js +9 -5
  18. package/dist/server/git-reflog-tool.js +126 -0
  19. package/dist/server/git-reset-soft-tool.js +7 -9
  20. package/dist/server/git-show-tool.js +83 -23
  21. package/dist/server/git-stash-tool.js +5 -6
  22. package/dist/server/git-tag-tool.js +8 -7
  23. package/dist/server/git-worktree-tool.js +14 -16
  24. package/dist/server/git.js +70 -4
  25. package/dist/server/list-presets-tool.js +2 -1
  26. package/dist/server/presets-resource.js +3 -2
  27. package/dist/server/presets.js +11 -5
  28. package/dist/server/roots.js +11 -10
  29. package/dist/server/schemas.js +4 -4
  30. package/dist/server/tool-parameter-schemas.js +9 -0
  31. package/dist/server/tools.js +6 -0
  32. package/docs/mcp-tools.md +120 -7
  33. package/package.json +10 -13
  34. package/schemas/git_blame.json +52 -0
  35. package/schemas/git_branch_list.json +36 -0
  36. package/schemas/git_diff.json +14 -1
  37. package/schemas/git_reflog.json +44 -0
  38. package/schemas/git_show.json +12 -1
  39. package/schemas/index.json +15 -0
  40. 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: "git_log_failed",
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: "Path-filtered, time-windowed read-only `git log` across one or more workspace roots. " +
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
- "`json`: structured groups array. " +
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 `git log --since=`. Accepts ISO timestamps or git relative forms like " +
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 (passed as `-- <paths>`)."),
197
+ .describe("Limit to commits touching these paths (`-- <paths>`)."),
200
198
  grep: z
201
199
  .string()
202
200
  .optional()
203
- .describe("Filter commits whose message matches this regex (git `--grep`, case-insensitive)."),
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(`Maximum commits to return per root (hard cap ${MAX_COMMITS_HARD_CAP}). Default ${DEFAULT_MAX_COMMITS}.`),
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: "invalid_since", since: rawSince });
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: "invalid_paths", path: p });
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: "unsafe_ref_token", branch: args.branch });
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 { _error: true, workspaceRoot: rootInput, error: "not_a_git_repository" };
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: "source_not_found" };
40
+ return { source, ok: false, error: ERROR_CODES.SOURCE_NOT_FOUND };
40
41
  }
41
42
  if (!intoSha) {
42
- return { source, ok: false, error: "destination_not_found" };
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: "merge_base_failed",
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: "cannot_fast_forward",
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: "merge_failed",
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: "merge_conflicts",
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: "rebase_conflicts",
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: "checkout_failed",
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. Default strategy `auto` " +
210
- "cascades fast-forward → rebase → merge-commit per source, preferring linear history. " +
211
- "Refuses if the working tree is dirty. Stops on the first conflict and reports " +
212
- "the affected paths. Optional flags auto-delete merged branches and worktrees, " +
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("After all sources merge cleanly, delete each source branch locally (`git branch -d`). " +
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("After all sources merge cleanly, remove any local worktree currently checked out " +
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: "unsafe_ref_token", ref: s });
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: "unsafe_ref_token", ref: args.into });
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: "into_detached_head" });
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: "working_tree_dirty" });
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: "checkout_failed",
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: "destination_not_found", ref: into });
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: "not_a_git_repository", path: workspaceRoot };
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: "no_pairs" });
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: "push_detached_head" });
46
+ return jsonRespond({ error: ERROR_CODES.PUSH_DETACHED_HEAD });
46
47
  }
47
48
  if (!isSafeGitRefToken(branch)) {
48
- return jsonRespond({ error: "unsafe_ref_token", ref: branch });
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({ error: "unsafe_remote_token", remote: args.remote.trim() });
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: "push_no_upstream",
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: "push_failed",
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: "Soft-reset the current branch to a reference (`git reset --soft <ref>`). " +
11
- "Moves the branch pointer back while keeping all changes from the rewound commits " +
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. Accepts ancestor notation (`HEAD~1`, `HEAD~3`), " +
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: "unsafe_ref_token", ref: args.ref });
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: "working_tree_dirty",
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: "reset_failed",
48
+ error: ERROR_CODES.RESET_FAILED,
51
49
  detail: (r.stderr || r.stdout).trim(),
52
50
  });
53
51
  }