@rethunk/mcp-multi-root-git 2.9.1 → 4.0.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.
Files changed (75) hide show
  1. package/AGENTS.md +23 -19
  2. package/CHANGELOG.md +145 -39
  3. package/HUMANS.md +23 -42
  4. package/README.md +30 -13
  5. package/dist/repo-paths.js +57 -13
  6. package/dist/server/batch-commit-tool.js +203 -53
  7. package/dist/server/error-codes.js +31 -16
  8. package/dist/server/git-blame-tool.js +66 -19
  9. package/dist/server/git-branch-tool.js +151 -0
  10. package/dist/server/git-cherry-pick-tool.js +221 -12
  11. package/dist/server/git-conflicts-tool.js +230 -0
  12. package/dist/server/git-diff-summary-tool.js +39 -28
  13. package/dist/server/git-diff-tool.js +56 -31
  14. package/dist/server/git-grep-tool.js +188 -0
  15. package/dist/server/git-inventory-tool.js +30 -10
  16. package/dist/server/git-log-tool.js +37 -6
  17. package/dist/server/git-merge-tool.js +71 -13
  18. package/dist/server/git-parity-tool.js +15 -6
  19. package/dist/server/git-push-tool.js +4 -2
  20. package/dist/server/git-refs.js +48 -17
  21. package/dist/server/git-reset-soft-tool.js +1 -1
  22. package/dist/server/git-revert-tool.js +160 -0
  23. package/dist/server/git-show-tool.js +5 -10
  24. package/dist/server/git-stash-tool.js +112 -78
  25. package/dist/server/git-status-tool.js +2 -2
  26. package/dist/server/git-tag-tool.js +8 -13
  27. package/dist/server/git-worktree-tool.js +67 -59
  28. package/dist/server/git.js +116 -24
  29. package/dist/server/inventory.js +90 -32
  30. package/dist/server/list-presets-tool.js +2 -8
  31. package/dist/server/presets-resource.js +37 -20
  32. package/dist/server/presets.js +87 -15
  33. package/dist/server/roots.js +52 -79
  34. package/dist/server/schemas.js +18 -19
  35. package/dist/server/test-harness.js +11 -4
  36. package/dist/server/tool-parameter-schemas.js +47 -58
  37. package/dist/server/tools.js +36 -17
  38. package/dist/server.js +1 -1
  39. package/docs/install.md +52 -5
  40. package/docs/mcp-tools.md +472 -284
  41. package/package.json +6 -6
  42. package/schemas/batch_commit.json +5 -17
  43. package/schemas/git_blame.json +13 -11
  44. package/schemas/git_branch.json +54 -0
  45. package/schemas/git_cherry_pick.json +13 -15
  46. package/schemas/git_cherry_pick_continue.json +34 -0
  47. package/schemas/git_conflicts.json +38 -0
  48. package/schemas/git_diff.json +12 -10
  49. package/schemas/git_diff_summary.json +1 -15
  50. package/schemas/git_grep.json +85 -0
  51. package/schemas/git_inventory.json +32 -23
  52. package/schemas/git_log.json +20 -24
  53. package/schemas/git_merge.json +3 -15
  54. package/schemas/git_parity.json +13 -23
  55. package/schemas/git_push.json +1 -13
  56. package/schemas/git_reset_soft.json +1 -13
  57. package/schemas/git_revert.json +47 -0
  58. package/schemas/git_show.json +2 -8
  59. package/schemas/git_stash_apply.json +2 -8
  60. package/schemas/git_stash_push.json +47 -0
  61. package/schemas/git_status.json +13 -23
  62. package/schemas/git_tag.json +1 -7
  63. package/schemas/git_worktree_add.json +2 -14
  64. package/schemas/git_worktree_remove.json +2 -14
  65. package/schemas/index.json +28 -23
  66. package/schemas/list_presets.json +13 -23
  67. package/tool-parameters.schema.json +407 -423
  68. package/dist/server/git-branch-list-tool.js +0 -138
  69. package/dist/server/git-fetch-tool.js +0 -266
  70. package/dist/server/git-reflog-tool.js +0 -126
  71. package/schemas/git_branch_list.json +0 -36
  72. package/schemas/git_fetch.json +0 -52
  73. package/schemas/git_reflog.json +0 -44
  74. package/schemas/git_stash_list.json +0 -30
  75. package/schemas/git_worktree_list.json +0 -30
@@ -0,0 +1,188 @@
1
+ import { basename } from "node:path";
2
+ import { z } from "zod";
3
+ import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
4
+ import { ERROR_CODES } from "./error-codes.js";
5
+ import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitTopLevel, spawnGitAsync } from "./git.js";
6
+ import { isSafeGitAncestorRef } from "./git-refs.js";
7
+ import { jsonRespond, spreadWhen } from "./json.js";
8
+ import { requireGitAndRoots } from "./roots.js";
9
+ import { RootPickSchema } from "./schemas.js";
10
+ // ---------------------------------------------------------------------------
11
+ // Constants
12
+ // ---------------------------------------------------------------------------
13
+ const MAX_MATCHES_HARD_CAP = 1000;
14
+ const DEFAULT_MAX_MATCHES = 200;
15
+ /** Run `git log -S` / `-G` pickaxe history search for a single repo root. */
16
+ async function runPickaxe(opts) {
17
+ const { top, mode, term, ref, paths, ignoreCase, maxMatches } = opts;
18
+ const args = [
19
+ "log",
20
+ "--pretty=format:%H%x01%s",
21
+ "-n",
22
+ String(maxMatches + 1),
23
+ mode === "S" ? "-S" : "-G",
24
+ term,
25
+ ];
26
+ // `-i` / `--regexp-ignore-case` affects `-G` (and `--grep`); harmless on `-S`.
27
+ if (ignoreCase)
28
+ args.push("-i");
29
+ if (ref)
30
+ args.push(ref);
31
+ if (paths.length > 0)
32
+ args.push("--", ...paths);
33
+ const r = await spawnGitAsync(top, args);
34
+ if (!r.ok) {
35
+ return {
36
+ error: ERROR_CODES.GIT_GREP_FAILED,
37
+ detail: r.stderr.trim() || r.stdout.trim(),
38
+ };
39
+ }
40
+ const commits = [];
41
+ for (const line of r.stdout.split("\n")) {
42
+ if (!line.trim())
43
+ continue;
44
+ const sep = line.indexOf("\x01");
45
+ if (sep < 0)
46
+ continue;
47
+ const sha = line.slice(0, sep).trim();
48
+ const subject = line.slice(sep + 1).trim();
49
+ if (sha)
50
+ commits.push({ sha, subject });
51
+ }
52
+ const truncated = commits.length > maxMatches;
53
+ return {
54
+ commits: truncated ? commits.slice(0, maxMatches) : commits,
55
+ truncated,
56
+ };
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Markdown rendering
60
+ // ---------------------------------------------------------------------------
61
+ function renderGrepMarkdown(group) {
62
+ const lines = [`### ${group.repo}`, `_root: ${group.root}_`, ""];
63
+ if (group.error) {
64
+ lines.push(`_error: ${group.error}${group.detail ? ` — ${group.detail}` : ""}_`);
65
+ return lines.join("\n");
66
+ }
67
+ const commits = group.commits ?? [];
68
+ if (commits.length === 0) {
69
+ lines.push("_(no pickaxe hits)_");
70
+ }
71
+ else {
72
+ for (const c of commits) {
73
+ lines.push(`- \`${c.sha.slice(0, 7)}\` ${c.subject}`);
74
+ }
75
+ }
76
+ if (group.truncated) {
77
+ lines.push("", "_(truncated — raise `maxMatches` for the full result set)_");
78
+ }
79
+ return lines.join("\n");
80
+ }
81
+ // ---------------------------------------------------------------------------
82
+ // Tool registration
83
+ // ---------------------------------------------------------------------------
84
+ export function registerGitGrepTool(server) {
85
+ server.addTool({
86
+ name: "git_grep",
87
+ description: "Pickaxe history search across one or more roots: which commits added or removed a term " +
88
+ '(`git log -S`) or changed lines matching a regex (`git log -G`), via `pickaxe: { mode: "S"|"G", term }`. ' +
89
+ "Returns `commits[]` (sha + subject) per root. Set `ref` to limit history to that tip. " +
90
+ "For working-tree content search use the client's native grep/rg tooling instead — " +
91
+ "content mode was removed in v6.",
92
+ annotations: {
93
+ readOnlyHint: true,
94
+ },
95
+ parameters: RootPickSchema.extend({
96
+ pickaxe: z
97
+ .object({
98
+ mode: z
99
+ .enum(["S", "G"])
100
+ .describe("`S` = pickaxe string (`git log -S`); `G` = pickaxe regex (`git log -G`)."),
101
+ term: z.string().min(1).max(500).describe("Search term / regex for pickaxe history."),
102
+ })
103
+ .describe("Pickaxe history search. Returns `commits[]` (sha + subject) per root."),
104
+ ref: z
105
+ .string()
106
+ .optional()
107
+ .describe("Commit/branch/tag to use as the history tip. Must be a safe ref token."),
108
+ paths: z
109
+ .array(z.string())
110
+ .optional()
111
+ .describe("Limit history to these paths (must resolve within the repo root)."),
112
+ ignoreCase: z
113
+ .boolean()
114
+ .optional()
115
+ .default(false)
116
+ .describe("Case-insensitive match (`-i`; affects `G` mode regexes)."),
117
+ maxMatches: z
118
+ .number()
119
+ .int()
120
+ .min(1)
121
+ .max(MAX_MATCHES_HARD_CAP)
122
+ .optional()
123
+ .default(DEFAULT_MAX_MATCHES)
124
+ .describe(`Max commits per root (hard cap ${MAX_MATCHES_HARD_CAP}, default ${DEFAULT_MAX_MATCHES}).`),
125
+ }),
126
+ execute: async (args) => {
127
+ const pre = requireGitAndRoots(server, args, undefined);
128
+ if (!pre.ok)
129
+ return jsonRespond(pre.error);
130
+ const pickaxe = args.pickaxe;
131
+ if (args.ref !== undefined && !isSafeGitAncestorRef(args.ref)) {
132
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
133
+ }
134
+ const ref = args.ref?.trim() || undefined;
135
+ const rawPaths = Array.isArray(args.paths) ? args.paths : [];
136
+ const ignoreCase = args.ignoreCase ?? false;
137
+ const maxMatches = Math.min(args.maxMatches ?? DEFAULT_MAX_MATCHES, MAX_MATCHES_HARD_CAP);
138
+ // Fan out across roots. Path confinement is per-root since each root has
139
+ // its own git toplevel.
140
+ const jobs = pre.roots.map((rootInput) => ({ rootInput }));
141
+ const groups = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
142
+ const top = gitTopLevel(rootInput);
143
+ if (!top) {
144
+ return {
145
+ root: rootInput,
146
+ repo: basename(rootInput),
147
+ error: ERROR_CODES.NOT_A_GIT_REPOSITORY,
148
+ detail: rootInput,
149
+ };
150
+ }
151
+ for (const p of rawPaths) {
152
+ const resolved = resolvePathForRepo(p, top);
153
+ if (!assertRelativePathUnderTop(p, resolved, top)) {
154
+ return {
155
+ root: top,
156
+ repo: basename(top),
157
+ error: ERROR_CODES.PATH_ESCAPES_REPO,
158
+ detail: p,
159
+ };
160
+ }
161
+ }
162
+ const result = await runPickaxe({
163
+ top,
164
+ mode: pickaxe.mode,
165
+ term: pickaxe.term,
166
+ ref,
167
+ paths: rawPaths,
168
+ ignoreCase,
169
+ maxMatches,
170
+ });
171
+ if ("error" in result) {
172
+ return { root: top, repo: basename(top), error: result.error, detail: result.detail };
173
+ }
174
+ return {
175
+ root: top,
176
+ repo: basename(top),
177
+ commits: result.commits,
178
+ ...spreadWhen(result.truncated, { truncated: true }),
179
+ };
180
+ });
181
+ if (args.format === "json") {
182
+ return jsonRespond({ results: groups });
183
+ }
184
+ const mdChunks = ["# git grep", ...groups.map((g) => renderGrepMarkdown(g))];
185
+ return mdChunks.join("\n\n");
186
+ },
187
+ });
188
+ }
@@ -1,19 +1,20 @@
1
1
  import { z } from "zod";
2
2
  import { ERROR_CODES } from "./error-codes.js";
3
3
  import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitRevParseGitDir, gitTopLevel, isSafeGitUpstreamToken, } from "./git.js";
4
+ import { isSafeGitAncestorRef } from "./git-refs.js";
4
5
  import { buildInventorySectionMarkdown, collectInventoryEntry, makeSkipEntry, validateRepoPath, } from "./inventory.js";
5
6
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
6
7
  import { applyPresetNestedRoots } from "./presets.js";
7
8
  import { requireGitAndRoots } from "./roots.js";
8
- import { MAX_INVENTORY_ROOTS_DEFAULT, WorkspacePickSchema } from "./schemas.js";
9
+ import { MAX_INVENTORY_ROOTS_DEFAULT, RootPickSchema } from "./schemas.js";
9
10
  export function registerGitInventoryTool(server) {
10
11
  server.addTool({
11
12
  name: "git_inventory",
12
- description: "Read-only status + ahead/behind per root.",
13
+ description: "Read-only status + ahead/behind per root. Optional `compareRefs` adds ahead/behind between two local refs (independent of upstream).",
13
14
  annotations: {
14
15
  readOnlyHint: true,
15
16
  },
16
- parameters: WorkspacePickSchema.extend({
17
+ parameters: RootPickSchema.extend({
17
18
  nestedRoots: z.array(z.string()).optional(),
18
19
  preset: z.string().optional(),
19
20
  presetMerge: z
@@ -23,12 +24,19 @@ export function registerGitInventoryTool(server) {
23
24
  .describe("Merge with preset instead of replacing."),
24
25
  remote: z.string().optional().describe("Pair with `branch`."),
25
26
  branch: z.string().optional().describe("Pair with `remote`."),
27
+ compareRefs: z
28
+ .object({
29
+ left: z.string().describe("Base ref (ahead = commits in right not in left)."),
30
+ right: z.string().describe("Other ref (behind = commits in left not in right)."),
31
+ })
32
+ .optional()
33
+ .describe("Ahead/behind between two local refs (e.g. main vs a feature branch), independent of upstream tracking."),
26
34
  maxRoots: z.number().int().min(1).max(256).optional().default(MAX_INVENTORY_ROOTS_DEFAULT),
27
35
  }),
28
36
  execute: async (args) => {
29
- if (args.absoluteGitRoots != null && args.absoluteGitRoots.length > 0) {
37
+ if (Array.isArray(args.root)) {
30
38
  if (args.preset || (args.nestedRoots?.length ?? 0) > 0) {
31
- return jsonRespond({ error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_NESTED_OR_PRESET_CONFLICT });
39
+ return jsonRespond({ error: ERROR_CODES.ROOT_LIST_NESTED_OR_PRESET_CONFLICT });
32
40
  }
33
41
  }
34
42
  const pre = requireGitAndRoots(server, args, args.preset);
@@ -49,24 +57,36 @@ export function registerGitInventoryTool(server) {
49
57
  }
50
58
  upstream = { mode: "fixed", remote: rawRemote, branch: rawBranch };
51
59
  }
60
+ let compareRefs;
61
+ if (args.compareRefs) {
62
+ const left = args.compareRefs.left.trim();
63
+ const right = args.compareRefs.right.trim();
64
+ if (!isSafeGitAncestorRef(left) || !isSafeGitAncestorRef(right)) {
65
+ return jsonRespond({
66
+ error: ERROR_CODES.UNSAFE_REF_TOKEN,
67
+ left: args.compareRefs.left,
68
+ right: args.compareRefs.right,
69
+ });
70
+ }
71
+ compareRefs = { left, right };
72
+ }
52
73
  const useFixed = upstream.mode === "fixed";
53
74
  const allJson = [];
54
75
  const mdChunks = [];
55
76
  for (const workspaceRoot of pre.roots) {
56
77
  const top = gitTopLevel(workspaceRoot);
57
78
  if (!top) {
58
- const err = { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: workspaceRoot };
59
79
  if (args.format === "json") {
60
80
  allJson.push({
61
81
  workspaceRoot: workspaceRoot,
62
82
  ...(upstream.mode === "fixed" ? { upstream } : {}),
63
83
  entries: [
64
- makeSkipEntry(workspaceRoot, workspaceRoot, upstream.mode, JSON.stringify(err)),
84
+ makeSkipEntry(workspaceRoot, workspaceRoot, upstream.mode, "(not a git repository)"),
65
85
  ],
66
86
  });
67
87
  }
68
88
  else {
69
- mdChunks.push(`### ${workspaceRoot}\n${jsonRespond(err)}`);
89
+ mdChunks.push(`### ${workspaceRoot}\n(not a git repository)`);
70
90
  }
71
91
  continue;
72
92
  }
@@ -106,14 +126,14 @@ export function registerGitInventoryTool(server) {
106
126
  }
107
127
  jobs.push({ label: rel, abs });
108
128
  }
109
- const computed = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, (j) => collectInventoryEntry(j.label, j.abs, upstream.remote, upstream.branch));
129
+ const computed = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, (j) => collectInventoryEntry(j.label, j.abs, upstream.remote, upstream.branch, compareRefs));
110
130
  entries.push(...computed);
111
131
  }
112
132
  else if (!gitRevParseGitDir(top)) {
113
133
  entries.push(makeSkipEntry(".", top, upstream.mode, "(not a git work tree — unexpected)"));
114
134
  }
115
135
  else {
116
- const one = await collectInventoryEntry(".", top, upstream.remote, upstream.branch);
136
+ const one = await collectInventoryEntry(".", top, upstream.remote, upstream.branch, compareRefs);
117
137
  entries.push(one);
118
138
  }
119
139
  if (args.format === "json") {
@@ -1,11 +1,12 @@
1
1
  import { basename } from "node:path";
2
2
  import { z } from "zod";
3
+ import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
3
4
  import { ERROR_CODES } from "./error-codes.js";
4
5
  import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitTopLevel, spawnGitAsync } from "./git.js";
5
6
  import { isSafeGitAncestorRef } from "./git-refs.js";
6
7
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
7
8
  import { requireGitAndRoots } from "./roots.js";
8
- import { WorkspacePickSchema } from "./schemas.js";
9
+ import { RootPickSchema } from "./schemas.js";
9
10
  // ---------------------------------------------------------------------------
10
11
  // Constants
11
12
  // ---------------------------------------------------------------------------
@@ -21,7 +22,7 @@ const RECORD_SEP_OUT = "\x02"; // what git outputs (STX) — used as record-STAR
21
22
  // %x02 is placed at the START of each record (tformat adds \n as terminator after each).
22
23
  // Splitting stdout on \x02 then gives empty-first-chunk + one chunk per commit,
23
24
  // each structured as: <fields>\x01\n\n <shortstat text>\n
24
- // Fields are separated by %x01; the trailing \x01 before \n leaves one empty last field (ignored).
25
+ // Fields are separated by %x01; the trailing %x01 before \n leaves one empty last field (ignored).
25
26
  const PRETTY_FORMAT = "%x02%H%x01%s%x01%aN%x01%aE%x01%aI%x01";
26
27
  // ---------------------------------------------------------------------------
27
28
  // Helpers
@@ -55,7 +56,7 @@ async function gitCurrentBranch(cwd, branchArg) {
55
56
  * Run git log for a single repo root and return structured data.
56
57
  */
57
58
  async function runGitLog(opts) {
58
- const { top, since, paths, grep, author, maxCommits, branch } = opts;
59
+ const { top, since, paths, grep, author, maxCommits, branch, follow } = opts;
59
60
  // Resolve branch first (needed for output metadata).
60
61
  const resolvedBranch = await gitCurrentBranch(top, branch);
61
62
  // Fetch one extra commit to detect truncation.
@@ -68,6 +69,9 @@ async function runGitLog(opts) {
68
69
  String(fetchLimit),
69
70
  `--since=${since}`,
70
71
  ];
72
+ if (follow) {
73
+ logArgs.push("--follow");
74
+ }
71
75
  if (branch?.trim()) {
72
76
  logArgs.push(branch.trim());
73
77
  }
@@ -176,11 +180,12 @@ function renderLogMarkdown(group, filterSummary) {
176
180
  export function registerGitLogTool(server) {
177
181
  server.addTool({
178
182
  name: "git_log",
179
- description: "Read-only `git log` across one or more roots. Returns author, date, subject, optional diff stats.",
183
+ description: "Read-only `git log` across one or more roots. Returns author, date, subject, optional diff stats. " +
184
+ "`follow: true` passes `--follow` for rename-aware history (requires exactly one `paths` entry).",
180
185
  annotations: {
181
186
  readOnlyHint: true,
182
187
  },
183
- parameters: WorkspacePickSchema.extend({
188
+ parameters: RootPickSchema.extend({
184
189
  format: z
185
190
  .enum(["markdown", "json", "oneline"])
186
191
  .optional()
@@ -209,6 +214,11 @@ export function registerGitLogTool(server) {
209
214
  .default(DEFAULT_MAX_COMMITS)
210
215
  .describe(`Max commits per root (hard cap ${MAX_COMMITS_HARD_CAP}, default ${DEFAULT_MAX_COMMITS}).`),
211
216
  branch: z.string().optional().describe("Ref/branch to log from. Default: HEAD."),
217
+ follow: z
218
+ .boolean()
219
+ .optional()
220
+ .default(false)
221
+ .describe("Pass `git log --follow` for rename-aware history. Requires exactly one entry in `paths`."),
212
222
  }),
213
223
  execute: async (args) => {
214
224
  const pre = requireGitAndRoots(server, args, undefined);
@@ -227,12 +237,20 @@ export function registerGitLogTool(server) {
227
237
  return jsonRespond({ error: ERROR_CODES.INVALID_PATHS, path: p });
228
238
  }
229
239
  }
240
+ const follow = args.follow ?? false;
241
+ if (follow && rawPaths.length !== 1) {
242
+ return jsonRespond({
243
+ error: ERROR_CODES.INVALID_PATHS,
244
+ detail: "follow requires exactly one path",
245
+ pathCount: rawPaths.length,
246
+ });
247
+ }
230
248
  // Validate branch — reject leading-dash and other injection attempts.
231
249
  if (args.branch && !isSafeGitAncestorRef(args.branch)) {
232
250
  return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, branch: args.branch });
233
251
  }
234
252
  const maxCommits = Math.min(args.maxCommits ?? DEFAULT_MAX_COMMITS, MAX_COMMITS_HARD_CAP);
235
- // Fan out across roots.
253
+ // Fan out across roots. Path confinement is per-root (each has its own toplevel).
236
254
  const jobs = pre.roots.map((rootInput) => ({ rootInput }));
237
255
  const results = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
238
256
  const top = gitTopLevel(rootInput);
@@ -243,6 +261,16 @@ export function registerGitLogTool(server) {
243
261
  error: ERROR_CODES.NOT_A_GIT_REPOSITORY,
244
262
  };
245
263
  }
264
+ for (const p of rawPaths) {
265
+ const resolved = resolvePathForRepo(p, top);
266
+ if (!assertRelativePathUnderTop(p, resolved, top)) {
267
+ return {
268
+ _error: true,
269
+ workspaceRoot: rootInput,
270
+ error: ERROR_CODES.PATH_ESCAPES_REPO,
271
+ };
272
+ }
273
+ }
246
274
  const r = await runGitLog({
247
275
  top,
248
276
  since: rawSince,
@@ -251,6 +279,7 @@ export function registerGitLogTool(server) {
251
279
  author: args.author,
252
280
  maxCommits,
253
281
  branch: args.branch,
282
+ follow,
254
283
  });
255
284
  if ("error" in r) {
256
285
  return { _error: true, workspaceRoot: rootInput, error: r.error };
@@ -261,6 +290,8 @@ export function registerGitLogTool(server) {
261
290
  const filterParts = [`since: ${rawSince}`];
262
291
  if (rawPaths.length > 0)
263
292
  filterParts.push(`paths: ${rawPaths.join(", ")}`);
293
+ if (follow)
294
+ filterParts.push("follow");
264
295
  if (args.grep)
265
296
  filterParts.push(`grep: ${args.grep}`);
266
297
  if (args.author)
@@ -15,22 +15,39 @@ const StrategySchema = z
15
15
  .describe("`auto` (default): cascade fast-forward → rebase → merge-commit per source. " +
16
16
  "`ff-only`: only fast-forward, fail if diverged. " +
17
17
  "`rebase`: rebase source onto destination, then fast-forward (no merge-commit fallback). " +
18
- "`merge`: always create a merge commit (no fast-forward).");
18
+ "`merge`: always create a merge commit (no fast-forward). " +
19
+ "Note: `auto`/`rebase` rewrite the source branch tip in place when rebasing (new SHAs).");
19
20
  // ---------------------------------------------------------------------------
20
21
  // Abort helpers
21
22
  // ---------------------------------------------------------------------------
22
- async function abortMerge(gitTop) {
23
- await spawnGitAsync(gitTop, ["merge", "--abort"]);
23
+ /** Result of `git merge --abort` — callers must check `ok` before claiming a clean abort. */
24
+ export async function abortMerge(gitTop) {
25
+ const r = await spawnGitAsync(gitTop, ["merge", "--abort"]);
26
+ if (r.ok)
27
+ return { ok: true };
28
+ const detail = (r.stderr || r.stdout).trim();
29
+ return detail === "" ? { ok: false } : { ok: false, detail };
24
30
  }
25
- async function abortRebase(gitTop) {
26
- await spawnGitAsync(gitTop, ["rebase", "--abort"]);
31
+ /** Result of `git rebase --abort` — callers must check `ok` before claiming a clean abort. */
32
+ export async function abortRebase(gitTop) {
33
+ const r = await spawnGitAsync(gitTop, ["rebase", "--abort"]);
34
+ if (r.ok)
35
+ return { ok: true };
36
+ const detail = (r.stderr || r.stdout).trim();
37
+ return detail === "" ? { ok: false } : { ok: false, detail };
38
+ }
39
+ async function mergeInProgress(gitTop) {
40
+ const r = await spawnGitAsync(gitTop, ["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]);
41
+ return r.ok;
27
42
  }
28
43
  // ---------------------------------------------------------------------------
29
44
  // Per-source merge logic
30
45
  // ---------------------------------------------------------------------------
31
46
  /**
32
47
  * Attempt to land `source` on `into`. Caller must ensure `into` is checked out.
33
- * On conflict the repo state is cleaned (merge/rebase aborted, HEAD restored to `into`).
48
+ * On conflict the repo is aborted when possible; if `--abort` itself fails the
49
+ * result carries `merge_abort_failed` / `rebase_abort_failed` and the tree may
50
+ * still be mid-merge/rebase.
34
51
  */
35
52
  async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
36
53
  // --- Classify state via merge-base ---
@@ -79,6 +96,7 @@ async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
79
96
  return mergeCommit(gitTop, source, mergeMessage, into);
80
97
  }
81
98
  // --- rebase or auto (diverged case) ---
99
+ // Warning: rebaseSourceOntoInto rewrites the source branch tip in place.
82
100
  const rebased = await rebaseSourceOntoInto(gitTop, into, source);
83
101
  if (rebased.ok) {
84
102
  // Rebase succeeded; FF destination up to the now-rebased source tip.
@@ -96,7 +114,21 @@ async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
96
114
  async function fastForward(gitTop, source) {
97
115
  const r = await spawnGitAsync(gitTop, ["merge", "--ff-only", source]);
98
116
  if (!r.ok) {
99
- await abortMerge(gitTop);
117
+ // ff-only refusal normally leaves no MERGE_HEAD — only abort when mid-merge.
118
+ if (await mergeInProgress(gitTop)) {
119
+ const abort = await abortMerge(gitTop);
120
+ if (!abort.ok) {
121
+ return {
122
+ source,
123
+ ok: false,
124
+ outcome: "conflicts",
125
+ conflictStage: "merge",
126
+ conflictPaths: [],
127
+ error: ERROR_CODES.MERGE_ABORT_FAILED,
128
+ detail: abort.detail ?? (r.stderr || r.stdout).trim(),
129
+ };
130
+ }
131
+ }
100
132
  return {
101
133
  source,
102
134
  ok: false,
@@ -120,7 +152,18 @@ async function mergeCommit(gitTop, source, message, into) {
120
152
  const r = await spawnGitAsync(gitTop, ["merge", "--no-ff", "--no-edit", "-m", msg, source]);
121
153
  if (!r.ok) {
122
154
  const paths = await conflictPaths(gitTop);
123
- await abortMerge(gitTop);
155
+ const abort = await abortMerge(gitTop);
156
+ if (!abort.ok) {
157
+ return {
158
+ source,
159
+ ok: false,
160
+ outcome: "conflicts",
161
+ conflictStage: "merge",
162
+ conflictPaths: paths,
163
+ error: ERROR_CODES.MERGE_ABORT_FAILED,
164
+ detail: abort.detail ?? (r.stderr || r.stdout).trim(),
165
+ };
166
+ }
124
167
  return {
125
168
  source,
126
169
  ok: false,
@@ -142,15 +185,27 @@ async function mergeCommit(gitTop, source, message, into) {
142
185
  /**
143
186
  * Rebase `source` onto `into`, then return to `into`.
144
187
  * On failure: abort rebase, check out `into` again, return structured conflict.
188
+ * Successful rebase rewrites the source branch tip (new SHAs).
145
189
  */
146
190
  async function rebaseSourceOntoInto(gitTop, into, source) {
147
191
  // `git rebase <upstream> <branch>` first switches to <branch>.
148
192
  const r = await spawnGitAsync(gitTop, ["rebase", into, source]);
149
193
  if (!r.ok) {
150
194
  const paths = await conflictPaths(gitTop);
151
- await abortRebase(gitTop);
152
- // Ensure we're back on `into` regardless of rebase state.
195
+ const abort = await abortRebase(gitTop);
196
+ // Ensure we're back on `into` regardless of rebase state (best-effort).
153
197
  await spawnGitAsync(gitTop, ["checkout", into]);
198
+ if (!abort.ok) {
199
+ return {
200
+ source,
201
+ ok: false,
202
+ outcome: "conflicts",
203
+ conflictStage: "rebase",
204
+ conflictPaths: paths,
205
+ error: ERROR_CODES.REBASE_ABORT_FAILED,
206
+ detail: abort.detail ?? (r.stderr || r.stdout).trim(),
207
+ };
208
+ }
154
209
  return {
155
210
  source,
156
211
  ok: false,
@@ -179,6 +234,9 @@ async function rebaseSourceOntoInto(gitTop, into, source) {
179
234
  async function maybeRemoveWorktree(gitTop, source, enabled) {
180
235
  if (!enabled)
181
236
  return undefined;
237
+ // Gate on the source branch name first (path basename may be an agent temp dir).
238
+ if (isProtectedBranch(source))
239
+ return undefined;
182
240
  const path = await worktreeForBranch(gitTop, source);
183
241
  if (!path)
184
242
  return undefined;
@@ -209,6 +267,7 @@ export function registerGitMergeTool(server) {
209
267
  name: "git_merge",
210
268
  description: "Merge one or more source branches into a destination. `auto` cascades " +
211
269
  "fast-forward → rebase → merge-commit, preferring linear history. " +
270
+ "`auto`/`rebase` rewrite the source branch tip in place when rebasing (new SHAs), not only the destination. " +
212
271
  "Refuses on dirty tree; stops on first conflict. Optional flags delete merged branches/worktrees " +
213
272
  "(protected names skipped: main, master, dev, develop, stable, trunk, prod, production, release/*, hotfix/*).",
214
273
  annotations: {
@@ -216,7 +275,7 @@ export function registerGitMergeTool(server) {
216
275
  destructiveHint: false,
217
276
  idempotentHint: false,
218
277
  },
219
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
278
+ parameters: WorkspacePickSchema.extend({
220
279
  sources: z
221
280
  .array(z.string().min(1))
222
281
  .min(1)
@@ -240,7 +299,7 @@ export function registerGitMergeTool(server) {
240
299
  .boolean()
241
300
  .optional()
242
301
  .default(false)
243
- .describe("Remove local worktrees on source branches after clean merge (`git worktree remove`). Protected tails skipped."),
302
+ .describe("Remove local worktrees on source branches after clean merge (`git worktree remove`). Protected source names and path tails skipped."),
244
303
  }),
245
304
  execute: async (args) => {
246
305
  const pre = requireSingleRepo(server, args);
@@ -330,7 +389,6 @@ export function registerGitMergeTool(server) {
330
389
  }),
331
390
  ...spreadWhen(r.branchDeleted === true, { branchDeleted: true }),
332
391
  ...spreadDefined("worktreeRemoved", r.worktreeRemoved),
333
- ...spreadDefined("skipReason", r.skipReason),
334
392
  ...spreadDefined("error", r.error),
335
393
  ...spreadDefined("detail", r.detail),
336
394
  })),
@@ -5,7 +5,7 @@ import { validateRepoPath } from "./inventory.js";
5
5
  import { jsonRespond, spreadDefined } from "./json.js";
6
6
  import { applyPresetParityPairs } from "./presets.js";
7
7
  import { requireGitAndRoots } from "./roots.js";
8
- import { WorkspacePickSchema } from "./schemas.js";
8
+ import { RootPickSchema } from "./schemas.js";
9
9
  export function registerGitParityTool(server) {
10
10
  server.addTool({
11
11
  name: "git_parity",
@@ -13,7 +13,7 @@ export function registerGitParityTool(server) {
13
13
  annotations: {
14
14
  readOnlyHint: true,
15
15
  },
16
- parameters: WorkspacePickSchema.extend({
16
+ parameters: RootPickSchema.extend({
17
17
  pairs: z
18
18
  .array(z.object({
19
19
  left: z.string(),
@@ -34,17 +34,26 @@ export function registerGitParityTool(server) {
34
34
  for (const workspaceRoot of pre.roots) {
35
35
  const top = gitTopLevel(workspaceRoot);
36
36
  if (!top) {
37
- const errPayload = { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: workspaceRoot };
38
- const err = jsonRespond(errPayload);
37
+ const errDesc = `not a git repository: ${workspaceRoot}`;
39
38
  if (args.format === "json") {
40
39
  results.push({
41
40
  workspaceRoot: workspaceRoot,
42
41
  status: "MISMATCH",
43
- pairs: [{ label: "—", leftPath: "", rightPath: "", match: false, error: err }],
42
+ pairs: [{ label: "—", leftPath: "", rightPath: "", match: false, error: errDesc }],
44
43
  });
45
44
  }
46
45
  else {
47
- mdParts.push(err);
46
+ mdParts.push([
47
+ "# Git HEAD parity",
48
+ "",
49
+ `status: MISMATCH`,
50
+ "",
51
+ `## — — error`,
52
+ "```text",
53
+ errDesc,
54
+ "```",
55
+ "",
56
+ ].join("\n"));
48
57
  }
49
58
  continue;
50
59
  }
@@ -16,7 +16,7 @@ export function registerGitPushTool(server) {
16
16
  destructiveHint: false,
17
17
  idempotentHint: false,
18
18
  },
19
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
19
+ parameters: WorkspacePickSchema.extend({
20
20
  remote: z
21
21
  .string()
22
22
  .optional()
@@ -45,7 +45,9 @@ export function registerGitPushTool(server) {
45
45
  if (!branch) {
46
46
  return jsonRespond({ error: ERROR_CODES.PUSH_DETACHED_HEAD });
47
47
  }
48
- if (!isSafeGitRefToken(branch)) {
48
+ // Reject leading `+` (git force-update refspec) even if the shared ref
49
+ // token helper still permits `+` mid-name — never force-push via argv.
50
+ if (!isSafeGitRefToken(branch) || branch.startsWith("+")) {
49
51
  return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: branch });
50
52
  }
51
53
  // --- Resolve remote ---