@rethunk/mcp-multi-root-git 2.6.0 → 2.8.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 +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/dist/server/batch-commit-tool.js +7 -6
- 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 +140 -0
- package/dist/server/git-cherry-pick-tool.js +11 -10
- package/dist/server/git-diff-summary-tool.js +3 -2
- package/dist/server/git-diff-tool.js +56 -12
- 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 +10 -5
- package/dist/server/git-merge-tool.js +15 -14
- 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 +4 -3
- package/dist/server/git-show-tool.js +83 -23
- package/dist/server/git-stash-tool.js +2 -1
- package/dist/server/git-tag-tool.js +8 -7
- package/dist/server/git-worktree-tool.js +8 -7
- 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/tool-parameter-schemas.js +9 -0
- package/dist/server/tools.js +6 -0
- package/docs/mcp-tools.md +119 -6
- package/package.json +2 -2
- 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 +152 -2
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
3
|
+
import { spawnGitAsync } from "./git.js";
|
|
4
|
+
import { isSafeGitRefToken } from "./git-refs.js";
|
|
5
|
+
import { jsonRespond } from "./json.js";
|
|
6
|
+
import { requireSingleRepo } from "./roots.js";
|
|
7
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Constants
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const MAX_ENTRIES_HARD_CAP = 200;
|
|
12
|
+
const DEFAULT_MAX_ENTRIES = 30;
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
/**
|
|
17
|
+
* Run git reflog for a single ref and return structured data.
|
|
18
|
+
* Uses NUL-delimited fields within each line for robust parsing.
|
|
19
|
+
*/
|
|
20
|
+
async function runGitReflog(opts) {
|
|
21
|
+
const { top, ref, maxEntries } = opts;
|
|
22
|
+
// %h = short sha, %H = full sha, %gd = selector (HEAD@{0}), %gs = reflog subject
|
|
23
|
+
const reflogArgs = [
|
|
24
|
+
"reflog",
|
|
25
|
+
"show",
|
|
26
|
+
ref,
|
|
27
|
+
"--format=%h%x00%H%x00%gd%x00%gs",
|
|
28
|
+
`-n`,
|
|
29
|
+
String(maxEntries),
|
|
30
|
+
];
|
|
31
|
+
const r = await spawnGitAsync(top, reflogArgs);
|
|
32
|
+
if (!r.ok) {
|
|
33
|
+
return {
|
|
34
|
+
error: ERROR_CODES.REFLOG_FAILED,
|
|
35
|
+
detail: (r.stderr || r.stdout || "git reflog failed").trim(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const entries = [];
|
|
39
|
+
const lines = r.stdout.split("\n");
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (!line.trim())
|
|
42
|
+
continue;
|
|
43
|
+
const parts = line.split("\x00");
|
|
44
|
+
// Expect 4 fields: shaShort, shaFull, selector, message
|
|
45
|
+
if (parts.length < 4)
|
|
46
|
+
continue;
|
|
47
|
+
const [, shaFull, selector, message] = parts;
|
|
48
|
+
if (!shaFull)
|
|
49
|
+
continue;
|
|
50
|
+
entries.push({
|
|
51
|
+
sha: shaFull.trim(),
|
|
52
|
+
selector: (selector ?? "").trim(),
|
|
53
|
+
message: (message ?? "").trim(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return { ref, entries };
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Markdown rendering
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
function renderReflogMarkdown(result) {
|
|
62
|
+
const lines = [];
|
|
63
|
+
lines.push(`## Reflog (${result.ref})`);
|
|
64
|
+
lines.push("");
|
|
65
|
+
if (result.entries.length === 0) {
|
|
66
|
+
lines.push("_(no reflog entries)_");
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
for (const entry of result.entries) {
|
|
70
|
+
lines.push(`${entry.selector} ${entry.sha.slice(0, 7)} ${entry.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return lines.join("\n");
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Tool registration
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
export function registerGitReflogTool(server) {
|
|
79
|
+
server.addTool({
|
|
80
|
+
name: "git_reflog",
|
|
81
|
+
description: "Show the reflog for a ref (default HEAD). Returns a list of recent HEAD movements with selector (e.g. HEAD@{0}), full SHA, and message. Useful for recovering lost commits or inspecting reset/checkout history.",
|
|
82
|
+
annotations: {
|
|
83
|
+
readOnlyHint: true,
|
|
84
|
+
},
|
|
85
|
+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
|
|
86
|
+
.pick({
|
|
87
|
+
workspaceRoot: true,
|
|
88
|
+
rootIndex: true,
|
|
89
|
+
format: true,
|
|
90
|
+
})
|
|
91
|
+
.extend({
|
|
92
|
+
ref: z
|
|
93
|
+
.string()
|
|
94
|
+
.optional()
|
|
95
|
+
.default("HEAD")
|
|
96
|
+
.describe("Ref whose reflog to show (branch name, HEAD, etc.). Default: HEAD."),
|
|
97
|
+
maxEntries: z
|
|
98
|
+
.number()
|
|
99
|
+
.int()
|
|
100
|
+
.min(1)
|
|
101
|
+
.max(MAX_ENTRIES_HARD_CAP)
|
|
102
|
+
.optional()
|
|
103
|
+
.default(DEFAULT_MAX_ENTRIES)
|
|
104
|
+
.describe(`Maximum reflog entries to return (hard cap ${MAX_ENTRIES_HARD_CAP}). Default ${DEFAULT_MAX_ENTRIES}.`),
|
|
105
|
+
}),
|
|
106
|
+
execute: async (args) => {
|
|
107
|
+
const pre = requireSingleRepo(server, args);
|
|
108
|
+
if (!pre.ok)
|
|
109
|
+
return jsonRespond(pre.error);
|
|
110
|
+
const top = pre.gitTop;
|
|
111
|
+
const ref = args.ref ?? "HEAD";
|
|
112
|
+
if (!isSafeGitRefToken(ref)) {
|
|
113
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref });
|
|
114
|
+
}
|
|
115
|
+
const maxEntries = Math.min(args.maxEntries ?? DEFAULT_MAX_ENTRIES, MAX_ENTRIES_HARD_CAP);
|
|
116
|
+
const result = await runGitReflog({ top, ref, maxEntries });
|
|
117
|
+
if ("error" in result) {
|
|
118
|
+
return jsonRespond(result);
|
|
119
|
+
}
|
|
120
|
+
if (args.format === "json") {
|
|
121
|
+
return jsonRespond(result);
|
|
122
|
+
}
|
|
123
|
+
return renderReflogMarkdown(result);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -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 { isSafeGitAncestorRef, isWorkingTreeClean } from "./git-refs.js";
|
|
4
5
|
import { jsonRespond, spreadDefined } from "./json.js";
|
|
@@ -30,12 +31,12 @@ export function registerGitResetSoftTool(server) {
|
|
|
30
31
|
const { gitTop } = pre;
|
|
31
32
|
// Validate ref — allow ancestor notation (~N, ^N).
|
|
32
33
|
if (!isSafeGitAncestorRef(args.ref)) {
|
|
33
|
-
return jsonRespond({ error:
|
|
34
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
|
|
34
35
|
}
|
|
35
36
|
// Refuse when the working tree is dirty (unstaged or untracked changes).
|
|
36
37
|
if (!(await isWorkingTreeClean(gitTop))) {
|
|
37
38
|
return jsonRespond({
|
|
38
|
-
error:
|
|
39
|
+
error: ERROR_CODES.WORKING_TREE_DIRTY,
|
|
39
40
|
detail: "git_reset_soft requires a clean working tree. " +
|
|
40
41
|
"Commit or stash pending changes first.",
|
|
41
42
|
});
|
|
@@ -47,7 +48,7 @@ export function registerGitResetSoftTool(server) {
|
|
|
47
48
|
const r = await spawnGitAsync(gitTop, ["reset", "--soft", args.ref]);
|
|
48
49
|
if (!r.ok) {
|
|
49
50
|
return jsonRespond({
|
|
50
|
-
error:
|
|
51
|
+
error: ERROR_CODES.RESET_FAILED,
|
|
51
52
|
detail: (r.stderr || r.stdout).trim(),
|
|
52
53
|
});
|
|
53
54
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
3
4
|
import { spawnGitAsync } from "./git.js";
|
|
4
5
|
import { isSafeGitAncestorRef } from "./git-refs.js";
|
|
5
6
|
import { jsonRespond } from "./json.js";
|
|
@@ -9,22 +10,35 @@ import { WorkspacePickSchema } from "./schemas.js";
|
|
|
9
10
|
// Helpers
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
/**
|
|
12
|
-
* Run git show for a single ref, optionally limiting to
|
|
13
|
-
*
|
|
13
|
+
* Run git show for a single ref, optionally limiting to specific paths and/or
|
|
14
|
+
* showing only the --stat diffstat rather than the full patch.
|
|
15
|
+
* Returns commit message and diff/stat output.
|
|
14
16
|
*/
|
|
15
17
|
async function runGitShow(opts) {
|
|
16
|
-
const { top, ref, path } = opts;
|
|
17
|
-
//
|
|
18
|
-
const
|
|
19
|
-
if (path)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
const { top, ref, path, paths, stat } = opts;
|
|
19
|
+
// Merge single path + paths array into a unified list (deduped, order preserved).
|
|
20
|
+
const effectivePaths = [];
|
|
21
|
+
if (path)
|
|
22
|
+
effectivePaths.push(path);
|
|
23
|
+
if (paths) {
|
|
24
|
+
for (const p of paths) {
|
|
25
|
+
if (!effectivePaths.includes(p))
|
|
26
|
+
effectivePaths.push(p);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Build git show args. Shows commit message + full diff (or --stat diffstat).
|
|
30
|
+
const showArgs = ["show"];
|
|
31
|
+
if (stat) {
|
|
32
|
+
showArgs.push("--stat");
|
|
33
|
+
}
|
|
34
|
+
showArgs.push(ref);
|
|
35
|
+
if (effectivePaths.length > 0) {
|
|
36
|
+
showArgs.push("--", ...effectivePaths);
|
|
23
37
|
}
|
|
24
38
|
const r = await spawnGitAsync(top, showArgs);
|
|
25
39
|
if (!r.ok) {
|
|
26
40
|
return {
|
|
27
|
-
error:
|
|
41
|
+
error: ERROR_CODES.GIT_SHOW_FAILED,
|
|
28
42
|
};
|
|
29
43
|
}
|
|
30
44
|
// Parse the output. For a commit, git show outputs:
|
|
@@ -32,10 +46,9 @@ async function runGitShow(opts) {
|
|
|
32
46
|
// - Blank line
|
|
33
47
|
// - Commit message (may contain multiple lines and blank lines)
|
|
34
48
|
// - Blank line (separator before diff)
|
|
35
|
-
// - Diff
|
|
49
|
+
// - Diff or --stat diffstat section
|
|
36
50
|
const output = r.stdout;
|
|
37
51
|
let message = "";
|
|
38
|
-
let diff = "";
|
|
39
52
|
const lines = output.split("\n");
|
|
40
53
|
let inHeader = true;
|
|
41
54
|
let inMessage = false;
|
|
@@ -50,9 +63,14 @@ async function runGitShow(opts) {
|
|
|
50
63
|
inMessage = true;
|
|
51
64
|
continue;
|
|
52
65
|
}
|
|
53
|
-
// In message: collect until we see "diff --git" which marks the start of the diff section
|
|
54
66
|
if (inMessage) {
|
|
55
|
-
|
|
67
|
+
// In stat mode: content starts at the first line that looks like a stat entry
|
|
68
|
+
// (indented file path) or the summary line "N files changed".
|
|
69
|
+
// In diff mode: content starts at "diff --git".
|
|
70
|
+
const isStatLine = stat &&
|
|
71
|
+
(line.match(/^\s+\S.*\|/) !== null || line.match(/^\s*\d+ files? changed/) !== null);
|
|
72
|
+
const isDiffLine = !stat && line.startsWith("diff --git");
|
|
73
|
+
if (isStatLine || isDiffLine) {
|
|
56
74
|
inMessage = false;
|
|
57
75
|
contentLines.push(line);
|
|
58
76
|
}
|
|
@@ -66,16 +84,26 @@ async function runGitShow(opts) {
|
|
|
66
84
|
}
|
|
67
85
|
}
|
|
68
86
|
message = messageLines.join("\n").trim();
|
|
69
|
-
|
|
87
|
+
const contentStr = contentLines.join("\n").trim();
|
|
70
88
|
const result = {
|
|
71
89
|
ref,
|
|
72
90
|
message,
|
|
73
91
|
};
|
|
74
|
-
|
|
92
|
+
// Reflect single legacy path (for backward-compat) and new paths[] in result.
|
|
93
|
+
if (path && !paths) {
|
|
75
94
|
result.path = path;
|
|
76
95
|
}
|
|
77
|
-
if (
|
|
78
|
-
result.
|
|
96
|
+
else if (effectivePaths.length > 0) {
|
|
97
|
+
result.paths = effectivePaths;
|
|
98
|
+
}
|
|
99
|
+
if (stat) {
|
|
100
|
+
result.stat = true;
|
|
101
|
+
if (contentStr) {
|
|
102
|
+
result.statOutput = contentStr;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else if (contentStr) {
|
|
106
|
+
result.diff = contentStr;
|
|
79
107
|
}
|
|
80
108
|
return result;
|
|
81
109
|
}
|
|
@@ -88,13 +116,27 @@ function renderShowMarkdown(result) {
|
|
|
88
116
|
if (result.path) {
|
|
89
117
|
lines.push(`_path: ${result.path}_`);
|
|
90
118
|
}
|
|
119
|
+
else if (result.paths && result.paths.length > 0) {
|
|
120
|
+
lines.push(`_paths: ${result.paths.join(", ")}_`);
|
|
121
|
+
}
|
|
122
|
+
if (result.stat) {
|
|
123
|
+
lines.push("_mode: stat_");
|
|
124
|
+
}
|
|
91
125
|
lines.push("");
|
|
92
126
|
lines.push("## Commit message");
|
|
93
127
|
lines.push("");
|
|
94
128
|
lines.push("```");
|
|
95
129
|
lines.push(result.message);
|
|
96
130
|
lines.push("```");
|
|
97
|
-
if (result.
|
|
131
|
+
if (result.stat && result.statOutput) {
|
|
132
|
+
lines.push("");
|
|
133
|
+
lines.push("## Stat");
|
|
134
|
+
lines.push("");
|
|
135
|
+
lines.push("```");
|
|
136
|
+
lines.push(result.statOutput);
|
|
137
|
+
lines.push("```");
|
|
138
|
+
}
|
|
139
|
+
else if (result.diff) {
|
|
98
140
|
lines.push("");
|
|
99
141
|
lines.push("## Diff");
|
|
100
142
|
lines.push("");
|
|
@@ -110,7 +152,7 @@ function renderShowMarkdown(result) {
|
|
|
110
152
|
export function registerGitShowTool(server) {
|
|
111
153
|
server.addTool({
|
|
112
154
|
name: "git_show",
|
|
113
|
-
description: "Inspect commit content by ref/SHA. Returns commit message and diff (or
|
|
155
|
+
description: "Inspect commit content by ref/SHA. Returns commit message and diff (or --stat diffstat when stat:true). Optionally filter to specific paths via path or paths[].",
|
|
114
156
|
annotations: {
|
|
115
157
|
readOnlyHint: true,
|
|
116
158
|
},
|
|
@@ -128,7 +170,15 @@ export function registerGitShowTool(server) {
|
|
|
128
170
|
path: z
|
|
129
171
|
.string()
|
|
130
172
|
.optional()
|
|
131
|
-
.describe("Optional file path to inspect at the ref.
|
|
173
|
+
.describe("Optional single file path to inspect at the ref. Merged with `paths` when both are provided."),
|
|
174
|
+
paths: z
|
|
175
|
+
.array(z.string())
|
|
176
|
+
.optional()
|
|
177
|
+
.describe("Optional list of file paths to filter the shown diff/stat. Merged with `path` when both are provided."),
|
|
178
|
+
stat: z
|
|
179
|
+
.boolean()
|
|
180
|
+
.optional()
|
|
181
|
+
.describe("When true, show --stat diffstat (files changed summary) instead of the full patch."),
|
|
132
182
|
}),
|
|
133
183
|
execute: async (args) => {
|
|
134
184
|
const pre = requireSingleRepo(server, args);
|
|
@@ -136,18 +186,28 @@ export function registerGitShowTool(server) {
|
|
|
136
186
|
return jsonRespond(pre.error);
|
|
137
187
|
const top = pre.gitTop;
|
|
138
188
|
if (!isSafeGitAncestorRef(args.ref)) {
|
|
139
|
-
return jsonRespond({ error:
|
|
189
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
|
|
140
190
|
}
|
|
141
191
|
if (args.path !== undefined) {
|
|
142
192
|
const resolved = resolvePathForRepo(args.path, top);
|
|
143
193
|
if (!assertRelativePathUnderTop(args.path, resolved, top)) {
|
|
144
|
-
return jsonRespond({ error:
|
|
194
|
+
return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: args.path });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (Array.isArray(args.paths)) {
|
|
198
|
+
for (const p of args.paths) {
|
|
199
|
+
const resolved = resolvePathForRepo(p, top);
|
|
200
|
+
if (!assertRelativePathUnderTop(p, resolved, top)) {
|
|
201
|
+
return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: p });
|
|
202
|
+
}
|
|
145
203
|
}
|
|
146
204
|
}
|
|
147
205
|
const result = await runGitShow({
|
|
148
206
|
top,
|
|
149
207
|
ref: args.ref,
|
|
150
208
|
path: args.path,
|
|
209
|
+
paths: args.paths,
|
|
210
|
+
stat: args.stat,
|
|
151
211
|
});
|
|
152
212
|
if ("error" in result) {
|
|
153
213
|
return jsonRespond(result);
|
|
@@ -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 { jsonRespond, spreadDefined } from "./json.js";
|
|
4
5
|
import { requireSingleRepo } from "./roots.js";
|
|
@@ -30,7 +31,7 @@ export function registerGitStashListTool(server) {
|
|
|
30
31
|
// If there are no stashes, git still returns ok=true with empty output
|
|
31
32
|
// Only treat as error if git itself failed
|
|
32
33
|
return jsonRespond({
|
|
33
|
-
error:
|
|
34
|
+
error: ERROR_CODES.STASH_LIST_FAILED,
|
|
34
35
|
detail: (r.stderr || r.stdout).trim(),
|
|
35
36
|
});
|
|
36
37
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
3
|
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
3
4
|
import { jsonRespond } from "./json.js";
|
|
4
5
|
import { requireSingleRepo } from "./roots.js";
|
|
@@ -72,18 +73,18 @@ export function registerGitTagTool(server) {
|
|
|
72
73
|
const gitTop = pre.gitTop;
|
|
73
74
|
const tag = args.tag.trim();
|
|
74
75
|
if (!tag) {
|
|
75
|
-
return jsonRespond({ error:
|
|
76
|
+
return jsonRespond({ error: ERROR_CODES.EMPTY_TAG_NAME });
|
|
76
77
|
}
|
|
77
78
|
// Validate tag name: no shell metacharacters
|
|
78
79
|
if (!isSafeGitUpstreamToken(tag)) {
|
|
79
|
-
return jsonRespond({ error:
|
|
80
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_TAG_TOKEN, tag });
|
|
80
81
|
}
|
|
81
82
|
// Handle deletion
|
|
82
83
|
if (args.delete === true) {
|
|
83
84
|
const delResult = await spawnGitAsync(gitTop, ["tag", "-d", tag]);
|
|
84
85
|
if (!delResult.ok) {
|
|
85
86
|
return jsonRespond({
|
|
86
|
-
error:
|
|
87
|
+
error: ERROR_CODES.TAG_DELETE_FAILED,
|
|
87
88
|
detail: (delResult.stderr || delResult.stdout).trim(),
|
|
88
89
|
});
|
|
89
90
|
}
|
|
@@ -99,13 +100,13 @@ export function registerGitTagTool(server) {
|
|
|
99
100
|
// Determine the ref to tag (default HEAD)
|
|
100
101
|
const ref = (args.ref ?? "HEAD").trim();
|
|
101
102
|
if (!isSafeGitUpstreamToken(ref)) {
|
|
102
|
-
return jsonRespond({ error:
|
|
103
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref });
|
|
103
104
|
}
|
|
104
105
|
// Get the SHA of the ref to tag
|
|
105
106
|
const sha = await getRefSha(gitTop, ref);
|
|
106
107
|
if (!sha) {
|
|
107
108
|
return jsonRespond({
|
|
108
|
-
error:
|
|
109
|
+
error: ERROR_CODES.REF_NOT_FOUND,
|
|
109
110
|
ref,
|
|
110
111
|
});
|
|
111
112
|
}
|
|
@@ -122,7 +123,7 @@ export function registerGitTagTool(server) {
|
|
|
122
123
|
const createResult = await spawnGitAsync(gitTop, tagArgs);
|
|
123
124
|
if (!createResult.ok) {
|
|
124
125
|
return jsonRespond({
|
|
125
|
-
error:
|
|
126
|
+
error: ERROR_CODES.TAG_CREATE_FAILED,
|
|
126
127
|
detail: (createResult.stderr || createResult.stdout).trim(),
|
|
127
128
|
});
|
|
128
129
|
}
|
|
@@ -130,7 +131,7 @@ export function registerGitTagTool(server) {
|
|
|
130
131
|
const tagType = await getTagType(gitTop, tag);
|
|
131
132
|
if (!tagType) {
|
|
132
133
|
return jsonRespond({
|
|
133
|
-
error:
|
|
134
|
+
error: ERROR_CODES.TAG_VERIFICATION_FAILED,
|
|
134
135
|
tag,
|
|
135
136
|
});
|
|
136
137
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
3
4
|
import { spawnGitAsync } from "./git.js";
|
|
4
5
|
import { isProtectedBranch, isSafeGitRefToken, listWorktrees, } from "./git-refs.js";
|
|
5
6
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
@@ -76,13 +77,13 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
76
77
|
const { gitTop } = pre;
|
|
77
78
|
// Validate branch
|
|
78
79
|
if (!isSafeGitRefToken(args.branch)) {
|
|
79
|
-
return jsonRespond({ error:
|
|
80
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.branch });
|
|
80
81
|
}
|
|
81
82
|
if (isProtectedBranch(args.branch)) {
|
|
82
|
-
return jsonRespond({ error:
|
|
83
|
+
return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch: args.branch });
|
|
83
84
|
}
|
|
84
85
|
if (args.baseRef !== undefined && !isSafeGitRefToken(args.baseRef)) {
|
|
85
|
-
return jsonRespond({ error:
|
|
86
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.baseRef });
|
|
86
87
|
}
|
|
87
88
|
// Resolve path
|
|
88
89
|
const wtPath = resolve(gitTop, args.path);
|
|
@@ -107,7 +108,7 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
107
108
|
if (!r.ok) {
|
|
108
109
|
return jsonRespond({
|
|
109
110
|
ok: false,
|
|
110
|
-
error:
|
|
111
|
+
error: ERROR_CODES.WORKTREE_ADD_FAILED,
|
|
111
112
|
detail: (r.stderr || r.stdout).trim(),
|
|
112
113
|
});
|
|
113
114
|
}
|
|
@@ -158,13 +159,13 @@ export function registerGitWorktreeRemoveTool(server) {
|
|
|
158
159
|
// Refuse to remove the main worktree
|
|
159
160
|
const wtPath = resolve(gitTop, args.path);
|
|
160
161
|
if (wtPath === gitTop) {
|
|
161
|
-
return jsonRespond({ error:
|
|
162
|
+
return jsonRespond({ error: ERROR_CODES.CANNOT_REMOVE_MAIN_WORKTREE, path: wtPath });
|
|
162
163
|
}
|
|
163
164
|
// Verify it exists in the worktree list
|
|
164
165
|
const trees = await listWorktrees(gitTop);
|
|
165
166
|
const isRegistered = trees.some((t) => t.path === wtPath || t.path === args.path);
|
|
166
167
|
if (!isRegistered) {
|
|
167
|
-
return jsonRespond({ error:
|
|
168
|
+
return jsonRespond({ error: ERROR_CODES.WORKTREE_NOT_FOUND, path: args.path });
|
|
168
169
|
}
|
|
169
170
|
const removeArgs = ["worktree", "remove", wtPath];
|
|
170
171
|
if (args.force)
|
|
@@ -173,7 +174,7 @@ export function registerGitWorktreeRemoveTool(server) {
|
|
|
173
174
|
if (!r.ok) {
|
|
174
175
|
return jsonRespond({
|
|
175
176
|
ok: false,
|
|
176
|
-
error:
|
|
177
|
+
error: ERROR_CODES.WORKTREE_REMOVE_FAILED,
|
|
177
178
|
detail: (r.stderr || r.stdout).trim(),
|
|
178
179
|
...spreadWhen((r.stderr || r.stdout).includes("contains modified") ||
|
|
179
180
|
(r.stderr || r.stdout).includes("is not empty"), { hint: "Pass force: true to remove a worktree with uncommitted changes." }),
|
package/dist/server/git.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
3
3
|
import { cpus } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
5
6
|
/**
|
|
6
7
|
* Parallel git subprocesses for inventory rows and git_status submodule rows.
|
|
7
8
|
* Reads from GIT_SUBPROCESS_PARALLELISM env var (default 4), clamped to [1, 2×CPU_COUNT].
|
|
@@ -19,9 +20,28 @@ function resolveGitSubprocessParallelism() {
|
|
|
19
20
|
return 4;
|
|
20
21
|
}
|
|
21
22
|
export const GIT_SUBPROCESS_PARALLELISM = resolveGitSubprocessParallelism();
|
|
23
|
+
/**
|
|
24
|
+
* Default timeout for git subprocesses spawned by spawnGitAsync.
|
|
25
|
+
* Reads from GIT_SUBPROCESS_TIMEOUT_MS env var (default 120000 ms = 2 min).
|
|
26
|
+
* A value of 0 (or negative/NaN) disables the timeout — use for operations
|
|
27
|
+
* like large clones where unbounded wait is intentional.
|
|
28
|
+
*/
|
|
29
|
+
function resolveGitSubprocessTimeoutMs() {
|
|
30
|
+
const env = process.env.GIT_SUBPROCESS_TIMEOUT_MS;
|
|
31
|
+
if (env) {
|
|
32
|
+
const n = Number.parseInt(env, 10);
|
|
33
|
+
if (!Number.isNaN(n) && n > 0)
|
|
34
|
+
return n;
|
|
35
|
+
// 0 or negative → disabled
|
|
36
|
+
if (!Number.isNaN(n))
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
return 120_000;
|
|
40
|
+
}
|
|
41
|
+
export const GIT_SUBPROCESS_TIMEOUT_MS = resolveGitSubprocessTimeoutMs();
|
|
22
42
|
let gitPathState = "unknown";
|
|
23
43
|
const GIT_NOT_FOUND_BODY = {
|
|
24
|
-
error:
|
|
44
|
+
error: ERROR_CODES.GIT_NOT_FOUND,
|
|
25
45
|
};
|
|
26
46
|
export function gateGit() {
|
|
27
47
|
if (gitPathState === "ok") {
|
|
@@ -147,11 +167,12 @@ export async function asyncPool(items, concurrency, fn) {
|
|
|
147
167
|
await Promise.all(Array.from({ length: n }, () => worker()));
|
|
148
168
|
return results;
|
|
149
169
|
}
|
|
150
|
-
export function spawnGitAsync(cwd, args) {
|
|
170
|
+
export function spawnGitAsync(cwd, args, opts) {
|
|
151
171
|
return new Promise((resolveP) => {
|
|
152
172
|
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
153
173
|
let stdout = "";
|
|
154
174
|
let stderr = "";
|
|
175
|
+
let settled = false;
|
|
155
176
|
child.stdout?.setEncoding("utf8");
|
|
156
177
|
child.stderr?.setEncoding("utf8");
|
|
157
178
|
child.stdout?.on("data", (c) => {
|
|
@@ -160,8 +181,53 @@ export function spawnGitAsync(cwd, args) {
|
|
|
160
181
|
child.stderr?.on("data", (c) => {
|
|
161
182
|
stderr += c;
|
|
162
183
|
});
|
|
163
|
-
|
|
164
|
-
|
|
184
|
+
const effectiveTimeout = opts?.timeoutMs ?? GIT_SUBPROCESS_TIMEOUT_MS;
|
|
185
|
+
let timer;
|
|
186
|
+
let abortListener;
|
|
187
|
+
function cleanup() {
|
|
188
|
+
if (timer !== undefined) {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
timer = undefined;
|
|
191
|
+
}
|
|
192
|
+
if (abortListener !== undefined && opts?.signal) {
|
|
193
|
+
opts.signal.removeEventListener("abort", abortListener);
|
|
194
|
+
abortListener = undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function settle(result) {
|
|
198
|
+
if (settled)
|
|
199
|
+
return;
|
|
200
|
+
settled = true;
|
|
201
|
+
cleanup();
|
|
202
|
+
resolveP(result);
|
|
203
|
+
}
|
|
204
|
+
// AbortSignal: kill immediately if already aborted, else listen
|
|
205
|
+
if (opts?.signal) {
|
|
206
|
+
if (opts.signal.aborted) {
|
|
207
|
+
child.kill("SIGTERM");
|
|
208
|
+
settle({ ok: false, stdout, stderr, aborted: true });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
abortListener = () => {
|
|
212
|
+
child.kill("SIGTERM");
|
|
213
|
+
settle({ ok: false, stdout, stderr, aborted: true });
|
|
214
|
+
};
|
|
215
|
+
opts.signal.addEventListener("abort", abortListener, { once: true });
|
|
216
|
+
}
|
|
217
|
+
// Timeout: set timer if effectiveTimeout > 0
|
|
218
|
+
if (effectiveTimeout > 0) {
|
|
219
|
+
timer = setTimeout(() => {
|
|
220
|
+
child.kill("SIGTERM");
|
|
221
|
+
settle({
|
|
222
|
+
ok: false,
|
|
223
|
+
stdout,
|
|
224
|
+
stderr: `${stderr}\n<git timed out after ${effectiveTimeout}ms>`,
|
|
225
|
+
timedOut: true,
|
|
226
|
+
});
|
|
227
|
+
}, effectiveTimeout);
|
|
228
|
+
}
|
|
229
|
+
child.on("error", () => settle({ ok: false, stdout, stderr }));
|
|
230
|
+
child.on("close", (code) => settle({ ok: code === 0, stdout, stderr }));
|
|
165
231
|
});
|
|
166
232
|
}
|
|
167
233
|
function gitStatusFailText(r) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
3
|
import { gitTopLevel } from "./git.js";
|
|
3
4
|
import { jsonRespond, spreadDefined } from "./json.js";
|
|
4
5
|
import { loadPresetsFromGitTop, PRESET_FILE_PATH, presetLoadErrorPayload } from "./presets.js";
|
|
@@ -34,7 +35,7 @@ export function registerListPresetsTool(server) {
|
|
|
34
35
|
presetFile,
|
|
35
36
|
fileExists: false,
|
|
36
37
|
presets: [],
|
|
37
|
-
error: { error:
|
|
38
|
+
error: { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: ws },
|
|
38
39
|
});
|
|
39
40
|
continue;
|
|
40
41
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
2
3
|
import { gitTopLevel } from "./git.js";
|
|
3
4
|
import { jsonRespond, spreadDefined } from "./json.js";
|
|
4
5
|
import { loadPresetsFromGitTop, PRESET_FILE_PATH, presetLoadErrorPayload } from "./presets.js";
|
|
@@ -15,11 +16,11 @@ export function registerPresetsResource(server) {
|
|
|
15
16
|
}
|
|
16
17
|
const ws = pre.roots[0];
|
|
17
18
|
if (!ws) {
|
|
18
|
-
return { text: jsonRespond({ error:
|
|
19
|
+
return { text: jsonRespond({ error: ERROR_CODES.NO_WORKSPACE_ROOT }) };
|
|
19
20
|
}
|
|
20
21
|
const top = gitTopLevel(ws);
|
|
21
22
|
if (!top) {
|
|
22
|
-
return { text: jsonRespond({ error:
|
|
23
|
+
return { text: jsonRespond({ error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: ws }) };
|
|
23
24
|
}
|
|
24
25
|
const loaded = loadPresetsFromGitTop(top);
|
|
25
26
|
if (!loaded.ok) {
|