@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,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";
|
|
@@ -9,7 +10,7 @@ import { WorkspacePickSchema } from "./schemas.js";
|
|
|
9
10
|
export function registerGitStashListTool(server) {
|
|
10
11
|
server.addTool({
|
|
11
12
|
name: "git_stash_list",
|
|
12
|
-
description: "List all git stashes.
|
|
13
|
+
description: "List all git stashes.",
|
|
13
14
|
annotations: {
|
|
14
15
|
readOnlyHint: true,
|
|
15
16
|
},
|
|
@@ -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
|
}
|
|
@@ -76,9 +77,7 @@ export function registerGitStashListTool(server) {
|
|
|
76
77
|
export function registerGitStashApplyTool(server) {
|
|
77
78
|
server.addTool({
|
|
78
79
|
name: "git_stash_apply",
|
|
79
|
-
description: "Apply or pop a git stash. `index` defaults to 0
|
|
80
|
-
"Set `pop: true` to run `git stash pop` instead of `git stash apply` (removes stash after applying). " +
|
|
81
|
-
"Returns `{ applied: boolean, stashIndex: number, popped: boolean, output: string }`.",
|
|
80
|
+
description: "Apply or pop a git stash. `index` defaults to 0. `pop: true` removes stash after applying.",
|
|
82
81
|
annotations: {
|
|
83
82
|
readOnlyHint: false,
|
|
84
83
|
destructiveHint: false,
|
|
@@ -102,7 +101,7 @@ export function registerGitStashApplyTool(server) {
|
|
|
102
101
|
.boolean()
|
|
103
102
|
.optional()
|
|
104
103
|
.default(false)
|
|
105
|
-
.describe("
|
|
104
|
+
.describe("Run `git stash pop` instead of `git stash apply` (removes stash after applying)."),
|
|
106
105
|
}),
|
|
107
106
|
execute: async (args) => {
|
|
108
107
|
const pre = requireSingleRepo(server, args);
|
|
@@ -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";
|
|
@@ -48,8 +49,7 @@ export function registerGitWorktreeListTool(server) {
|
|
|
48
49
|
export function registerGitWorktreeAddTool(server) {
|
|
49
50
|
server.addTool({
|
|
50
51
|
name: "git_worktree_add",
|
|
51
|
-
description: "Add a new git worktree.
|
|
52
|
-
"(defaults to HEAD). Refuses to create on a protected branch name.",
|
|
52
|
+
description: "Add a new git worktree. Creates `branch` from `baseRef` (default: HEAD) if it doesn't exist. Refuses protected branch names.",
|
|
53
53
|
annotations: {
|
|
54
54
|
readOnlyHint: false,
|
|
55
55
|
destructiveHint: false,
|
|
@@ -59,15 +59,15 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
59
59
|
path: z
|
|
60
60
|
.string()
|
|
61
61
|
.min(1)
|
|
62
|
-
.describe("Filesystem path for the new worktree
|
|
62
|
+
.describe("Filesystem path for the new worktree (relative paths resolved from git toplevel)."),
|
|
63
63
|
branch: z
|
|
64
64
|
.string()
|
|
65
65
|
.min(1)
|
|
66
|
-
.describe("Branch to check out
|
|
66
|
+
.describe("Branch to check out; created from `baseRef` if it doesn't exist."),
|
|
67
67
|
baseRef: z
|
|
68
68
|
.string()
|
|
69
69
|
.optional()
|
|
70
|
-
.describe("Commit-ish to base the new branch on
|
|
70
|
+
.describe("Commit-ish to base the new branch on. Default: HEAD."),
|
|
71
71
|
}),
|
|
72
72
|
execute: async (args) => {
|
|
73
73
|
const pre = requireSingleRepo(server, args);
|
|
@@ -76,13 +76,13 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
76
76
|
const { gitTop } = pre;
|
|
77
77
|
// Validate branch
|
|
78
78
|
if (!isSafeGitRefToken(args.branch)) {
|
|
79
|
-
return jsonRespond({ error:
|
|
79
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.branch });
|
|
80
80
|
}
|
|
81
81
|
if (isProtectedBranch(args.branch)) {
|
|
82
|
-
return jsonRespond({ error:
|
|
82
|
+
return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch: args.branch });
|
|
83
83
|
}
|
|
84
84
|
if (args.baseRef !== undefined && !isSafeGitRefToken(args.baseRef)) {
|
|
85
|
-
return jsonRespond({ error:
|
|
85
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.baseRef });
|
|
86
86
|
}
|
|
87
87
|
// Resolve path
|
|
88
88
|
const wtPath = resolve(gitTop, args.path);
|
|
@@ -107,7 +107,7 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
107
107
|
if (!r.ok) {
|
|
108
108
|
return jsonRespond({
|
|
109
109
|
ok: false,
|
|
110
|
-
error:
|
|
110
|
+
error: ERROR_CODES.WORKTREE_ADD_FAILED,
|
|
111
111
|
detail: (r.stderr || r.stdout).trim(),
|
|
112
112
|
});
|
|
113
113
|
}
|
|
@@ -131,9 +131,7 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
131
131
|
export function registerGitWorktreeRemoveTool(server) {
|
|
132
132
|
server.addTool({
|
|
133
133
|
name: "git_worktree_remove",
|
|
134
|
-
description: "Remove a git worktree
|
|
135
|
-
"Pass `force: true` to remove a worktree with uncommitted changes. " +
|
|
136
|
-
"Refuses to remove the main worktree.",
|
|
134
|
+
description: "Remove a git worktree. Pass `force: true` to remove with uncommitted changes. Refuses to remove the main worktree.",
|
|
137
135
|
annotations: {
|
|
138
136
|
readOnlyHint: false,
|
|
139
137
|
destructiveHint: true,
|
|
@@ -148,7 +146,7 @@ export function registerGitWorktreeRemoveTool(server) {
|
|
|
148
146
|
.boolean()
|
|
149
147
|
.optional()
|
|
150
148
|
.default(false)
|
|
151
|
-
.describe("
|
|
149
|
+
.describe("Allow removal of worktrees with uncommitted changes (`--force`)."),
|
|
152
150
|
}),
|
|
153
151
|
execute: async (args) => {
|
|
154
152
|
const pre = requireSingleRepo(server, args);
|
|
@@ -158,13 +156,13 @@ export function registerGitWorktreeRemoveTool(server) {
|
|
|
158
156
|
// Refuse to remove the main worktree
|
|
159
157
|
const wtPath = resolve(gitTop, args.path);
|
|
160
158
|
if (wtPath === gitTop) {
|
|
161
|
-
return jsonRespond({ error:
|
|
159
|
+
return jsonRespond({ error: ERROR_CODES.CANNOT_REMOVE_MAIN_WORKTREE, path: wtPath });
|
|
162
160
|
}
|
|
163
161
|
// Verify it exists in the worktree list
|
|
164
162
|
const trees = await listWorktrees(gitTop);
|
|
165
163
|
const isRegistered = trees.some((t) => t.path === wtPath || t.path === args.path);
|
|
166
164
|
if (!isRegistered) {
|
|
167
|
-
return jsonRespond({ error:
|
|
165
|
+
return jsonRespond({ error: ERROR_CODES.WORKTREE_NOT_FOUND, path: args.path });
|
|
168
166
|
}
|
|
169
167
|
const removeArgs = ["worktree", "remove", wtPath];
|
|
170
168
|
if (args.force)
|
|
@@ -173,7 +171,7 @@ export function registerGitWorktreeRemoveTool(server) {
|
|
|
173
171
|
if (!r.ok) {
|
|
174
172
|
return jsonRespond({
|
|
175
173
|
ok: false,
|
|
176
|
-
error:
|
|
174
|
+
error: ERROR_CODES.WORKTREE_REMOVE_FAILED,
|
|
177
175
|
detail: (r.stderr || r.stdout).trim(),
|
|
178
176
|
...spreadWhen((r.stderr || r.stdout).includes("contains modified") ||
|
|
179
177
|
(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) {
|
package/dist/server/presets.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
4
5
|
/**
|
|
5
6
|
* Schema for `.rethunk/git-mcp-presets.json` at the workspace root.
|
|
6
7
|
* Each named entry defines roots for `git_inventory` and/or pairs for `git_parity`.
|
|
@@ -85,16 +86,21 @@ export function presetLoadErrorPayload(gitTop, fail) {
|
|
|
85
86
|
const presetFile = join(gitTop, PRESET_FILE_PATH);
|
|
86
87
|
if (fail.reason === "invalid_json") {
|
|
87
88
|
return {
|
|
88
|
-
error:
|
|
89
|
+
error: ERROR_CODES.PRESET_FILE_INVALID,
|
|
89
90
|
kind: "invalid_json",
|
|
90
91
|
presetFile,
|
|
91
92
|
message: fail.message,
|
|
92
93
|
};
|
|
93
94
|
}
|
|
94
95
|
if (fail.reason === "schema") {
|
|
95
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
error: ERROR_CODES.PRESET_FILE_INVALID,
|
|
98
|
+
kind: "schema",
|
|
99
|
+
presetFile,
|
|
100
|
+
issues: fail.issues,
|
|
101
|
+
};
|
|
96
102
|
}
|
|
97
|
-
return { error:
|
|
103
|
+
return { error: ERROR_CODES.PRESET_FILE_INVALID, presetFile };
|
|
98
104
|
}
|
|
99
105
|
function getPresetEntry(gitTop, presetName) {
|
|
100
106
|
const loaded = loadPresetsFromGitTop(gitTop);
|
|
@@ -103,7 +109,7 @@ function getPresetEntry(gitTop, presetName) {
|
|
|
103
109
|
return {
|
|
104
110
|
ok: false,
|
|
105
111
|
error: {
|
|
106
|
-
error:
|
|
112
|
+
error: ERROR_CODES.PRESET_NOT_FOUND,
|
|
107
113
|
preset: presetName,
|
|
108
114
|
presetFile: join(gitTop, PRESET_FILE_PATH),
|
|
109
115
|
},
|
|
@@ -116,7 +122,7 @@ function getPresetEntry(gitTop, presetName) {
|
|
|
116
122
|
return {
|
|
117
123
|
ok: false,
|
|
118
124
|
error: {
|
|
119
|
-
error:
|
|
125
|
+
error: ERROR_CODES.PRESET_NOT_FOUND,
|
|
120
126
|
preset: presetName,
|
|
121
127
|
presetFile: join(gitTop, PRESET_FILE_PATH),
|
|
122
128
|
},
|