@rethunk/mcp-multi-root-git 2.6.0 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +21 -0
- package/HUMANS.md +1 -1
- package/README.md +1 -1
- package/dist/server/batch-commit-tool.js +16 -20
- package/dist/server/error-codes.js +95 -0
- package/dist/server/git-blame-tool.js +227 -0
- package/dist/server/git-branch-list-tool.js +138 -0
- package/dist/server/git-cherry-pick-tool.js +22 -31
- package/dist/server/git-diff-summary-tool.js +6 -6
- package/dist/server/git-diff-tool.js +54 -18
- package/dist/server/git-fetch-tool.js +142 -14
- package/dist/server/git-inventory-tool.js +5 -4
- package/dist/server/git-log-tool.js +18 -19
- package/dist/server/git-merge-tool.js +21 -24
- package/dist/server/git-parity-tool.js +3 -2
- package/dist/server/git-push-tool.js +9 -5
- package/dist/server/git-reflog-tool.js +126 -0
- package/dist/server/git-reset-soft-tool.js +7 -9
- package/dist/server/git-show-tool.js +83 -23
- package/dist/server/git-stash-tool.js +5 -6
- package/dist/server/git-tag-tool.js +8 -7
- package/dist/server/git-worktree-tool.js +14 -16
- package/dist/server/git.js +70 -4
- package/dist/server/list-presets-tool.js +2 -1
- package/dist/server/presets-resource.js +3 -2
- package/dist/server/presets.js +11 -5
- package/dist/server/roots.js +11 -10
- package/dist/server/schemas.js +4 -4
- package/dist/server/tool-parameter-schemas.js +9 -0
- package/dist/server/tools.js +6 -0
- package/docs/mcp-tools.md +120 -7
- package/package.json +10 -13
- package/schemas/git_blame.json +52 -0
- package/schemas/git_branch_list.json +36 -0
- package/schemas/git_diff.json +14 -1
- package/schemas/git_reflog.json +44 -0
- package/schemas/git_show.json +12 -1
- package/schemas/index.json +15 -0
- package/tool-parameters.schema.json +236 -86
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
3
|
import { spawnGitAsync } from "./git.js";
|
|
3
4
|
import { commitListBetween, conflictPaths, getCurrentBranch, isContentEquivalentlyMergedInto, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
4
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
@@ -30,12 +31,12 @@ async function branchExists(gitTop, name) {
|
|
|
30
31
|
async function resolveSource(gitTop, onto, raw) {
|
|
31
32
|
if (raw.includes("..")) {
|
|
32
33
|
if (!isSafeGitRangeToken(raw)) {
|
|
33
|
-
return { error:
|
|
34
|
+
return { error: ERROR_CODES.UNSAFE_REF_TOKEN, raw };
|
|
34
35
|
}
|
|
35
36
|
const r = await spawnGitAsync(gitTop, ["rev-list", "--reverse", raw]);
|
|
36
37
|
if (!r.ok) {
|
|
37
38
|
return {
|
|
38
|
-
error:
|
|
39
|
+
error: ERROR_CODES.RANGE_RESOLUTION_FAILED,
|
|
39
40
|
detail: (r.stderr || r.stdout).trim(),
|
|
40
41
|
raw,
|
|
41
42
|
};
|
|
@@ -47,18 +48,18 @@ async function resolveSource(gitTop, onto, raw) {
|
|
|
47
48
|
return { raw, kind: "range", commits };
|
|
48
49
|
}
|
|
49
50
|
if (!isSafeGitRefToken(raw)) {
|
|
50
|
-
return { error:
|
|
51
|
+
return { error: ERROR_CODES.UNSAFE_REF_TOKEN, raw };
|
|
51
52
|
}
|
|
52
53
|
if (await branchExists(gitTop, raw)) {
|
|
53
54
|
const commits = await commitListBetween(gitTop, onto, raw);
|
|
54
55
|
if (commits === null) {
|
|
55
|
-
return { error:
|
|
56
|
+
return { error: ERROR_CODES.RANGE_RESOLUTION_FAILED, raw };
|
|
56
57
|
}
|
|
57
58
|
return { raw, kind: "branch", commits };
|
|
58
59
|
}
|
|
59
60
|
const sha = await resolveRef(gitTop, raw);
|
|
60
61
|
if (!sha) {
|
|
61
|
-
return { error:
|
|
62
|
+
return { error: ERROR_CODES.SOURCE_NOT_FOUND, raw };
|
|
62
63
|
}
|
|
63
64
|
return { raw, kind: "sha", commits: [sha] };
|
|
64
65
|
}
|
|
@@ -93,14 +94,11 @@ async function filterAndDedupe(gitTop, onto, resolved) {
|
|
|
93
94
|
export function registerGitCherryPickTool(server) {
|
|
94
95
|
server.addTool({
|
|
95
96
|
name: "git_cherry_pick",
|
|
96
|
-
description: "
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
"(content-identical commits with different SHAs are treated as merged, which is the " +
|
|
102
|
-
"normal cherry-pick outcome). Pass `strictMergedRefEquality: true` for strict `git branch -d` " +
|
|
103
|
-
"ancestry semantics. Protected branch names always skipped.",
|
|
97
|
+
description: "Cherry-pick commits from one or more sources onto a destination. Sources: SHAs, `A..B` ranges, " +
|
|
98
|
+
"or branch names (expanded to `onto..<branch>`, oldest-first). Already-reachable commits skipped. " +
|
|
99
|
+
"Refuses on dirty tree; stops on first conflict. Optional flags delete source branches/worktrees " +
|
|
100
|
+
"after success using patch-id equivalence (set `strictMergedRefEquality: true` for strict ancestry). " +
|
|
101
|
+
"Protected names always skipped.",
|
|
104
102
|
annotations: {
|
|
105
103
|
readOnlyHint: false,
|
|
106
104
|
destructiveHint: false,
|
|
@@ -111,8 +109,7 @@ export function registerGitCherryPickTool(server) {
|
|
|
111
109
|
.array(z.string().min(1))
|
|
112
110
|
.min(1)
|
|
113
111
|
.max(50)
|
|
114
|
-
.describe("Sources
|
|
115
|
-
"resolve to `onto..<branch>` (only commits missing from destination)."),
|
|
112
|
+
.describe("Sources: SHA, `A..B` range, or branch name (resolves to `onto..<branch>`)."),
|
|
116
113
|
onto: z
|
|
117
114
|
.string()
|
|
118
115
|
.optional()
|
|
@@ -121,24 +118,18 @@ export function registerGitCherryPickTool(server) {
|
|
|
121
118
|
.boolean()
|
|
122
119
|
.optional()
|
|
123
120
|
.default(false)
|
|
124
|
-
.describe("
|
|
125
|
-
"(`git branch -d`) when it is fully merged into the destination. " +
|
|
126
|
-
"Protected names always skipped; never touches remote refs."),
|
|
121
|
+
.describe("Delete branch-kind sources locally after success. Protected names and remote refs unaffected."),
|
|
127
122
|
deleteMergedWorktrees: z
|
|
128
123
|
.boolean()
|
|
129
124
|
.optional()
|
|
130
125
|
.default(false)
|
|
131
|
-
.describe("
|
|
132
|
-
"(`git worktree remove`). Protected tails always skipped."),
|
|
126
|
+
.describe("Remove local worktrees on branch-kind sources after success. Protected tails skipped."),
|
|
133
127
|
strictMergedRefEquality: z
|
|
134
128
|
.boolean()
|
|
135
129
|
.optional()
|
|
136
130
|
.default(false)
|
|
137
|
-
.describe("
|
|
138
|
-
"
|
|
139
|
-
"destination (same diff, different SHA — the normal cherry-pick outcome). " +
|
|
140
|
-
"Set to true to require strict ref ancestry (`git branch -d` semantics), which " +
|
|
141
|
-
"will refuse deletion after a cherry-pick because the SHA differs."),
|
|
131
|
+
.describe("false (default): delete branch when every commit is content-equivalent on destination (patch-id, normal cherry-pick outcome). " +
|
|
132
|
+
"true: require strict ref ancestry (`git branch -d` semantics — will refuse after cherry-pick due to SHA mismatch)."),
|
|
142
133
|
}),
|
|
143
134
|
execute: async (args) => {
|
|
144
135
|
const pre = requireSingleRepo(server, args);
|
|
@@ -149,26 +140,26 @@ export function registerGitCherryPickTool(server) {
|
|
|
149
140
|
const startBranch = await getCurrentBranch(gitTop);
|
|
150
141
|
const onto = args.onto?.trim() || startBranch;
|
|
151
142
|
if (!onto)
|
|
152
|
-
return jsonRespond({ error:
|
|
143
|
+
return jsonRespond({ error: ERROR_CODES.ONTO_DETACHED_HEAD });
|
|
153
144
|
if (args.onto !== undefined && !isSafeGitRefToken(args.onto)) {
|
|
154
|
-
return jsonRespond({ error:
|
|
145
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.onto });
|
|
155
146
|
}
|
|
156
147
|
// --- Refuse dirty tree ---
|
|
157
148
|
if (!(await isWorkingTreeClean(gitTop))) {
|
|
158
|
-
return jsonRespond({ error:
|
|
149
|
+
return jsonRespond({ error: ERROR_CODES.WORKING_TREE_DIRTY });
|
|
159
150
|
}
|
|
160
151
|
// --- Ensure destination is checked out ---
|
|
161
152
|
if (onto !== startBranch) {
|
|
162
153
|
const co = await spawnGitAsync(gitTop, ["checkout", onto]);
|
|
163
154
|
if (!co.ok) {
|
|
164
155
|
return jsonRespond({
|
|
165
|
-
error:
|
|
156
|
+
error: ERROR_CODES.CHECKOUT_FAILED,
|
|
166
157
|
detail: (co.stderr || co.stdout).trim(),
|
|
167
158
|
});
|
|
168
159
|
}
|
|
169
160
|
}
|
|
170
161
|
if (!(await resolveRef(gitTop, onto))) {
|
|
171
|
-
return jsonRespond({ error:
|
|
162
|
+
return jsonRespond({ error: ERROR_CODES.DESTINATION_NOT_FOUND, ref: onto });
|
|
172
163
|
}
|
|
173
164
|
// --- Resolve each source ---
|
|
174
165
|
const resolved = [];
|
|
@@ -217,7 +208,7 @@ export function registerGitCherryPickTool(server) {
|
|
|
217
208
|
if (allOk) {
|
|
218
209
|
for (let i = 0; i < perSourceReport.length; i++) {
|
|
219
210
|
const src = perSourceReport[i];
|
|
220
|
-
if (
|
|
211
|
+
if (src?.kind !== "branch")
|
|
221
212
|
continue;
|
|
222
213
|
if (isProtectedBranch(src.raw))
|
|
223
214
|
continue;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { matchesGlob } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
3
4
|
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
4
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
6
|
import { requireSingleRepo } from "./roots.js";
|
|
@@ -178,9 +179,8 @@ function rangeLabel(range, diffArgs) {
|
|
|
178
179
|
export function registerGitDiffSummaryTool(server) {
|
|
179
180
|
server.addTool({
|
|
180
181
|
name: "git_diff_summary",
|
|
181
|
-
description: "Structured
|
|
182
|
-
"
|
|
183
|
-
"Use `range` to target staged, HEAD, or any revision range.",
|
|
182
|
+
description: "Structured diff viewer: per-file diffs with counts, truncated to configurable limits. " +
|
|
183
|
+
"Noise files (lock files, dist, etc.) excluded by default. Use `range` to target staged, HEAD, or a revision range.",
|
|
184
184
|
annotations: {
|
|
185
185
|
readOnlyHint: true,
|
|
186
186
|
},
|
|
@@ -213,7 +213,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
213
213
|
excludePatterns: z
|
|
214
214
|
.array(z.string())
|
|
215
215
|
.optional()
|
|
216
|
-
.describe("Glob patterns to exclude.
|
|
216
|
+
.describe("Glob patterns to exclude. Default: lock files, dist, vendor, etc."),
|
|
217
217
|
}),
|
|
218
218
|
execute: async (args) => {
|
|
219
219
|
const pre = requireSingleRepo(server, args);
|
|
@@ -230,7 +230,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
230
230
|
const statResult = await spawnGitAsync(gitTop, ["diff", "--numstat", ...diffArgs]);
|
|
231
231
|
if (!statResult.ok) {
|
|
232
232
|
return jsonRespond({
|
|
233
|
-
error:
|
|
233
|
+
error: ERROR_CODES.GIT_DIFF_FAILED,
|
|
234
234
|
detail: (statResult.stderr || statResult.stdout).trim(),
|
|
235
235
|
});
|
|
236
236
|
}
|
|
@@ -239,7 +239,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
239
239
|
const diffResult = await spawnGitAsync(gitTop, ["diff", ...diffArgs]);
|
|
240
240
|
if (!diffResult.ok) {
|
|
241
241
|
return jsonRespond({
|
|
242
|
-
error:
|
|
242
|
+
error: ERROR_CODES.GIT_DIFF_FAILED,
|
|
243
243
|
detail: (diffResult.stderr || diffResult.stdout).trim(),
|
|
244
244
|
});
|
|
245
245
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
4
|
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
3
5
|
import { jsonRespond } from "./json.js";
|
|
4
6
|
import { requireSingleRepo } from "./roots.js";
|
|
@@ -19,14 +21,18 @@ function buildDiffArgs(opts) {
|
|
|
19
21
|
const baseStr = opts.base?.trim() ?? "HEAD";
|
|
20
22
|
const headStr = opts.head?.trim() ?? "HEAD";
|
|
21
23
|
if (!isSafeGitUpstreamToken(baseStr) || !isSafeGitUpstreamToken(headStr)) {
|
|
22
|
-
return { ok: false, error:
|
|
24
|
+
return { ok: false, error: ERROR_CODES.UNSAFE_RANGE_TOKEN };
|
|
23
25
|
}
|
|
24
26
|
// Use two-dot range: base..head
|
|
25
27
|
args.push(`${baseStr}..${headStr}`);
|
|
26
28
|
}
|
|
27
|
-
//
|
|
28
|
-
if (opts.
|
|
29
|
-
args.push(
|
|
29
|
+
// Apply unified context width if specified
|
|
30
|
+
if (typeof opts.unified === "number") {
|
|
31
|
+
args.push(`-U${opts.unified}`);
|
|
32
|
+
}
|
|
33
|
+
// Scope to paths if provided
|
|
34
|
+
if (opts.paths && opts.paths.length > 0) {
|
|
35
|
+
args.push("--", ...opts.paths);
|
|
30
36
|
}
|
|
31
37
|
return { ok: true, args };
|
|
32
38
|
}
|
|
@@ -44,8 +50,8 @@ function rangeLabel(opts) {
|
|
|
44
50
|
else {
|
|
45
51
|
label = "unstaged changes";
|
|
46
52
|
}
|
|
47
|
-
if (opts.
|
|
48
|
-
label += ` (${opts.
|
|
53
|
+
if (opts.paths && opts.paths.length > 0) {
|
|
54
|
+
label += ` (${opts.paths.join(", ")})`;
|
|
49
55
|
}
|
|
50
56
|
return label;
|
|
51
57
|
}
|
|
@@ -55,9 +61,8 @@ function rangeLabel(opts) {
|
|
|
55
61
|
export function registerGitDiffTool(server) {
|
|
56
62
|
server.addTool({
|
|
57
63
|
name: "git_diff",
|
|
58
|
-
description: "
|
|
59
|
-
"
|
|
60
|
-
"and `path` to scope to a specific file.",
|
|
64
|
+
description: "Raw diff text for scoped file(s) or range. `staged: true` for staged changes, " +
|
|
65
|
+
"`base`/`head` for revision ranges, `path`/`paths` to scope, `unified` for context lines.",
|
|
61
66
|
annotations: {
|
|
62
67
|
readOnlyHint: true,
|
|
63
68
|
},
|
|
@@ -68,33 +73,64 @@ export function registerGitDiffTool(server) {
|
|
|
68
73
|
base: z
|
|
69
74
|
.string()
|
|
70
75
|
.optional()
|
|
71
|
-
.describe('Base ref (e.g. "main", "HEAD~3").
|
|
72
|
-
"If omitted and `staged: false`, shows unstaged changes."),
|
|
76
|
+
.describe('Base ref (e.g. "main", "HEAD~3"). Omit for unstaged changes.'),
|
|
73
77
|
head: z
|
|
74
78
|
.string()
|
|
75
79
|
.optional()
|
|
76
|
-
.describe('Head ref (e.g. "feature-branch").
|
|
77
|
-
"Only used if `base` is provided."),
|
|
80
|
+
.describe('Head ref (e.g. "feature-branch"). Defaults to HEAD. Used only when `base` is set.'),
|
|
78
81
|
path: z
|
|
79
82
|
.string()
|
|
80
83
|
.optional()
|
|
81
|
-
.describe(
|
|
84
|
+
.describe("Scope to a single file. Unioned with `paths` if both given."),
|
|
85
|
+
paths: z
|
|
86
|
+
.array(z.string())
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("Scope to multiple files (must be within repo root). Unioned with `path` if both given."),
|
|
82
89
|
staged: z
|
|
83
90
|
.boolean()
|
|
84
91
|
.optional()
|
|
85
92
|
.default(false)
|
|
86
|
-
.describe("
|
|
93
|
+
.describe("Show staged changes (`git diff --staged`). Ignored if `base` is set."),
|
|
94
|
+
unified: z
|
|
95
|
+
.number()
|
|
96
|
+
.int()
|
|
97
|
+
.min(0)
|
|
98
|
+
.max(100)
|
|
99
|
+
.optional()
|
|
100
|
+
.describe("Context lines around each change (`-U<n>`). Default: 3. Use 0 for no context."),
|
|
87
101
|
}),
|
|
88
102
|
execute: async (args) => {
|
|
89
103
|
const pre = requireSingleRepo(server, args);
|
|
90
104
|
if (!pre.ok)
|
|
91
105
|
return jsonRespond(pre.error);
|
|
92
106
|
const gitTop = pre.gitTop;
|
|
107
|
+
// Union path + paths, trim, dedup
|
|
108
|
+
const rawPaths = [];
|
|
109
|
+
if (args.path && typeof args.path === "string" && args.path.trim()) {
|
|
110
|
+
rawPaths.push(args.path.trim());
|
|
111
|
+
}
|
|
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
|
+
// Dedup preserving order
|
|
120
|
+
const dedupedPaths = [...new Set(rawPaths)];
|
|
121
|
+
// Confine each path within the repo
|
|
122
|
+
for (const p of dedupedPaths) {
|
|
123
|
+
const resolved = resolvePathForRepo(p, gitTop);
|
|
124
|
+
if (!assertRelativePathUnderTop(p, resolved, gitTop)) {
|
|
125
|
+
return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: p });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
93
128
|
// Build git diff args
|
|
94
129
|
const diffArgsResult = buildDiffArgs({
|
|
95
130
|
base: args.base,
|
|
96
131
|
head: args.head,
|
|
97
|
-
|
|
132
|
+
paths: dedupedPaths.length > 0 ? dedupedPaths : undefined,
|
|
133
|
+
unified: typeof args.unified === "number" ? args.unified : undefined,
|
|
98
134
|
staged: args.staged,
|
|
99
135
|
});
|
|
100
136
|
if (!diffArgsResult.ok) {
|
|
@@ -104,14 +140,14 @@ export function registerGitDiffTool(server) {
|
|
|
104
140
|
const result = await spawnGitAsync(gitTop, diffArgsResult.args);
|
|
105
141
|
if (!result.ok) {
|
|
106
142
|
return jsonRespond({
|
|
107
|
-
error:
|
|
143
|
+
error: ERROR_CODES.GIT_DIFF_FAILED,
|
|
108
144
|
detail: (result.stderr || result.stdout).trim(),
|
|
109
145
|
});
|
|
110
146
|
}
|
|
111
147
|
const label = rangeLabel({
|
|
112
148
|
base: args.base,
|
|
113
149
|
head: args.head,
|
|
114
|
-
|
|
150
|
+
paths: dedupedPaths.length > 0 ? dedupedPaths : undefined,
|
|
115
151
|
staged: args.staged,
|
|
116
152
|
});
|
|
117
153
|
if (args.format === "json") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
3
|
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
3
|
-
import { jsonRespond } from "./json.js";
|
|
4
|
+
import { jsonRespond, spreadWhen } from "./json.js";
|
|
4
5
|
import { requireSingleRepo } from "./roots.js";
|
|
5
6
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -30,6 +31,61 @@ function parseGitFetchOutput(output) {
|
|
|
30
31
|
}
|
|
31
32
|
return { updatedRefs, newRefs };
|
|
32
33
|
}
|
|
34
|
+
const ZEROS_SHA = "0000000000000000000000000000000000000000";
|
|
35
|
+
/**
|
|
36
|
+
* Parse `git fetch --porcelain` stdout.
|
|
37
|
+
*
|
|
38
|
+
* Machine-readable lines have the form:
|
|
39
|
+
* <flag><SP><old-sha><SP><new-sha><SP><local-ref>
|
|
40
|
+
*
|
|
41
|
+
* Flags:
|
|
42
|
+
* ' ' = fast-forward update
|
|
43
|
+
* '+' = forced update
|
|
44
|
+
* '*' = new ref
|
|
45
|
+
* '-' = pruned (deleted)
|
|
46
|
+
* '!' = rejected
|
|
47
|
+
* '=' = up-to-date (no change)
|
|
48
|
+
* 't' = tag update
|
|
49
|
+
*/
|
|
50
|
+
function parsePorcelainOutput(stdout) {
|
|
51
|
+
const updated = [];
|
|
52
|
+
const created = [];
|
|
53
|
+
const pruned = [];
|
|
54
|
+
for (const line of stdout.split("\n")) {
|
|
55
|
+
if (!line)
|
|
56
|
+
continue;
|
|
57
|
+
// Porcelain lines: flag(1) + space(1) + old-sha + space + new-sha + space + ref
|
|
58
|
+
// Minimum viable line: 1 flag + 1 space + at least some content
|
|
59
|
+
if (line.length < 3)
|
|
60
|
+
continue;
|
|
61
|
+
const flag = line[0];
|
|
62
|
+
const rest = line.slice(2); // skip "flag " prefix
|
|
63
|
+
const parts = rest.split(" ");
|
|
64
|
+
// Expected: [old-sha, new-sha, ref...]
|
|
65
|
+
if (parts.length < 3)
|
|
66
|
+
continue;
|
|
67
|
+
const oldSha = parts[0] ?? "";
|
|
68
|
+
const newSha = parts[1] ?? "";
|
|
69
|
+
const ref = parts.slice(2).join(" ");
|
|
70
|
+
if (!ref)
|
|
71
|
+
continue;
|
|
72
|
+
if (flag === "-") {
|
|
73
|
+
pruned.push({ ref });
|
|
74
|
+
}
|
|
75
|
+
else if (flag === "*" || oldSha === ZEROS_SHA) {
|
|
76
|
+
// New ref: flag is '*' OR old-sha is all-zeros
|
|
77
|
+
created.push({ ref, newSha, flag: flag ?? "*" });
|
|
78
|
+
}
|
|
79
|
+
else if (flag === " " || flag === "+" || flag === "t") {
|
|
80
|
+
// Update: old and new differ
|
|
81
|
+
if (oldSha !== newSha) {
|
|
82
|
+
updated.push({ ref, oldSha, newSha, flag: flag ?? " " });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// '!' (rejected) and '=' (up-to-date) are intentionally ignored
|
|
86
|
+
}
|
|
87
|
+
return { updated, created, pruned };
|
|
88
|
+
}
|
|
33
89
|
// ---------------------------------------------------------------------------
|
|
34
90
|
// Tool registration
|
|
35
91
|
// ---------------------------------------------------------------------------
|
|
@@ -80,31 +136,80 @@ export function registerGitFetchTool(server) {
|
|
|
80
136
|
const tags = args.tags === true;
|
|
81
137
|
// Validate remote and branch to prevent argument injection.
|
|
82
138
|
if (!isSafeGitUpstreamToken(remote)) {
|
|
83
|
-
return jsonRespond({ error:
|
|
139
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REMOTE_TOKEN, remote });
|
|
84
140
|
}
|
|
85
141
|
if (branch && !isSafeGitUpstreamToken(branch)) {
|
|
86
|
-
return jsonRespond({ error:
|
|
142
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, branch });
|
|
87
143
|
}
|
|
88
|
-
// Build
|
|
89
|
-
const
|
|
144
|
+
// Build base fetch args (without --porcelain for now)
|
|
145
|
+
const baseArgs = ["fetch"];
|
|
90
146
|
if (prune) {
|
|
91
|
-
|
|
147
|
+
baseArgs.push("--prune");
|
|
92
148
|
}
|
|
93
149
|
if (tags) {
|
|
94
|
-
|
|
150
|
+
baseArgs.push("--tags");
|
|
95
151
|
}
|
|
96
|
-
|
|
152
|
+
baseArgs.push(remote);
|
|
97
153
|
if (branch) {
|
|
98
|
-
|
|
154
|
+
baseArgs.push(branch);
|
|
155
|
+
}
|
|
156
|
+
// --- Attempt structured fetch with --porcelain (requires git >= 2.41) ---
|
|
157
|
+
let structured = null;
|
|
158
|
+
let result;
|
|
159
|
+
const porcelainArgs = ["fetch", "--porcelain"];
|
|
160
|
+
if (prune)
|
|
161
|
+
porcelainArgs.push("--prune");
|
|
162
|
+
if (tags)
|
|
163
|
+
porcelainArgs.push("--tags");
|
|
164
|
+
porcelainArgs.push(remote);
|
|
165
|
+
if (branch)
|
|
166
|
+
porcelainArgs.push(branch);
|
|
167
|
+
const porcelainResult = await spawnGitAsync(gitTop, porcelainArgs);
|
|
168
|
+
if (!porcelainResult.ok &&
|
|
169
|
+
(porcelainResult.stderr.includes("unknown option") ||
|
|
170
|
+
porcelainResult.stderr.includes("unknown switch") ||
|
|
171
|
+
porcelainResult.stderr.includes("invalid option"))) {
|
|
172
|
+
// --porcelain not supported: fall back to plain fetch
|
|
173
|
+
result = await spawnGitAsync(gitTop, baseArgs);
|
|
174
|
+
structured = null;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Use porcelain result (ok or actual fetch error)
|
|
178
|
+
result = porcelainResult;
|
|
179
|
+
if (porcelainResult.ok || !porcelainResult.stderr.includes("unknown option")) {
|
|
180
|
+
structured = parsePorcelainOutput(porcelainResult.stdout);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Legacy string-line parse: use combined output when porcelain is unavailable.
|
|
184
|
+
// When porcelain succeeded, derive legacy fields from structured data so
|
|
185
|
+
// that callers who rely on updatedRefs/newRefs still get useful values.
|
|
186
|
+
let updatedRefs;
|
|
187
|
+
let newRefs;
|
|
188
|
+
if (structured !== null) {
|
|
189
|
+
// Derive legacy string arrays from structured deltas
|
|
190
|
+
updatedRefs = structured.updated.map((d) => `${d.oldSha.slice(0, 7)}..${d.newSha.slice(0, 7)} ${d.ref}`);
|
|
191
|
+
newRefs = structured.created.map((d) => `[new ref] ${d.ref} -> ${d.newSha.slice(0, 7)}`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const parsed = parseGitFetchOutput(result.stdout + result.stderr);
|
|
195
|
+
updatedRefs = parsed.updatedRefs;
|
|
196
|
+
newRefs = parsed.newRefs;
|
|
99
197
|
}
|
|
100
|
-
const result = await spawnGitAsync(gitTop, fetchArgs);
|
|
101
|
-
const { updatedRefs, newRefs } = parseGitFetchOutput(result.stdout + result.stderr);
|
|
102
198
|
const fetchResult = {
|
|
103
199
|
ok: result.ok,
|
|
104
200
|
remote,
|
|
105
201
|
updatedRefs,
|
|
106
202
|
newRefs,
|
|
107
203
|
output: (result.stdout + result.stderr).trim(),
|
|
204
|
+
...spreadWhen(structured !== null && structured.updated.length > 0, {
|
|
205
|
+
updated: structured?.updated ?? [],
|
|
206
|
+
}),
|
|
207
|
+
...spreadWhen(structured !== null && structured.created.length > 0, {
|
|
208
|
+
created: structured?.created ?? [],
|
|
209
|
+
}),
|
|
210
|
+
...spreadWhen(structured !== null && structured.pruned.length > 0, {
|
|
211
|
+
pruned: structured?.pruned ?? [],
|
|
212
|
+
}),
|
|
108
213
|
};
|
|
109
214
|
if (args.format === "json") {
|
|
110
215
|
return jsonRespond(fetchResult);
|
|
@@ -117,19 +222,42 @@ export function registerGitFetchTool(server) {
|
|
|
117
222
|
return lines.join("\n");
|
|
118
223
|
}
|
|
119
224
|
lines.push("", "**Status**: Success", "");
|
|
120
|
-
|
|
225
|
+
// Prefer structured deltas in markdown when available
|
|
226
|
+
if (structured !== null && structured.updated.length > 0) {
|
|
227
|
+
lines.push("## Updated refs", "");
|
|
228
|
+
for (const d of structured.updated) {
|
|
229
|
+
lines.push(`- \`${d.ref}\` ${d.oldSha.slice(0, 7)}→${d.newSha.slice(0, 7)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (updatedRefs.length > 0) {
|
|
121
233
|
lines.push("## Updated refs", "");
|
|
122
234
|
for (const ref of updatedRefs) {
|
|
123
235
|
lines.push(`- ${ref}`);
|
|
124
236
|
}
|
|
125
237
|
}
|
|
126
|
-
if (
|
|
238
|
+
if (structured !== null && structured.created.length > 0) {
|
|
239
|
+
lines.push("", "## New refs", "");
|
|
240
|
+
for (const d of structured.created) {
|
|
241
|
+
lines.push(`- \`${d.ref}\` (new, ${d.newSha.slice(0, 7)})`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (newRefs.length > 0) {
|
|
127
245
|
lines.push("", "## New refs", "");
|
|
128
246
|
for (const ref of newRefs) {
|
|
129
247
|
lines.push(`- ${ref}`);
|
|
130
248
|
}
|
|
131
249
|
}
|
|
132
|
-
if (
|
|
250
|
+
if (structured !== null && structured.pruned.length > 0) {
|
|
251
|
+
lines.push("", "## Pruned refs", "");
|
|
252
|
+
for (const d of structured.pruned) {
|
|
253
|
+
lines.push(`- \`${d.ref}\` (deleted)`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (updatedRefs.length === 0 &&
|
|
257
|
+
newRefs.length === 0 &&
|
|
258
|
+
(structured === null ||
|
|
259
|
+
(structured.updated.length === 0 && structured.created.length === 0)) &&
|
|
260
|
+
result.stdout.trim()) {
|
|
133
261
|
lines.push("", "## Output", "", "```", result.stdout.trim(), "```");
|
|
134
262
|
}
|
|
135
263
|
return lines.join("\n");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
3
|
import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitRevParseGitDir, gitTopLevel, isSafeGitUpstreamToken, } from "./git.js";
|
|
3
4
|
import { buildInventorySectionMarkdown, collectInventoryEntry, makeSkipEntry, validateRepoPath, } from "./inventory.js";
|
|
4
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
@@ -27,7 +28,7 @@ export function registerGitInventoryTool(server) {
|
|
|
27
28
|
execute: async (args) => {
|
|
28
29
|
if (args.absoluteGitRoots != null && args.absoluteGitRoots.length > 0) {
|
|
29
30
|
if (args.preset || (args.nestedRoots?.length ?? 0) > 0) {
|
|
30
|
-
return jsonRespond({ error:
|
|
31
|
+
return jsonRespond({ error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_NESTED_OR_PRESET_CONFLICT });
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
const pre = requireGitAndRoots(server, args, args.preset);
|
|
@@ -39,12 +40,12 @@ export function registerGitInventoryTool(server) {
|
|
|
39
40
|
const hasRemote = rawRemote !== undefined && rawRemote !== "";
|
|
40
41
|
const hasBranch = rawBranch !== undefined && rawBranch !== "";
|
|
41
42
|
if (hasRemote !== hasBranch) {
|
|
42
|
-
return jsonRespond({ error:
|
|
43
|
+
return jsonRespond({ error: ERROR_CODES.REMOTE_BRANCH_MISMATCH });
|
|
43
44
|
}
|
|
44
45
|
let upstream = { mode: "auto" };
|
|
45
46
|
if (hasRemote && hasBranch && rawRemote && rawBranch) {
|
|
46
47
|
if (!isSafeGitUpstreamToken(rawRemote) || !isSafeGitUpstreamToken(rawBranch)) {
|
|
47
|
-
return jsonRespond({ error:
|
|
48
|
+
return jsonRespond({ error: ERROR_CODES.INVALID_REMOTE_OR_BRANCH });
|
|
48
49
|
}
|
|
49
50
|
upstream = { mode: "fixed", remote: rawRemote, branch: rawBranch };
|
|
50
51
|
}
|
|
@@ -54,7 +55,7 @@ export function registerGitInventoryTool(server) {
|
|
|
54
55
|
for (const workspaceRoot of pre.roots) {
|
|
55
56
|
const top = gitTopLevel(workspaceRoot);
|
|
56
57
|
if (!top) {
|
|
57
|
-
const err = { error:
|
|
58
|
+
const err = { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: workspaceRoot };
|
|
58
59
|
if (args.format === "json") {
|
|
59
60
|
allJson.push({
|
|
60
61
|
workspaceRoot: workspaceRoot,
|