@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.
- package/AGENTS.md +23 -19
- package/CHANGELOG.md +145 -39
- package/HUMANS.md +23 -42
- package/README.md +30 -13
- package/dist/repo-paths.js +57 -13
- package/dist/server/batch-commit-tool.js +203 -53
- package/dist/server/error-codes.js +31 -16
- package/dist/server/git-blame-tool.js +66 -19
- package/dist/server/git-branch-tool.js +151 -0
- package/dist/server/git-cherry-pick-tool.js +221 -12
- package/dist/server/git-conflicts-tool.js +230 -0
- package/dist/server/git-diff-summary-tool.js +39 -28
- package/dist/server/git-diff-tool.js +56 -31
- package/dist/server/git-grep-tool.js +188 -0
- package/dist/server/git-inventory-tool.js +30 -10
- package/dist/server/git-log-tool.js +37 -6
- package/dist/server/git-merge-tool.js +71 -13
- package/dist/server/git-parity-tool.js +15 -6
- package/dist/server/git-push-tool.js +4 -2
- package/dist/server/git-refs.js +48 -17
- package/dist/server/git-reset-soft-tool.js +1 -1
- package/dist/server/git-revert-tool.js +160 -0
- package/dist/server/git-show-tool.js +5 -10
- package/dist/server/git-stash-tool.js +112 -78
- package/dist/server/git-status-tool.js +2 -2
- package/dist/server/git-tag-tool.js +8 -13
- package/dist/server/git-worktree-tool.js +67 -59
- package/dist/server/git.js +116 -24
- package/dist/server/inventory.js +90 -32
- package/dist/server/list-presets-tool.js +2 -8
- package/dist/server/presets-resource.js +37 -20
- package/dist/server/presets.js +87 -15
- package/dist/server/roots.js +52 -79
- package/dist/server/schemas.js +18 -19
- package/dist/server/test-harness.js +11 -4
- package/dist/server/tool-parameter-schemas.js +47 -58
- package/dist/server/tools.js +36 -17
- package/dist/server.js +1 -1
- package/docs/install.md +52 -5
- package/docs/mcp-tools.md +472 -284
- package/package.json +6 -6
- package/schemas/batch_commit.json +5 -17
- package/schemas/git_blame.json +13 -11
- package/schemas/git_branch.json +54 -0
- package/schemas/git_cherry_pick.json +13 -15
- package/schemas/git_cherry_pick_continue.json +34 -0
- package/schemas/git_conflicts.json +38 -0
- package/schemas/git_diff.json +12 -10
- package/schemas/git_diff_summary.json +1 -15
- package/schemas/git_grep.json +85 -0
- package/schemas/git_inventory.json +32 -23
- package/schemas/git_log.json +20 -24
- package/schemas/git_merge.json +3 -15
- package/schemas/git_parity.json +13 -23
- package/schemas/git_push.json +1 -13
- package/schemas/git_reset_soft.json +1 -13
- package/schemas/git_revert.json +47 -0
- package/schemas/git_show.json +2 -8
- package/schemas/git_stash_apply.json +2 -8
- package/schemas/git_stash_push.json +47 -0
- package/schemas/git_status.json +13 -23
- package/schemas/git_tag.json +1 -7
- package/schemas/git_worktree_add.json +2 -14
- package/schemas/git_worktree_remove.json +2 -14
- package/schemas/index.json +28 -23
- package/schemas/list_presets.json +13 -23
- package/tool-parameters.schema.json +407 -423
- package/dist/server/git-branch-list-tool.js +0 -138
- package/dist/server/git-fetch-tool.js +0 -266
- package/dist/server/git-reflog-tool.js +0 -126
- package/schemas/git_branch_list.json +0 -36
- package/schemas/git_fetch.json +0 -52
- package/schemas/git_reflog.json +0 -44
- package/schemas/git_stash_list.json +0 -30
- package/schemas/git_worktree_list.json +0 -30
package/dist/server/git-refs.js
CHANGED
|
@@ -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
|
-
|
|
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 `
|
|
74
|
-
* by `git reset --soft HEAD~3
|
|
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
|
-
|
|
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
|
|
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
|
|
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) =>
|
|
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) =>
|
|
117
|
+
return parts.length === 2 && parts.every((p) => isSafeGitCommitIsh(p));
|
|
97
118
|
}
|
|
98
|
-
return
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
253
|
-
|
|
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.
|
|
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 {
|
|
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.
|
|
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(
|
|
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 (!
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
|
79
|
-
if (!
|
|
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 (!
|
|
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
|