@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
|
@@ -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,
|
|
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:
|
|
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 (
|
|
37
|
+
if (Array.isArray(args.root)) {
|
|
30
38
|
if (args.preset || (args.nestedRoots?.length ?? 0) > 0) {
|
|
31
|
-
return jsonRespond({ error: ERROR_CODES.
|
|
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,
|
|
84
|
+
makeSkipEntry(workspaceRoot, workspaceRoot, upstream.mode, "(not a git repository)"),
|
|
65
85
|
],
|
|
66
86
|
});
|
|
67
87
|
}
|
|
68
88
|
else {
|
|
69
|
-
mdChunks.push(`### ${workspaceRoot}\n
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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:
|
|
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
|
|
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:
|
|
42
|
+
pairs: [{ label: "—", leftPath: "", rightPath: "", match: false, error: errDesc }],
|
|
44
43
|
});
|
|
45
44
|
}
|
|
46
45
|
else {
|
|
47
|
-
mdParts.push(
|
|
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.
|
|
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
|
-
|
|
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 ---
|