@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { basename, resolve } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { ERROR_CODES } from "./error-codes.js";
|
|
4
4
|
import { spawnGitAsync } from "./git.js";
|
|
@@ -6,42 +6,29 @@ import { isProtectedBranch, isSafeGitRefToken, listWorktrees, } from "./git-refs
|
|
|
6
6
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
7
7
|
import { requireSingleRepo } from "./roots.js";
|
|
8
8
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
if (trees.length === 0) {
|
|
34
|
-
return "# Worktrees\n_(none)_";
|
|
35
|
-
}
|
|
36
|
-
const lines = ["# Worktrees", ""];
|
|
37
|
-
for (const t of trees) {
|
|
38
|
-
const branchPart = t.branch ?? "(detached)";
|
|
39
|
-
const headPart = t.head ? ` @ ${t.head.slice(0, 7)}` : "";
|
|
40
|
-
lines.push(`- \`${t.path}\` ${branchPart}${headPart}`);
|
|
41
|
-
}
|
|
42
|
-
return lines.join("\n");
|
|
43
|
-
},
|
|
44
|
-
});
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a worktree path for add/remove.
|
|
11
|
+
*
|
|
12
|
+
* Sibling worktrees outside `gitTop` are intentional (absolute or relative).
|
|
13
|
+
* Still refuse empty/whitespace, NUL bytes, and leading-dash / option-like
|
|
14
|
+
* path tokens so git does not treat the operand as a flag. Callers must pass
|
|
15
|
+
* the resolved path after `--` in argv.
|
|
16
|
+
*/
|
|
17
|
+
function resolveWorktreePath(gitTop, rawPath) {
|
|
18
|
+
const trimmed = rawPath.trim();
|
|
19
|
+
if (trimmed.length === 0 || trimmed.includes("\0")) {
|
|
20
|
+
return { ok: false, error: ERROR_CODES.INVALID_PATHS, path: rawPath };
|
|
21
|
+
}
|
|
22
|
+
// Reject option-like tokens before resolve (relative "-foo") and after
|
|
23
|
+
// (absolute paths whose basename still starts with `-`, e.g. "/tmp/-evil").
|
|
24
|
+
if (trimmed.startsWith("-") || basename(trimmed).startsWith("-")) {
|
|
25
|
+
return { ok: false, error: ERROR_CODES.INVALID_PATHS, path: rawPath };
|
|
26
|
+
}
|
|
27
|
+
const wtPath = resolve(gitTop, trimmed);
|
|
28
|
+
if (basename(wtPath).startsWith("-")) {
|
|
29
|
+
return { ok: false, error: ERROR_CODES.INVALID_PATHS, path: rawPath };
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, path: wtPath };
|
|
45
32
|
}
|
|
46
33
|
// ---------------------------------------------------------------------------
|
|
47
34
|
// git_worktree_add
|
|
@@ -55,11 +42,12 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
55
42
|
destructiveHint: false,
|
|
56
43
|
idempotentHint: false,
|
|
57
44
|
},
|
|
58
|
-
parameters: WorkspacePickSchema.
|
|
45
|
+
parameters: WorkspacePickSchema.extend({
|
|
59
46
|
path: z
|
|
60
47
|
.string()
|
|
61
48
|
.min(1)
|
|
62
|
-
.describe("Filesystem path for the new worktree (relative paths resolved from git toplevel)."
|
|
49
|
+
.describe("Filesystem path for the new worktree (relative paths resolved from git toplevel). " +
|
|
50
|
+
"Sibling directories outside the repo toplevel are allowed; leading `-` and NUL are rejected."),
|
|
63
51
|
branch: z
|
|
64
52
|
.string()
|
|
65
53
|
.min(1)
|
|
@@ -74,34 +62,40 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
74
62
|
if (!pre.ok)
|
|
75
63
|
return jsonRespond(pre.error);
|
|
76
64
|
const { gitTop } = pre;
|
|
65
|
+
const branch = args.branch.trim();
|
|
66
|
+
const baseRef = args.baseRef !== undefined ? args.baseRef.trim() : undefined;
|
|
77
67
|
// Validate branch
|
|
78
|
-
if (!isSafeGitRefToken(
|
|
68
|
+
if (!isSafeGitRefToken(branch)) {
|
|
79
69
|
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.branch });
|
|
80
70
|
}
|
|
81
|
-
if (isProtectedBranch(
|
|
82
|
-
return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch
|
|
71
|
+
if (isProtectedBranch(branch)) {
|
|
72
|
+
return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch });
|
|
83
73
|
}
|
|
84
|
-
if (
|
|
74
|
+
if (baseRef !== undefined && !isSafeGitRefToken(baseRef)) {
|
|
85
75
|
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.baseRef });
|
|
86
76
|
}
|
|
87
|
-
|
|
88
|
-
|
|
77
|
+
const resolved = resolveWorktreePath(gitTop, args.path);
|
|
78
|
+
if (!resolved.ok) {
|
|
79
|
+
return jsonRespond({ error: resolved.error, path: resolved.path });
|
|
80
|
+
}
|
|
81
|
+
const wtPath = resolved.path;
|
|
89
82
|
// Check if branch already exists
|
|
90
83
|
const branchCheck = await spawnGitAsync(gitTop, [
|
|
91
84
|
"show-ref",
|
|
92
85
|
"--verify",
|
|
93
86
|
"--quiet",
|
|
94
|
-
`refs/heads/${
|
|
87
|
+
`refs/heads/${branch}`,
|
|
95
88
|
]);
|
|
96
89
|
const branchExists = branchCheck.ok;
|
|
90
|
+
// Path must follow `--` so a leading-dash path cannot be parsed as an option.
|
|
97
91
|
let gitArgs;
|
|
98
92
|
if (branchExists) {
|
|
99
|
-
gitArgs = ["worktree", "add", wtPath,
|
|
93
|
+
gitArgs = ["worktree", "add", "--", wtPath, branch];
|
|
100
94
|
}
|
|
101
95
|
else {
|
|
102
|
-
gitArgs = ["worktree", "add", "-b",
|
|
103
|
-
if (
|
|
104
|
-
gitArgs.push(
|
|
96
|
+
gitArgs = ["worktree", "add", "-b", branch, "--", wtPath];
|
|
97
|
+
if (baseRef)
|
|
98
|
+
gitArgs.push(baseRef);
|
|
105
99
|
}
|
|
106
100
|
const r = await spawnGitAsync(gitTop, gitArgs);
|
|
107
101
|
if (!r.ok) {
|
|
@@ -115,13 +109,13 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
115
109
|
return jsonRespond({
|
|
116
110
|
ok: true,
|
|
117
111
|
path: wtPath,
|
|
118
|
-
branch
|
|
112
|
+
branch,
|
|
119
113
|
created: !branchExists,
|
|
120
|
-
...spreadDefined("baseRef", !branchExists ? (
|
|
114
|
+
...spreadDefined("baseRef", !branchExists ? (baseRef ?? "HEAD") : undefined),
|
|
121
115
|
});
|
|
122
116
|
}
|
|
123
|
-
const createdNote = branchExists ? "" : ` (new branch from ${
|
|
124
|
-
return `# Worktree added\n✓ ${wtPath} → ${
|
|
117
|
+
const createdNote = branchExists ? "" : ` (new branch from ${baseRef ?? "HEAD"})`;
|
|
118
|
+
return `# Worktree added\n✓ ${wtPath} → ${branch}${createdNote}`;
|
|
125
119
|
},
|
|
126
120
|
});
|
|
127
121
|
}
|
|
@@ -137,11 +131,12 @@ export function registerGitWorktreeRemoveTool(server) {
|
|
|
137
131
|
destructiveHint: true,
|
|
138
132
|
idempotentHint: false,
|
|
139
133
|
},
|
|
140
|
-
parameters: WorkspacePickSchema.
|
|
134
|
+
parameters: WorkspacePickSchema.extend({
|
|
141
135
|
path: z
|
|
142
136
|
.string()
|
|
143
137
|
.min(1)
|
|
144
|
-
.describe("Path of the worktree to remove. Must not be the main worktree."
|
|
138
|
+
.describe("Path of the worktree to remove. Must not be the main worktree. " +
|
|
139
|
+
"Sibling paths outside the repo toplevel are allowed; leading `-` and NUL are rejected."),
|
|
145
140
|
force: z
|
|
146
141
|
.boolean()
|
|
147
142
|
.optional()
|
|
@@ -153,20 +148,33 @@ export function registerGitWorktreeRemoveTool(server) {
|
|
|
153
148
|
if (!pre.ok)
|
|
154
149
|
return jsonRespond(pre.error);
|
|
155
150
|
const { gitTop } = pre;
|
|
151
|
+
const resolved = resolveWorktreePath(gitTop, args.path);
|
|
152
|
+
if (!resolved.ok) {
|
|
153
|
+
return jsonRespond({ error: resolved.error, path: resolved.path });
|
|
154
|
+
}
|
|
155
|
+
const wtPath = resolved.path;
|
|
156
156
|
// Refuse to remove the main worktree
|
|
157
|
-
const wtPath = resolve(gitTop, args.path);
|
|
158
157
|
if (wtPath === gitTop) {
|
|
159
158
|
return jsonRespond({ error: ERROR_CODES.CANNOT_REMOVE_MAIN_WORKTREE, path: wtPath });
|
|
160
159
|
}
|
|
161
160
|
// Verify it exists in the worktree list
|
|
162
|
-
const
|
|
161
|
+
const listed = await listWorktrees(gitTop);
|
|
162
|
+
if (!listed.ok) {
|
|
163
|
+
return jsonRespond({
|
|
164
|
+
ok: false,
|
|
165
|
+
error: ERROR_CODES.WORKTREE_REMOVE_FAILED,
|
|
166
|
+
detail: listed.detail,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const trees = listed.worktrees;
|
|
163
170
|
const isRegistered = trees.some((t) => t.path === wtPath || t.path === args.path);
|
|
164
171
|
if (!isRegistered) {
|
|
165
172
|
return jsonRespond({ error: ERROR_CODES.WORKTREE_NOT_FOUND, path: args.path });
|
|
166
173
|
}
|
|
167
|
-
const removeArgs = ["worktree", "remove"
|
|
174
|
+
const removeArgs = ["worktree", "remove"];
|
|
168
175
|
if (args.force)
|
|
169
176
|
removeArgs.push("--force");
|
|
177
|
+
removeArgs.push("--", wtPath);
|
|
170
178
|
const r = await spawnGitAsync(gitTop, removeArgs);
|
|
171
179
|
if (!r.ok) {
|
|
172
180
|
return jsonRespond({
|
package/dist/server/git.js
CHANGED
|
@@ -7,13 +7,11 @@ import { ERROR_CODES } from "./error-codes.js";
|
|
|
7
7
|
* Parallel git subprocesses for inventory rows and git_status submodule rows.
|
|
8
8
|
* Reads from GIT_SUBPROCESS_PARALLELISM env var (default 4), clamped to [1, 2×CPU_COUNT].
|
|
9
9
|
*/
|
|
10
|
-
function resolveGitSubprocessParallelism() {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const n = Number.parseInt(env, 10);
|
|
10
|
+
export function resolveGitSubprocessParallelism(envValue = process.env.GIT_SUBPROCESS_PARALLELISM, cpuCount = cpus().length) {
|
|
11
|
+
if (envValue) {
|
|
12
|
+
const n = Number.parseInt(envValue, 10);
|
|
14
13
|
if (!Number.isNaN(n) && n >= 1) {
|
|
15
|
-
const
|
|
16
|
-
const maxParallel = cpuCount * 2;
|
|
14
|
+
const maxParallel = Math.max(1, cpuCount * 2);
|
|
17
15
|
return Math.min(n, maxParallel);
|
|
18
16
|
}
|
|
19
17
|
}
|
|
@@ -26,10 +24,9 @@ export const GIT_SUBPROCESS_PARALLELISM = resolveGitSubprocessParallelism();
|
|
|
26
24
|
* A value of 0 (or negative/NaN) disables the timeout — use for operations
|
|
27
25
|
* like large clones where unbounded wait is intentional.
|
|
28
26
|
*/
|
|
29
|
-
function resolveGitSubprocessTimeoutMs() {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const n = Number.parseInt(env, 10);
|
|
27
|
+
export function resolveGitSubprocessTimeoutMs(envValue = process.env.GIT_SUBPROCESS_TIMEOUT_MS) {
|
|
28
|
+
if (envValue) {
|
|
29
|
+
const n = Number.parseInt(envValue, 10);
|
|
33
30
|
if (!Number.isNaN(n) && n > 0)
|
|
34
31
|
return n;
|
|
35
32
|
// 0 or negative → disabled
|
|
@@ -39,10 +36,31 @@ function resolveGitSubprocessTimeoutMs() {
|
|
|
39
36
|
return 120_000;
|
|
40
37
|
}
|
|
41
38
|
export const GIT_SUBPROCESS_TIMEOUT_MS = resolveGitSubprocessTimeoutMs();
|
|
39
|
+
/**
|
|
40
|
+
* Max combined stdout+stderr bytes retained from spawnGitAsync.
|
|
41
|
+
* Env: GIT_SUBPROCESS_MAX_BUFFER_BYTES (default 16 MiB). Exceeding kills the child.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveGitSubprocessMaxBufferBytes(envValue = process.env.GIT_SUBPROCESS_MAX_BUFFER_BYTES) {
|
|
44
|
+
if (envValue) {
|
|
45
|
+
const n = Number.parseInt(envValue, 10);
|
|
46
|
+
if (!Number.isNaN(n) && n >= 1024)
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
return 16 * 1024 * 1024;
|
|
50
|
+
}
|
|
51
|
+
export const GIT_SUBPROCESS_MAX_BUFFER_BYTES = resolveGitSubprocessMaxBufferBytes();
|
|
52
|
+
/** Delay after SIGTERM before escalating to SIGKILL (spawnGitAsync timeout/abort/overflow). */
|
|
53
|
+
export const GIT_SUBPROCESS_SIGKILL_ESCALATION_MS = 2_000;
|
|
54
|
+
/** Timeout for sync spawnSync helpers (gateGit, rev-parse). */
|
|
55
|
+
export const GIT_SYNC_TIMEOUT_MS = 30_000;
|
|
42
56
|
let gitPathState = "unknown";
|
|
43
57
|
const GIT_NOT_FOUND_BODY = {
|
|
44
58
|
error: ERROR_CODES.GIT_NOT_FOUND,
|
|
45
59
|
};
|
|
60
|
+
/** Test-only: reset the cached git-on-PATH probe. */
|
|
61
|
+
export function resetGitPathStateForTests() {
|
|
62
|
+
gitPathState = "unknown";
|
|
63
|
+
}
|
|
46
64
|
export function gateGit() {
|
|
47
65
|
if (gitPathState === "ok") {
|
|
48
66
|
return { ok: true };
|
|
@@ -53,9 +71,16 @@ export function gateGit() {
|
|
|
53
71
|
body: GIT_NOT_FOUND_BODY,
|
|
54
72
|
};
|
|
55
73
|
}
|
|
56
|
-
const r = spawnSync("git", ["--version"], {
|
|
57
|
-
|
|
58
|
-
|
|
74
|
+
const r = spawnSync("git", ["--version"], {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
77
|
+
});
|
|
78
|
+
if (r.error || r.status !== 0) {
|
|
79
|
+
// Do not cache "missing" on timeout — a wedged git may recover.
|
|
80
|
+
const timedOut = r.error !== undefined && r.error.code === "ETIMEDOUT";
|
|
81
|
+
if (!timedOut) {
|
|
82
|
+
gitPathState = "missing";
|
|
83
|
+
}
|
|
59
84
|
return {
|
|
60
85
|
ok: false,
|
|
61
86
|
body: GIT_NOT_FOUND_BODY,
|
|
@@ -71,8 +96,9 @@ export function gitTopLevel(cwd) {
|
|
|
71
96
|
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
72
97
|
cwd,
|
|
73
98
|
encoding: "utf8",
|
|
99
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
74
100
|
});
|
|
75
|
-
if (r.status !== 0)
|
|
101
|
+
if (r.error || r.status !== 0)
|
|
76
102
|
return null;
|
|
77
103
|
return r.stdout.trim();
|
|
78
104
|
}
|
|
@@ -80,15 +106,17 @@ export function gitRevParseGitDir(cwd) {
|
|
|
80
106
|
const r = spawnSync("git", ["rev-parse", "--git-dir"], {
|
|
81
107
|
cwd,
|
|
82
108
|
encoding: "utf8",
|
|
109
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
83
110
|
});
|
|
84
|
-
return r.status === 0;
|
|
111
|
+
return !r.error && r.status === 0;
|
|
85
112
|
}
|
|
86
113
|
export function gitRevParseHead(cwd) {
|
|
87
114
|
const r = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
88
115
|
cwd,
|
|
89
116
|
encoding: "utf8",
|
|
117
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
90
118
|
});
|
|
91
|
-
if (r.status !== 0) {
|
|
119
|
+
if (r.error || r.status !== 0) {
|
|
92
120
|
return { ok: false, text: (r.stderr || r.stdout || "git rev-parse HEAD failed").trim() };
|
|
93
121
|
}
|
|
94
122
|
return { ok: true, sha: r.stdout.trim(), text: r.stdout.trim() };
|
|
@@ -163,17 +191,70 @@ export async function asyncPool(items, concurrency, fn) {
|
|
|
163
191
|
}
|
|
164
192
|
export function spawnGitAsync(cwd, args, opts) {
|
|
165
193
|
return new Promise((resolveP) => {
|
|
166
|
-
const child = spawn("git", args, { cwd, stdio: ["
|
|
194
|
+
const child = spawn("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
167
195
|
let stdout = "";
|
|
168
196
|
let stderr = "";
|
|
169
197
|
let settled = false;
|
|
198
|
+
let stdoutBytes = 0;
|
|
199
|
+
let stderrBytes = 0;
|
|
200
|
+
let sigkillTimer;
|
|
201
|
+
const maxBuffer = opts?.maxBufferBytes ?? GIT_SUBPROCESS_MAX_BUFFER_BYTES;
|
|
202
|
+
const sigkillAfter = opts?.sigkillAfterMs ?? GIT_SUBPROCESS_SIGKILL_ESCALATION_MS;
|
|
203
|
+
if (!opts?.holdStdin) {
|
|
204
|
+
child.stdin?.end();
|
|
205
|
+
}
|
|
170
206
|
child.stdout?.setEncoding("utf8");
|
|
171
207
|
child.stderr?.setEncoding("utf8");
|
|
208
|
+
function escalateKill() {
|
|
209
|
+
try {
|
|
210
|
+
child.kill("SIGTERM");
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
/* already dead */
|
|
214
|
+
}
|
|
215
|
+
if (sigkillTimer !== undefined)
|
|
216
|
+
return;
|
|
217
|
+
sigkillTimer = setTimeout(() => {
|
|
218
|
+
sigkillTimer = undefined;
|
|
219
|
+
try {
|
|
220
|
+
if (!settled && child.exitCode === null && child.signalCode === null) {
|
|
221
|
+
child.kill("SIGKILL");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
/* already dead */
|
|
226
|
+
}
|
|
227
|
+
}, sigkillAfter);
|
|
228
|
+
// Do not keep the process alive solely for SIGKILL escalation.
|
|
229
|
+
if (typeof sigkillTimer === "object" && "unref" in sigkillTimer) {
|
|
230
|
+
sigkillTimer.unref();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function onChunk(stream, chunk) {
|
|
234
|
+
const byteLen = Buffer.byteLength(chunk, "utf8");
|
|
235
|
+
if (stream === "stdout") {
|
|
236
|
+
stdoutBytes += byteLen;
|
|
237
|
+
stdout += chunk;
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
stderrBytes += byteLen;
|
|
241
|
+
stderr += chunk;
|
|
242
|
+
}
|
|
243
|
+
if (stdoutBytes + stderrBytes > maxBuffer) {
|
|
244
|
+
escalateKill();
|
|
245
|
+
settle({
|
|
246
|
+
ok: false,
|
|
247
|
+
stdout,
|
|
248
|
+
stderr: `${stderr}\n<git output exceeded ${maxBuffer} bytes>`,
|
|
249
|
+
truncated: true,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
172
253
|
child.stdout?.on("data", (c) => {
|
|
173
|
-
stdout
|
|
254
|
+
onChunk("stdout", c);
|
|
174
255
|
});
|
|
175
256
|
child.stderr?.on("data", (c) => {
|
|
176
|
-
stderr
|
|
257
|
+
onChunk("stderr", c);
|
|
177
258
|
});
|
|
178
259
|
const effectiveTimeout = opts?.timeoutMs ?? GIT_SUBPROCESS_TIMEOUT_MS;
|
|
179
260
|
let timer;
|
|
@@ -183,10 +264,20 @@ export function spawnGitAsync(cwd, args, opts) {
|
|
|
183
264
|
clearTimeout(timer);
|
|
184
265
|
timer = undefined;
|
|
185
266
|
}
|
|
267
|
+
if (sigkillTimer !== undefined) {
|
|
268
|
+
clearTimeout(sigkillTimer);
|
|
269
|
+
sigkillTimer = undefined;
|
|
270
|
+
}
|
|
186
271
|
if (abortListener !== undefined && opts?.signal) {
|
|
187
272
|
opts.signal.removeEventListener("abort", abortListener);
|
|
188
273
|
abortListener = undefined;
|
|
189
274
|
}
|
|
275
|
+
try {
|
|
276
|
+
child.stdin?.destroy();
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
/* ignore */
|
|
280
|
+
}
|
|
190
281
|
}
|
|
191
282
|
function settle(result) {
|
|
192
283
|
if (settled)
|
|
@@ -195,15 +286,18 @@ export function spawnGitAsync(cwd, args, opts) {
|
|
|
195
286
|
cleanup();
|
|
196
287
|
resolveP(result);
|
|
197
288
|
}
|
|
289
|
+
// Register lifecycle handlers before any early kill so close/error are observed.
|
|
290
|
+
child.on("error", () => settle({ ok: false, stdout, stderr }));
|
|
291
|
+
child.on("close", (code) => settle({ ok: code === 0, stdout, stderr }));
|
|
198
292
|
// AbortSignal: kill immediately if already aborted, else listen
|
|
199
293
|
if (opts?.signal) {
|
|
200
294
|
if (opts.signal.aborted) {
|
|
201
|
-
|
|
295
|
+
escalateKill();
|
|
202
296
|
settle({ ok: false, stdout, stderr, aborted: true });
|
|
203
297
|
return;
|
|
204
298
|
}
|
|
205
299
|
abortListener = () => {
|
|
206
|
-
|
|
300
|
+
escalateKill();
|
|
207
301
|
settle({ ok: false, stdout, stderr, aborted: true });
|
|
208
302
|
};
|
|
209
303
|
opts.signal.addEventListener("abort", abortListener, { once: true });
|
|
@@ -211,7 +305,7 @@ export function spawnGitAsync(cwd, args, opts) {
|
|
|
211
305
|
// Timeout: set timer if effectiveTimeout > 0
|
|
212
306
|
if (effectiveTimeout > 0) {
|
|
213
307
|
timer = setTimeout(() => {
|
|
214
|
-
|
|
308
|
+
escalateKill();
|
|
215
309
|
settle({
|
|
216
310
|
ok: false,
|
|
217
311
|
stdout,
|
|
@@ -220,8 +314,6 @@ export function spawnGitAsync(cwd, args, opts) {
|
|
|
220
314
|
});
|
|
221
315
|
}, effectiveTimeout);
|
|
222
316
|
}
|
|
223
|
-
child.on("error", () => settle({ ok: false, stdout, stderr }));
|
|
224
|
-
child.on("close", (code) => settle({ ok: code === 0, stdout, stderr }));
|
|
225
317
|
});
|
|
226
318
|
}
|
|
227
319
|
function gitStatusFailText(r) {
|
package/dist/server/inventory.js
CHANGED
|
@@ -22,6 +22,15 @@ export function buildInventorySectionMarkdown(e) {
|
|
|
22
22
|
else if (e.upstreamNote) {
|
|
23
23
|
lines.push(`upstream: ${e.upstreamNote}`);
|
|
24
24
|
}
|
|
25
|
+
if (e.compareRefs) {
|
|
26
|
+
const cr = e.compareRefs;
|
|
27
|
+
if (cr.ahead !== undefined && cr.behind !== undefined) {
|
|
28
|
+
lines.push(`${cr.left}...${cr.right}: ahead ${cr.ahead}, behind ${cr.behind}`);
|
|
29
|
+
}
|
|
30
|
+
else if (cr.note) {
|
|
31
|
+
lines.push(`compareRefs ${cr.left}...${cr.right}: ${cr.note}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
25
34
|
const single = lines.length === 1 ? lines[0] : undefined;
|
|
26
35
|
if (single !== undefined && !single.includes("\n")) {
|
|
27
36
|
return ["", header, single];
|
|
@@ -53,7 +62,48 @@ function buildEntry(params) {
|
|
|
53
62
|
out.upstreamNote = params.upstreamNote;
|
|
54
63
|
return out;
|
|
55
64
|
}
|
|
56
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Ahead = commits reachable from `right` but not `left` (`left..right`).
|
|
67
|
+
* Behind = commits reachable from `left` but not `right` (`right..left`).
|
|
68
|
+
*/
|
|
69
|
+
async function fetchCompareAheadBehind(absPath, left, right) {
|
|
70
|
+
const [aheadR, behindR] = await Promise.all([
|
|
71
|
+
spawnGitAsync(absPath, ["rev-list", "--count", `${left}..${right}`]),
|
|
72
|
+
spawnGitAsync(absPath, ["rev-list", "--count", `${right}..${left}`]),
|
|
73
|
+
]);
|
|
74
|
+
return {
|
|
75
|
+
ahead: aheadR.ok ? aheadR.stdout.trim() : null,
|
|
76
|
+
behind: behindR.ok ? behindR.stdout.trim() : null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async function attachCompareRefs(entry, absPath, compareRefs) {
|
|
80
|
+
if (!compareRefs)
|
|
81
|
+
return entry;
|
|
82
|
+
const left = compareRefs.left;
|
|
83
|
+
const right = compareRefs.right;
|
|
84
|
+
const [leftOk, rightOk] = await Promise.all([
|
|
85
|
+
spawnGitAsync(absPath, ["rev-parse", "--verify", left]),
|
|
86
|
+
spawnGitAsync(absPath, ["rev-parse", "--verify", right]),
|
|
87
|
+
]);
|
|
88
|
+
if (!leftOk.ok || !rightOk.ok) {
|
|
89
|
+
entry.compareRefs = {
|
|
90
|
+
left,
|
|
91
|
+
right,
|
|
92
|
+
note: `(ref unreadable: ${[!leftOk.ok ? left : "", !rightOk.ok ? right : ""].filter(Boolean).join(", ")})`,
|
|
93
|
+
};
|
|
94
|
+
return entry;
|
|
95
|
+
}
|
|
96
|
+
const { ahead, behind } = await fetchCompareAheadBehind(absPath, left, right);
|
|
97
|
+
entry.compareRefs = {
|
|
98
|
+
left,
|
|
99
|
+
right,
|
|
100
|
+
...(ahead != null ? { ahead } : {}),
|
|
101
|
+
...(behind != null ? { behind } : {}),
|
|
102
|
+
...(ahead == null || behind == null ? { note: "(counts unreadable)" } : {}),
|
|
103
|
+
};
|
|
104
|
+
return entry;
|
|
105
|
+
}
|
|
106
|
+
export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBranch, compareRefs) {
|
|
57
107
|
const [snap, headR] = await Promise.all([
|
|
58
108
|
gitStatusSnapshotAsync(absPath),
|
|
59
109
|
spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
@@ -62,11 +112,12 @@ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBr
|
|
|
62
112
|
const headAbbrev = headR.ok ? headR.stdout.trim() : "";
|
|
63
113
|
const detached = !headR.ok || headAbbrev === "HEAD" || headAbbrev.endsWith("/HEAD");
|
|
64
114
|
const base = { label, absPath, branchStatus, detached, headAbbrev };
|
|
115
|
+
let entry;
|
|
65
116
|
if (fixedRemote !== undefined && fixedBranch !== undefined) {
|
|
66
117
|
const ref = `${fixedRemote}/${fixedBranch}`;
|
|
67
118
|
const verify = await spawnGitAsync(absPath, ["rev-parse", "--verify", ref]);
|
|
68
119
|
if (!verify.ok) {
|
|
69
|
-
|
|
120
|
+
entry = buildEntry({
|
|
70
121
|
...base,
|
|
71
122
|
upstreamMode: "fixed",
|
|
72
123
|
upstreamRef: ref,
|
|
@@ -75,36 +126,43 @@ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBr
|
|
|
75
126
|
upstreamNote: `(no local ref ${ref} or unreadable)`,
|
|
76
127
|
});
|
|
77
128
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
129
|
+
else {
|
|
130
|
+
const { ahead, behind } = await fetchAheadBehind(absPath, ref);
|
|
131
|
+
entry = buildEntry({
|
|
132
|
+
...base,
|
|
133
|
+
upstreamMode: "fixed",
|
|
134
|
+
upstreamRef: ref,
|
|
135
|
+
ahead,
|
|
136
|
+
behind,
|
|
137
|
+
upstreamNote: upstreamNoteFor(ref, ahead != null && behind != null),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
87
140
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
141
|
+
else {
|
|
142
|
+
const upVerify = await spawnGitAsync(absPath, ["rev-parse", "--verify", "@{u}"]);
|
|
143
|
+
if (!upVerify.ok) {
|
|
144
|
+
entry = buildEntry({
|
|
145
|
+
...base,
|
|
146
|
+
upstreamMode: "auto",
|
|
147
|
+
upstreamRef: null,
|
|
148
|
+
ahead: null,
|
|
149
|
+
behind: null,
|
|
150
|
+
upstreamNote: detached ? "detached HEAD — no upstream" : "no upstream configured",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const abbrevR = await spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "@{u}"]);
|
|
155
|
+
const upstreamRef = abbrevR.ok ? abbrevR.stdout.trim() : "@{u}";
|
|
156
|
+
const { ahead, behind } = await fetchAheadBehind(absPath, "@{u}");
|
|
157
|
+
entry = buildEntry({
|
|
158
|
+
...base,
|
|
159
|
+
upstreamMode: "auto",
|
|
160
|
+
upstreamRef,
|
|
161
|
+
ahead,
|
|
162
|
+
behind,
|
|
163
|
+
upstreamNote: upstreamNoteFor(upstreamRef, ahead != null && behind != null),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
98
166
|
}
|
|
99
|
-
|
|
100
|
-
const upstreamRef = abbrevR.ok ? abbrevR.stdout.trim() : "@{u}";
|
|
101
|
-
const { ahead, behind } = await fetchAheadBehind(absPath, "@{u}");
|
|
102
|
-
return buildEntry({
|
|
103
|
-
...base,
|
|
104
|
-
upstreamMode: "auto",
|
|
105
|
-
upstreamRef,
|
|
106
|
-
ahead,
|
|
107
|
-
behind,
|
|
108
|
-
upstreamNote: upstreamNoteFor(upstreamRef, ahead != null && behind != null),
|
|
109
|
-
});
|
|
167
|
+
return attachCompareRefs(entry, absPath, compareRefs);
|
|
110
168
|
}
|
|
@@ -4,7 +4,7 @@ import { gitTopLevel } from "./git.js";
|
|
|
4
4
|
import { jsonRespond, spreadDefined } from "./json.js";
|
|
5
5
|
import { loadPresetsFromGitTop, PRESET_FILE_PATH, presetLoadErrorPayload } from "./presets.js";
|
|
6
6
|
import { requireGitAndRoots } from "./roots.js";
|
|
7
|
-
import {
|
|
7
|
+
import { RootPickSchema } from "./schemas.js";
|
|
8
8
|
export function registerListPresetsTool(server) {
|
|
9
9
|
server.addTool({
|
|
10
10
|
name: "list_presets",
|
|
@@ -12,13 +12,7 @@ export function registerListPresetsTool(server) {
|
|
|
12
12
|
annotations: {
|
|
13
13
|
readOnlyHint: true,
|
|
14
14
|
},
|
|
15
|
-
parameters:
|
|
16
|
-
workspaceRoot: true,
|
|
17
|
-
rootIndex: true,
|
|
18
|
-
allWorkspaceRoots: true,
|
|
19
|
-
absoluteGitRoots: true,
|
|
20
|
-
format: true,
|
|
21
|
-
}),
|
|
15
|
+
parameters: RootPickSchema,
|
|
22
16
|
execute: async (args) => {
|
|
23
17
|
const pre = requireGitAndRoots(server, args, undefined);
|
|
24
18
|
if (!pre.ok) {
|