@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
@@ -51,13 +51,14 @@ export function isProtectedBranch(name) {
51
51
  * Conservative check for branch/ref names passed to git argv.
52
52
  * Rejects anything outside the ASCII subset `A-Z a-z 0-9 _ . / + -`,
53
53
  * sequences git itself rejects (`..`, `@{`, leading `-`, trailing `.lock`/`/`),
54
- * and pathological tokens.
54
+ * leading `+` (git force-refspec), and pathological tokens.
55
55
  */
56
56
  export function isSafeGitRefToken(s) {
57
57
  const t = s.trim();
58
58
  if (t.length === 0 || t.length > 256)
59
59
  return false;
60
- if (t.startsWith("-"))
60
+ // Leading `-` is a git option; leading `+` is a force-update refspec.
61
+ if (t.startsWith("-") || t.startsWith("+"))
61
62
  return false;
62
63
  if (t.endsWith("/") || t.endsWith(".lock") || t.endsWith("."))
63
64
  return false;
@@ -70,32 +71,52 @@ export function isSafeGitRefToken(s) {
70
71
  return /^[A-Za-z0-9_./+-]+$/.test(t);
71
72
  }
72
73
  /**
73
- * Same as `isSafeGitRefToken` but also allows `~N` / `^N` ancestor notation used
74
- * by `git reset --soft HEAD~3`. Permits `~` and `^` suffix characters.
74
+ * Same as `isSafeGitCommitIsh` trailing `~N` / `^N` ancestor notation used
75
+ * by `git reset --soft HEAD~3`, plus the full `isSafeGitRefToken` base guards
76
+ * (`..`, `.lock`, `//`, `@{`, leading `+/-`, etc.). Kept as a named export for
77
+ * call sites that only need ancestor-capable single refs (not ranges).
75
78
  */
76
79
  export function isSafeGitAncestorRef(s) {
80
+ return isSafeGitCommitIsh(s);
81
+ }
82
+ /**
83
+ * Same as `isSafeGitRefToken`, but also accepts a trailing run of ancestor
84
+ * operators (`~N` / `^N`, mixable and order-independent, e.g. `HEAD~3`,
85
+ * `main^2`, `v1.0.0~2^1`) as used by commit-ish arguments to `git diff`,
86
+ * `git blame`, and `git show`. The base name (everything before the first
87
+ * ancestor operator) must itself pass `isSafeGitRefToken` — so all of that
88
+ * function's guards (leading `-`/`+`, `..`, `.lock` suffix, `//`, `@{`,
89
+ * whitespace, `:`, etc.) still apply. Ancestor operators are only permitted
90
+ * as a trailing suffix run, not embedded mid-name (e.g. `a~b` is rejected).
91
+ */
92
+ export function isSafeGitCommitIsh(s) {
77
93
  const t = s.trim();
78
94
  if (t.length === 0 || t.length > 256)
79
95
  return false;
80
- if (t.startsWith("-"))
96
+ const suffixMatch = /(?:[~^][0-9]*)+$/.exec(t);
97
+ const base = suffixMatch ? t.slice(0, suffixMatch.index) : t;
98
+ if (base.length === 0)
81
99
  return false;
82
- return /^[A-Za-z0-9_./+~^-]+$/.test(t);
100
+ return isSafeGitRefToken(base);
83
101
  }
84
102
  /**
85
103
  * Same as `isSafeGitRefToken` but also allows the `A..B` / `A...B` range forms
86
- * used by `git log` / `git cherry-pick`. Splits once and validates each side.
104
+ * used by `git log` / `git cherry-pick` / `git_diff_summary`. Splits once and
105
+ * validates each side (and the no-range single-ref fallthrough) with
106
+ * `isSafeGitCommitIsh`, so ancestor notation (`HEAD~3`, `main^2`) is accepted
107
+ * on either endpoint, e.g. `HEAD~3..HEAD` or `main...feature^2`.
87
108
  */
88
109
  export function isSafeGitRangeToken(s) {
89
110
  const t = s.trim();
90
111
  if (t.includes("...")) {
91
112
  const parts = t.split("...");
92
- return parts.length === 2 && parts.every((p) => isSafeGitRefToken(p));
113
+ return parts.length === 2 && parts.every((p) => isSafeGitCommitIsh(p));
93
114
  }
94
115
  if (t.includes("..")) {
95
116
  const parts = t.split("..");
96
- return parts.length === 2 && parts.every((p) => isSafeGitRefToken(p));
117
+ return parts.length === 2 && parts.every((p) => isSafeGitCommitIsh(p));
97
118
  }
98
- return isSafeGitRefToken(t);
119
+ return isSafeGitCommitIsh(t);
99
120
  }
100
121
  // ---------------------------------------------------------------------------
101
122
  // Async helpers
@@ -218,11 +239,16 @@ export async function commitListBetween(cwd, excludeRef, includeRef) {
218
239
  .map((l) => l.trim())
219
240
  .filter((l) => l.length > 0);
220
241
  }
221
- /** Parse `git worktree list --porcelain` into structured entries. */
242
+ /**
243
+ * Parse `git worktree list --porcelain` into structured entries.
244
+ * Returns `{ ok: false, detail }` when git fails — callers must not treat
245
+ * failure as an empty worktree list.
246
+ */
222
247
  export async function listWorktrees(cwd) {
223
248
  const r = await spawnGitAsync(cwd, ["worktree", "list", "--porcelain"]);
224
- if (!r.ok)
225
- return [];
249
+ if (!r.ok) {
250
+ return { ok: false, detail: (r.stderr || r.stdout).trim() || "git worktree list failed" };
251
+ }
226
252
  const out = [];
227
253
  let cur = {};
228
254
  for (const line of r.stdout.split("\n")) {
@@ -245,12 +271,17 @@ export async function listWorktrees(cwd) {
245
271
  }
246
272
  if (cur.path)
247
273
  out.push({ path: cur.path, branch: cur.branch ?? null, head: cur.head ?? null });
248
- return out;
274
+ return { ok: true, worktrees: out };
249
275
  }
250
- /** Path of the worktree currently checked out on `branch`; `null` if none. */
276
+ /**
277
+ * Path of the worktree currently checked out on `branch`; `null` if none
278
+ * or if listing worktrees failed (fail closed for destructive callers).
279
+ */
251
280
  export async function worktreeForBranch(cwd, branch) {
252
- const trees = await listWorktrees(cwd);
253
- const hit = trees.find((t) => t.branch === branch);
281
+ const listed = await listWorktrees(cwd);
282
+ if (!listed.ok)
283
+ return null;
284
+ const hit = listed.worktrees.find((t) => t.branch === branch);
254
285
  return hit?.path ?? null;
255
286
  }
256
287
  // ---------------------------------------------------------------------------
@@ -15,7 +15,7 @@ export function registerGitResetSoftTool(server) {
15
15
  destructiveHint: false,
16
16
  idempotentHint: false,
17
17
  },
18
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
18
+ parameters: WorkspacePickSchema.extend({
19
19
  ref: z
20
20
  .string()
21
21
  .min(1)
@@ -0,0 +1,160 @@
1
+ import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
3
+ import { spawnGitAsync } from "./git.js";
4
+ import { conflictPaths, isSafeGitAncestorRef, isWorkingTreeClean } from "./git-refs.js";
5
+ import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
6
+ import { requireSingleRepo } from "./roots.js";
7
+ import { WorkspacePickSchema } from "./schemas.js";
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+ /** SHA of REVERT_HEAD (the commit currently mid-revert), or undefined once resolved/absent. */
12
+ async function revertHead(gitTop) {
13
+ const r = await spawnGitAsync(gitTop, ["rev-parse", "--verify", "--quiet", "REVERT_HEAD"]);
14
+ if (!r.ok)
15
+ return undefined;
16
+ const sha = r.stdout.trim();
17
+ return sha === "" ? undefined : sha;
18
+ }
19
+ /** Result of attempting `git revert --abort` — callers must check `ok` before claiming a clean abort. */
20
+ export async function abortRevert(gitTop) {
21
+ const r = await spawnGitAsync(gitTop, ["revert", "--abort"]);
22
+ if (r.ok)
23
+ return { ok: true };
24
+ const detail = (r.stderr || r.stdout).trim();
25
+ return detail === "" ? { ok: false } : { ok: false, detail };
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // Tool registration
29
+ // ---------------------------------------------------------------------------
30
+ export function registerGitRevertTool(server) {
31
+ server.addTool({
32
+ name: "git_revert",
33
+ description: "`git revert`: creates new commit(s) that undo the changes introduced by one or more source " +
34
+ "commits. Unlike `git_reset_soft`, this never rewrites history — safe on shared/pushed branches. " +
35
+ "Refuses on a dirty tree. On conflict, aborts and returns the tree clean. `noCommit` stages the " +
36
+ "revert(s) without committing (working tree intentionally left staged in that case). `mainline` " +
37
+ "selects the parent to diff against when reverting a merge commit.",
38
+ annotations: {
39
+ readOnlyHint: false,
40
+ destructiveHint: false,
41
+ idempotentHint: false,
42
+ },
43
+ parameters: WorkspacePickSchema.extend({
44
+ sources: z
45
+ .array(z.string().min(1))
46
+ .min(1)
47
+ .max(20)
48
+ .describe("Commits to revert, applied in order: SHA, branch/tag name, or ancestor notation (`HEAD~1`)."),
49
+ noCommit: z
50
+ .boolean()
51
+ .optional()
52
+ .default(false)
53
+ .describe("Pass `--no-commit`: apply the revert(s) to the index/working tree without committing " +
54
+ "(changes are left staged instead)."),
55
+ mainline: z
56
+ .number()
57
+ .int()
58
+ .min(1)
59
+ .optional()
60
+ .describe("Parent number (`-m N`) to diff against — required when reverting a merge commit."),
61
+ }),
62
+ execute: async (args) => {
63
+ const pre = requireSingleRepo(server, args);
64
+ if (!pre.ok)
65
+ return jsonRespond(pre.error);
66
+ const { gitTop } = pre;
67
+ // --- Validate sources ---
68
+ for (const raw of args.sources) {
69
+ if (!isSafeGitAncestorRef(raw)) {
70
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, source: raw });
71
+ }
72
+ }
73
+ // --- Refuse dirty tree ---
74
+ if (!(await isWorkingTreeClean(gitTop))) {
75
+ return jsonRespond({
76
+ error: ERROR_CODES.WORKING_TREE_DIRTY,
77
+ detail: "git_revert requires a clean working tree. Commit or stash pending changes first.",
78
+ });
79
+ }
80
+ const preHeadProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
81
+ const preHead = preHeadProbe.ok ? preHeadProbe.stdout.trim() : "";
82
+ const revertArgs = ["revert"];
83
+ if (args.noCommit)
84
+ revertArgs.push("--no-commit");
85
+ if (args.mainline !== undefined)
86
+ revertArgs.push("-m", String(args.mainline));
87
+ revertArgs.push(...args.sources);
88
+ const r = await spawnGitAsync(gitTop, revertArgs);
89
+ if (!r.ok) {
90
+ const failedSha = await revertHead(gitTop);
91
+ const paths = await conflictPaths(gitTop);
92
+ const abortResult = await abortRevert(gitTop);
93
+ return jsonRespond({
94
+ ok: false,
95
+ aborted: abortResult.ok,
96
+ ...spreadDefined("commit", failedSha),
97
+ conflicts: paths,
98
+ ...spreadDefined("detail", (r.stderr || r.stdout).trim() || undefined),
99
+ ...spreadWhen(!abortResult.ok, {
100
+ error: ERROR_CODES.REVERT_ABORT_FAILED,
101
+ ...spreadDefined("abortDetail", abortResult.detail),
102
+ }),
103
+ });
104
+ }
105
+ // --- No-commit: revert(s) staged, no new commits ---
106
+ if (args.noCommit) {
107
+ const stagedResult = await spawnGitAsync(gitTop, ["diff", "--cached", "--name-only"]);
108
+ const stagedFiles = stagedResult.ok
109
+ ? stagedResult.stdout
110
+ .split("\n")
111
+ .map((l) => l.trim())
112
+ .filter((l) => l.length > 0)
113
+ : [];
114
+ if (args.format === "json") {
115
+ return jsonRespond({
116
+ ok: true,
117
+ staged: true,
118
+ sources: args.sources,
119
+ stagedCount: stagedFiles.length,
120
+ });
121
+ }
122
+ return [
123
+ "# Revert (staged, not committed)",
124
+ `${args.sources.length} source(s) → ${stagedFiles.length} file(s) staged`,
125
+ ...args.sources.map((s) => `- ${s}`),
126
+ ].join("\n");
127
+ }
128
+ // --- Committed: one new commit per source, oldest-first ---
129
+ const newCommitsResult = await spawnGitAsync(gitTop, [
130
+ "rev-list",
131
+ "--reverse",
132
+ `${preHead}..HEAD`,
133
+ ]);
134
+ const newCommits = newCommitsResult.ok
135
+ ? newCommitsResult.stdout
136
+ .split("\n")
137
+ .map((l) => l.trim())
138
+ .filter((l) => l.length > 0)
139
+ : [];
140
+ const reverted = [];
141
+ for (let i = 0; i < args.sources.length; i++) {
142
+ const source = args.sources[i];
143
+ const sha = newCommits[i];
144
+ if (source !== undefined && sha !== undefined)
145
+ reverted.push({ source, sha });
146
+ }
147
+ if (args.format === "json") {
148
+ return jsonRespond({
149
+ ok: true,
150
+ reverted,
151
+ });
152
+ }
153
+ const lines = [`# Revert: ${reverted.length} commit(s)`, ""];
154
+ for (const item of reverted) {
155
+ lines.push(`- ${item.source} → ${item.sha.slice(0, 7)}`);
156
+ }
157
+ return lines.join("\n");
158
+ },
159
+ });
160
+ }
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
3
3
  import { ERROR_CODES } from "./error-codes.js";
4
4
  import { spawnGitAsync } from "./git.js";
5
- import { isSafeGitAncestorRef } from "./git-refs.js";
5
+ import { isSafeGitCommitIsh } from "./git-refs.js";
6
6
  import { jsonRespond } from "./json.js";
7
7
  import { requireSingleRepo } from "./roots.js";
8
8
  import { WorkspacePickSchema } from "./schemas.js";
@@ -39,6 +39,7 @@ async function runGitShow(opts) {
39
39
  if (!r.ok) {
40
40
  return {
41
41
  error: ERROR_CODES.GIT_SHOW_FAILED,
42
+ detail: (r.stderr || r.stdout).trim(),
42
43
  };
43
44
  }
44
45
  // Parse the output. For a commit, git show outputs:
@@ -156,17 +157,11 @@ export function registerGitShowTool(server) {
156
157
  annotations: {
157
158
  readOnlyHint: true,
158
159
  },
159
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
160
- .pick({
161
- workspaceRoot: true,
162
- rootIndex: true,
163
- format: true,
164
- })
165
- .extend({
160
+ parameters: WorkspacePickSchema.extend({
166
161
  ref: z
167
162
  .string()
168
163
  .min(1)
169
- .describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
164
+ .describe('Commit reference (SHA, branch, tag, or ancestor notation like "HEAD~3", "main^2").'),
170
165
  path: z
171
166
  .string()
172
167
  .optional()
@@ -185,7 +180,7 @@ export function registerGitShowTool(server) {
185
180
  if (!pre.ok)
186
181
  return jsonRespond(pre.error);
187
182
  const top = pre.gitTop;
188
- if (!isSafeGitAncestorRef(args.ref)) {
183
+ if (!isSafeGitCommitIsh(args.ref)) {
189
184
  return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
190
185
  }
191
186
  if (args.path !== undefined) {
@@ -1,77 +1,12 @@
1
1
  import { z } from "zod";
2
+ import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
2
3
  import { ERROR_CODES } from "./error-codes.js";
3
4
  import { spawnGitAsync } from "./git.js";
4
- import { jsonRespond, spreadDefined } from "./json.js";
5
+ import { conflictPaths } from "./git-refs.js";
6
+ import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
7
  import { requireSingleRepo } from "./roots.js";
6
8
  import { WorkspacePickSchema } from "./schemas.js";
7
9
  // ---------------------------------------------------------------------------
8
- // git_stash_list
9
- // ---------------------------------------------------------------------------
10
- export function registerGitStashListTool(server) {
11
- server.addTool({
12
- name: "git_stash_list",
13
- description: "List all git stashes.",
14
- annotations: {
15
- readOnlyHint: true,
16
- },
17
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true }).pick({
18
- workspaceRoot: true,
19
- rootIndex: true,
20
- format: true,
21
- }),
22
- execute: async (args) => {
23
- const pre = requireSingleRepo(server, args);
24
- if (!pre.ok)
25
- return jsonRespond(pre.error);
26
- const { gitTop } = pre;
27
- // List all stashes: git stash list --format='%(refname:short)|%(subject)|%(objectname:short)'
28
- // git stash list uses git-log format (%s, %h) not for-each-ref format %(subject).
29
- const r = await spawnGitAsync(gitTop, ["stash", "list", "--format=%gd|%s|%h"]);
30
- if (!r.ok) {
31
- // If there are no stashes, git still returns ok=true with empty output
32
- // Only treat as error if git itself failed
33
- return jsonRespond({
34
- error: ERROR_CODES.STASH_LIST_FAILED,
35
- detail: (r.stderr || r.stdout).trim(),
36
- });
37
- }
38
- const stashes = [];
39
- const lines = (r.stdout || "")
40
- .split("\n")
41
- .map((l) => l.trim())
42
- .filter((l) => l.length > 0);
43
- for (const line of lines) {
44
- const parts = line.split("|");
45
- // parts[0] = stash@{N}, last part = short SHA, middle = message (may contain "|")
46
- const sha = parts[parts.length - 1];
47
- const message = parts.slice(1, -1).join("|");
48
- // Parse the real stash index from the canonical stash@{N} ref in parts[0].
49
- const indexMatch = parts[0] ? /stash@\{(\d+)\}/.exec(parts[0]) : null;
50
- if (!indexMatch || parts.length < 3 || !message || !sha) {
51
- // Malformed line — skip without affecting index tracking.
52
- continue;
53
- }
54
- stashes.push({
55
- index: Number(indexMatch[1]),
56
- message,
57
- sha,
58
- });
59
- }
60
- if (args.format === "json") {
61
- return jsonRespond({ stashes });
62
- }
63
- if (stashes.length === 0) {
64
- return "# Stashes\n_(none)_";
65
- }
66
- const lines_out = ["# Stashes", ""];
67
- for (const s of stashes) {
68
- lines_out.push(`- **stash@{${s.index}}** — ${s.message} (\`${s.sha}\`)`);
69
- }
70
- return lines_out.join("\n");
71
- },
72
- });
73
- }
74
- // ---------------------------------------------------------------------------
75
10
  // git_stash_apply
76
11
  // ---------------------------------------------------------------------------
77
12
  export function registerGitStashApplyTool(server) {
@@ -80,20 +15,16 @@ export function registerGitStashApplyTool(server) {
80
15
  description: "Apply or pop a git stash. `index` defaults to 0. `pop: true` removes stash after applying.",
81
16
  annotations: {
82
17
  readOnlyHint: false,
83
- destructiveHint: false,
18
+ // pop:true deletes a stash entry — treat the tool as destructive for client filters.
19
+ destructiveHint: true,
84
20
  idempotentHint: false,
85
21
  },
86
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
87
- .pick({
88
- workspaceRoot: true,
89
- rootIndex: true,
90
- format: true,
91
- })
92
- .extend({
22
+ parameters: WorkspacePickSchema.extend({
93
23
  index: z
94
24
  .number()
95
25
  .int()
96
26
  .min(0)
27
+ .max(10000)
97
28
  .optional()
98
29
  .default(0)
99
30
  .describe("Stash index (defaults to 0 for stash@{0})."),
@@ -113,19 +44,122 @@ export function registerGitStashApplyTool(server) {
113
44
  const r = await spawnGitAsync(gitTop, ["stash", cmd, stashRef]);
114
45
  const applied = r.ok;
115
46
  const output = (r.stdout || r.stderr).trim();
47
+ // On apply/pop failure, surface unresolved conflict paths when present
48
+ // (mirrors merge/cherry-pick/revert). Leave the tree as git left it —
49
+ // stash conflicts are not auto-aborted.
50
+ const paths = applied ? [] : await conflictPaths(gitTop);
116
51
  if (args.format === "json") {
117
52
  return jsonRespond({
53
+ ...spreadWhen(!applied, { error: ERROR_CODES.STASH_APPLY_FAILED }),
118
54
  applied,
119
55
  stashIndex: args.index,
120
56
  popped: args.pop,
121
- ...spreadDefined("output", output),
57
+ ...spreadDefined("output", output || undefined),
58
+ ...spreadWhen(paths.length > 0, { conflictPaths: paths }),
122
59
  });
123
60
  }
124
61
  const verb = args.pop ? "popped" : "applied";
125
62
  if (applied) {
126
63
  return `# Stash ${verb}\n✓ ${stashRef} → ${verb}`;
127
64
  }
128
- return `# Stash ${verb} (failed)\n✗ ${stashRef}\n\n\`\`\`\n${output}\n\`\`\``;
65
+ const conflictBlock = paths.length > 0 ? `\n\nConflicts:\n${paths.map((p) => `- ${p}`).join("\n")}` : "";
66
+ return `# Stash ${verb} (failed)\n✗ ${stashRef}\n\n\`\`\`\n${output}\n\`\`\`${conflictBlock}`;
67
+ },
68
+ });
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // git_stash_push
72
+ // ---------------------------------------------------------------------------
73
+ export function registerGitStashPushTool(server) {
74
+ server.addTool({
75
+ name: "git_stash_push",
76
+ description: "Stash working-tree changes (`git stash push`). Optional `message` for the stash subject, " +
77
+ "`includeUntracked` (-u) to also stash untracked files, `keepIndex` (--keep-index) to leave " +
78
+ "staged changes in the index, and `paths` to scope the stash to specific files. " +
79
+ "Returns the new stash ref/SHA, or `stashed: false` if there was nothing to stash.",
80
+ annotations: {
81
+ readOnlyHint: false,
82
+ destructiveHint: false,
83
+ idempotentHint: false,
84
+ },
85
+ parameters: WorkspacePickSchema.extend({
86
+ message: z
87
+ .string()
88
+ .optional()
89
+ .describe("Stash subject message (`git stash push -m <message>`)."),
90
+ includeUntracked: z
91
+ .boolean()
92
+ .optional()
93
+ .default(false)
94
+ .describe("Include untracked files in the stash (`git stash push -u`)."),
95
+ keepIndex: z
96
+ .boolean()
97
+ .optional()
98
+ .default(false)
99
+ .describe("Keep staged changes in the index after stashing (`git stash push --keep-index`)."),
100
+ paths: z
101
+ .array(z.string())
102
+ .optional()
103
+ .describe("Scope the stash to specific paths, relative to git root (must resolve within repo root)."),
104
+ }),
105
+ execute: async (args) => {
106
+ const pre = requireSingleRepo(server, args);
107
+ if (!pre.ok)
108
+ return jsonRespond(pre.error);
109
+ const { gitTop } = pre;
110
+ // Union + dedup + confine paths within the repo (escaping-attempt rejected).
111
+ const rawPaths = [];
112
+ if (Array.isArray(args.paths)) {
113
+ for (const p of args.paths) {
114
+ if (typeof p === "string" && p.trim()) {
115
+ rawPaths.push(p.trim());
116
+ }
117
+ }
118
+ }
119
+ const dedupedPaths = [...new Set(rawPaths)];
120
+ for (const p of dedupedPaths) {
121
+ const resolved = resolvePathForRepo(p, gitTop);
122
+ if (!assertRelativePathUnderTop(p, resolved, gitTop)) {
123
+ return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: p });
124
+ }
125
+ }
126
+ const stashArgs = ["stash", "push"];
127
+ if (args.includeUntracked)
128
+ stashArgs.push("-u");
129
+ if (args.keepIndex)
130
+ stashArgs.push("--keep-index");
131
+ if (args.message)
132
+ stashArgs.push("-m", args.message);
133
+ if (dedupedPaths.length > 0)
134
+ stashArgs.push("--", ...dedupedPaths);
135
+ const r = await spawnGitAsync(gitTop, stashArgs);
136
+ const output = (r.stdout || r.stderr).trim();
137
+ if (!r.ok) {
138
+ return jsonRespond({
139
+ error: ERROR_CODES.STASH_PUSH_FAILED,
140
+ detail: output,
141
+ });
142
+ }
143
+ // `git stash push` exits 0 with this message when there is nothing to stash.
144
+ if (/no local changes to save/i.test(output)) {
145
+ if (args.format === "json") {
146
+ return jsonRespond({ stashed: false, reason: "no_local_changes" });
147
+ }
148
+ return "# Stash push\n_(no local changes to save)_";
149
+ }
150
+ const shaResult = await spawnGitAsync(gitTop, ["rev-parse", "stash@{0}"]);
151
+ const sha = shaResult.ok ? shaResult.stdout.trim() : "";
152
+ const subjectResult = await spawnGitAsync(gitTop, ["log", "-1", "--format=%s", "stash@{0}"]);
153
+ const message = subjectResult.ok ? subjectResult.stdout.trim() : "";
154
+ if (args.format === "json") {
155
+ return jsonRespond({
156
+ stashed: true,
157
+ ref: "stash@{0}",
158
+ sha,
159
+ message,
160
+ });
161
+ }
162
+ return `# Stash pushed\n✓ stash@{0} — ${message}${sha ? ` (\`${sha}\`)` : ""}`;
129
163
  },
130
164
  });
131
165
  }
@@ -4,7 +4,7 @@ import { isStrictlyUnderGitTop } from "../repo-paths.js";
4
4
  import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitStatusShortBranchAsync, gitTopLevel, hasGitMetadata, parseGitSubmodulePaths, } from "./git.js";
5
5
  import { jsonRespond } from "./json.js";
6
6
  import { requireGitAndRoots } from "./roots.js";
7
- import { WorkspacePickSchema } from "./schemas.js";
7
+ import { RootPickSchema } from "./schemas.js";
8
8
  export function registerGitStatusTool(server) {
9
9
  server.addTool({
10
10
  name: "git_status",
@@ -12,7 +12,7 @@ export function registerGitStatusTool(server) {
12
12
  annotations: {
13
13
  readOnlyHint: true,
14
14
  },
15
- parameters: WorkspacePickSchema.extend({
15
+ parameters: RootPickSchema.extend({
16
16
  includeSubmodules: z.boolean().optional().default(true),
17
17
  }),
18
18
  execute: async (args) => {
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { ERROR_CODES } from "./error-codes.js";
3
- import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
3
+ import { spawnGitAsync } from "./git.js";
4
+ import { isSafeGitCommitIsh, isSafeGitRefToken } from "./git-refs.js";
4
5
  import { jsonRespond } from "./json.js";
5
6
  import { requireSingleRepo } from "./roots.js";
6
7
  import { WorkspacePickSchema } from "./schemas.js";
@@ -38,19 +39,13 @@ async function getTagType(gitTop, tag) {
38
39
  export function registerGitTagTool(server) {
39
40
  server.addTool({
40
41
  name: "git_tag",
41
- description: "Create, delete, or inspect git tags. Create annotated tags (with message) or lightweight tags (ref only). " +
42
+ description: "Create or delete git tags. Create annotated tags (with message) or lightweight tags (ref only). " +
42
43
  "Returns tag name, type, and SHA.",
43
44
  annotations: {
44
45
  readOnlyHint: false,
45
46
  destructiveHint: true,
46
47
  },
47
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
48
- .pick({
49
- workspaceRoot: true,
50
- rootIndex: true,
51
- format: true,
52
- })
53
- .extend({
48
+ parameters: WorkspacePickSchema.extend({
54
49
  tag: z.string().min(1).describe("Tag name (e.g. 'v1.2.3')."),
55
50
  message: z
56
51
  .string()
@@ -75,8 +70,8 @@ export function registerGitTagTool(server) {
75
70
  if (!tag) {
76
71
  return jsonRespond({ error: ERROR_CODES.EMPTY_TAG_NAME });
77
72
  }
78
- // Validate tag name: no shell metacharacters
79
- if (!isSafeGitUpstreamToken(tag)) {
73
+ // Validate tag name with ref-token rules (rejects .lock, //, etc.).
74
+ if (!isSafeGitRefToken(tag)) {
80
75
  return jsonRespond({ error: ERROR_CODES.UNSAFE_TAG_TOKEN, tag });
81
76
  }
82
77
  // Handle deletion
@@ -97,9 +92,9 @@ export function registerGitTagTool(server) {
97
92
  }
98
93
  return `Deleted tag: ${tag}`;
99
94
  }
100
- // Determine the ref to tag (default HEAD)
95
+ // Determine the ref to tag (default HEAD). Commit-ish allows HEAD~1 / main^2.
101
96
  const ref = (args.ref ?? "HEAD").trim();
102
- if (!isSafeGitUpstreamToken(ref)) {
97
+ if (!isSafeGitCommitIsh(ref)) {
103
98
  return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref });
104
99
  }
105
100
  // Get the SHA of the ref to tag