@rethunk/mcp-multi-root-git 2.2.0 → 2.3.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 +10 -8
- package/CHANGELOG.md +58 -0
- package/HUMANS.md +1 -1
- package/README.md +1 -1
- package/dist/server/batch-commit-tool.js +13 -35
- package/dist/server/git-cherry-pick-tool.js +6 -20
- package/dist/server/git-diff-summary-tool.js +5 -12
- package/dist/server/git-inventory-tool.js +4 -1
- package/dist/server/git-log-tool.js +16 -18
- package/dist/server/git-merge-tool.js +8 -23
- package/dist/server/git-parity-tool.js +4 -1
- package/dist/server/git-push-tool.js +109 -0
- package/dist/server/git-refs.js +41 -0
- package/dist/server/git-reset-soft-tool.js +82 -0
- package/dist/server/git-status-tool.js +4 -1
- package/dist/server/git-worktree-tool.js +188 -0
- package/dist/server/list-presets-tool.js +4 -1
- package/dist/server/roots.js +17 -0
- package/dist/server/schemas.js +11 -2
- package/dist/server/test-harness.js +27 -0
- package/dist/server/tools.js +13 -2
- package/docs/mcp-tools.md +122 -13
- package/package.json +1 -1
package/dist/server/git-refs.js
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { spawnGitAsync } from "./git.js";
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
|
+
// Merge conflict helpers (shared between git_merge and git_cherry_pick)
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
/** Paths with unresolved merge conflicts (`--diff-filter=U`). */
|
|
6
|
+
export async function conflictPaths(gitTop) {
|
|
7
|
+
const r = await spawnGitAsync(gitTop, ["diff", "--name-only", "--diff-filter=U"]);
|
|
8
|
+
if (!r.ok)
|
|
9
|
+
return [];
|
|
10
|
+
return r.stdout
|
|
11
|
+
.split("\n")
|
|
12
|
+
.map((l) => l.trim())
|
|
13
|
+
.filter((l) => l.length > 0);
|
|
14
|
+
}
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
3
16
|
// Protected branch names — never auto-delete, never cascade destructive ops onto
|
|
4
17
|
// ---------------------------------------------------------------------------
|
|
5
18
|
const PROTECTED_EXACT = new Set([
|
|
@@ -48,6 +61,18 @@ export function isSafeGitRefToken(s) {
|
|
|
48
61
|
return false;
|
|
49
62
|
return /^[A-Za-z0-9_./+-]+$/.test(t);
|
|
50
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Same as `isSafeGitRefToken` but also allows `~N` / `^N` ancestor notation used
|
|
66
|
+
* by `git reset --soft HEAD~3`. Permits `~` and `^` suffix characters.
|
|
67
|
+
*/
|
|
68
|
+
export function isSafeGitAncestorRef(s) {
|
|
69
|
+
const t = s.trim();
|
|
70
|
+
if (t.length === 0 || t.length > 256)
|
|
71
|
+
return false;
|
|
72
|
+
if (t.startsWith("-"))
|
|
73
|
+
return false;
|
|
74
|
+
return /^[A-Za-z0-9_./+~^-]+$/.test(t);
|
|
75
|
+
}
|
|
51
76
|
/**
|
|
52
77
|
* Same as `isSafeGitRefToken` but also allows the `A..B` / `A...B` range forms
|
|
53
78
|
* used by `git log` / `git cherry-pick`. Splits once and validates each side.
|
|
@@ -149,3 +174,19 @@ export async function worktreeForBranch(cwd, branch) {
|
|
|
149
174
|
const hit = trees.find((t) => t.branch === branch);
|
|
150
175
|
return hit?.path ?? null;
|
|
151
176
|
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Push helpers
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
/**
|
|
181
|
+
* Probe `@{u}` and extract the remote name from the tracking ref.
|
|
182
|
+
* Returns an error payload when no upstream is configured.
|
|
183
|
+
*/
|
|
184
|
+
export async function inferRemoteFromUpstream(cwd) {
|
|
185
|
+
const r = await spawnGitAsync(cwd, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
186
|
+
if (!r.ok)
|
|
187
|
+
return { ok: false, detail: (r.stderr || r.stdout).trim() };
|
|
188
|
+
const upstream = r.stdout.trim();
|
|
189
|
+
const slash = upstream.indexOf("/");
|
|
190
|
+
const remote = slash > 0 ? upstream.slice(0, slash) : "origin";
|
|
191
|
+
return { ok: true, remote, upstream };
|
|
192
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { spawnGitAsync } from "./git.js";
|
|
3
|
+
import { isSafeGitAncestorRef, isWorkingTreeClean } from "./git-refs.js";
|
|
4
|
+
import { jsonRespond, spreadDefined } from "./json.js";
|
|
5
|
+
import { requireSingleRepo } from "./roots.js";
|
|
6
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
|
+
export function registerGitResetSoftTool(server) {
|
|
8
|
+
server.addTool({
|
|
9
|
+
name: "git_reset_soft",
|
|
10
|
+
description: "Soft-reset the current branch to a reference (`git reset --soft <ref>`). " +
|
|
11
|
+
"Moves the branch pointer back while keeping all changes from the rewound commits " +
|
|
12
|
+
"in the staging index — use this to re-split an already-committed chunk. " +
|
|
13
|
+
"Refuses when the working tree has any uncommitted or unstaged changes (run on a clean tree).",
|
|
14
|
+
annotations: {
|
|
15
|
+
readOnlyHint: false,
|
|
16
|
+
destructiveHint: false,
|
|
17
|
+
idempotentHint: false,
|
|
18
|
+
},
|
|
19
|
+
parameters: WorkspacePickSchema.extend({
|
|
20
|
+
ref: z
|
|
21
|
+
.string()
|
|
22
|
+
.min(1)
|
|
23
|
+
.describe("Commit to reset to. Accepts ancestor notation (`HEAD~1`, `HEAD~3`), " +
|
|
24
|
+
"branch names, or full SHAs."),
|
|
25
|
+
}),
|
|
26
|
+
execute: async (args) => {
|
|
27
|
+
const pre = requireSingleRepo(server, args);
|
|
28
|
+
if (!pre.ok)
|
|
29
|
+
return jsonRespond(pre.error);
|
|
30
|
+
const { gitTop } = pre;
|
|
31
|
+
// Validate ref — allow ancestor notation (~N, ^N).
|
|
32
|
+
if (!isSafeGitAncestorRef(args.ref)) {
|
|
33
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: args.ref });
|
|
34
|
+
}
|
|
35
|
+
// Refuse when the working tree is dirty (unstaged or untracked changes).
|
|
36
|
+
if (!(await isWorkingTreeClean(gitTop))) {
|
|
37
|
+
return jsonRespond({
|
|
38
|
+
error: "working_tree_dirty",
|
|
39
|
+
detail: "git_reset_soft requires a clean working tree. " +
|
|
40
|
+
"Commit or stash pending changes first.",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// Probe HEAD before reset for the response.
|
|
44
|
+
const preSha = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
45
|
+
const beforeSha = preSha.ok ? preSha.stdout.trim() : undefined;
|
|
46
|
+
// Run the reset.
|
|
47
|
+
const r = await spawnGitAsync(gitTop, ["reset", "--soft", args.ref]);
|
|
48
|
+
if (!r.ok) {
|
|
49
|
+
return jsonRespond({
|
|
50
|
+
error: "reset_failed",
|
|
51
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
// Probe HEAD after reset.
|
|
55
|
+
const postSha = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
56
|
+
const afterSha = postSha.ok ? postSha.stdout.trim() : undefined;
|
|
57
|
+
// Count staged changes after reset.
|
|
58
|
+
const stagedResult = await spawnGitAsync(gitTop, ["diff", "--cached", "--name-only"]);
|
|
59
|
+
const stagedFiles = stagedResult.ok
|
|
60
|
+
? stagedResult.stdout
|
|
61
|
+
.split("\n")
|
|
62
|
+
.map((l) => l.trim())
|
|
63
|
+
.filter((l) => l.length > 0)
|
|
64
|
+
: [];
|
|
65
|
+
if (args.format === "json") {
|
|
66
|
+
return jsonRespond({
|
|
67
|
+
ok: true,
|
|
68
|
+
ref: args.ref,
|
|
69
|
+
...spreadDefined("beforeSha", beforeSha),
|
|
70
|
+
...spreadDefined("afterSha", afterSha),
|
|
71
|
+
stagedCount: stagedFiles.length,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const beforeShort = beforeSha?.slice(0, 7) ?? "?";
|
|
75
|
+
const afterShort = afterSha?.slice(0, 7) ?? "?";
|
|
76
|
+
return [
|
|
77
|
+
"# Reset (soft)",
|
|
78
|
+
`✓ ${beforeShort} → ${afterShort} (${stagedFiles.length} file(s) staged)`,
|
|
79
|
+
].join("\n");
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -8,7 +8,10 @@ import { WorkspacePickSchema } from "./schemas.js";
|
|
|
8
8
|
export function registerGitStatusTool(server) {
|
|
9
9
|
server.addTool({
|
|
10
10
|
name: "git_status",
|
|
11
|
-
description: "Read-only `git status --short -b` per root + submodules.
|
|
11
|
+
description: "Read-only `git status --short -b` per root + submodules.",
|
|
12
|
+
annotations: {
|
|
13
|
+
readOnlyHint: true,
|
|
14
|
+
},
|
|
12
15
|
parameters: WorkspacePickSchema.extend({
|
|
13
16
|
includeSubmodules: z.boolean().optional().default(true),
|
|
14
17
|
}),
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { spawnGitAsync } from "./git.js";
|
|
4
|
+
import { isProtectedBranch, isSafeGitRefToken, listWorktrees, } from "./git-refs.js";
|
|
5
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
6
|
+
import { requireSingleRepo } from "./roots.js";
|
|
7
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// git_worktree_list
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
export function registerGitWorktreeListTool(server) {
|
|
12
|
+
server.addTool({
|
|
13
|
+
name: "git_worktree_list",
|
|
14
|
+
description: "List all git worktrees for the repository (`git worktree list --porcelain`).",
|
|
15
|
+
annotations: {
|
|
16
|
+
readOnlyHint: true,
|
|
17
|
+
},
|
|
18
|
+
parameters: WorkspacePickSchema.pick({
|
|
19
|
+
workspaceRoot: true,
|
|
20
|
+
rootIndex: true,
|
|
21
|
+
format: true,
|
|
22
|
+
}),
|
|
23
|
+
execute: async (args) => {
|
|
24
|
+
const pre = requireSingleRepo(server, args);
|
|
25
|
+
if (!pre.ok)
|
|
26
|
+
return jsonRespond(pre.error);
|
|
27
|
+
const { gitTop } = pre;
|
|
28
|
+
const trees = await listWorktrees(gitTop);
|
|
29
|
+
if (args.format === "json") {
|
|
30
|
+
return jsonRespond({ worktrees: trees });
|
|
31
|
+
}
|
|
32
|
+
if (trees.length === 0) {
|
|
33
|
+
return "# Worktrees\n_(none)_";
|
|
34
|
+
}
|
|
35
|
+
const lines = ["# Worktrees", ""];
|
|
36
|
+
for (const t of trees) {
|
|
37
|
+
const branchPart = t.branch ?? "(detached)";
|
|
38
|
+
const headPart = t.head ? ` @ ${t.head.slice(0, 7)}` : "";
|
|
39
|
+
lines.push(`- \`${t.path}\` ${branchPart}${headPart}`);
|
|
40
|
+
}
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// git_worktree_add
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
export function registerGitWorktreeAddTool(server) {
|
|
49
|
+
server.addTool({
|
|
50
|
+
name: "git_worktree_add",
|
|
51
|
+
description: "Add a new git worktree. If `branch` does not exist it is created from `baseRef` " +
|
|
52
|
+
"(defaults to HEAD). Refuses to create on a protected branch name.",
|
|
53
|
+
annotations: {
|
|
54
|
+
readOnlyHint: false,
|
|
55
|
+
destructiveHint: false,
|
|
56
|
+
idempotentHint: false,
|
|
57
|
+
},
|
|
58
|
+
parameters: WorkspacePickSchema.extend({
|
|
59
|
+
path: z
|
|
60
|
+
.string()
|
|
61
|
+
.min(1)
|
|
62
|
+
.describe("Filesystem path for the new worktree. Relative paths are resolved from the git toplevel."),
|
|
63
|
+
branch: z
|
|
64
|
+
.string()
|
|
65
|
+
.min(1)
|
|
66
|
+
.describe("Branch to check out in the new worktree. Created from `baseRef` if absent."),
|
|
67
|
+
baseRef: z
|
|
68
|
+
.string()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Commit-ish to base the new branch on when it does not yet exist. Default: HEAD."),
|
|
71
|
+
}),
|
|
72
|
+
execute: async (args) => {
|
|
73
|
+
const pre = requireSingleRepo(server, args);
|
|
74
|
+
if (!pre.ok)
|
|
75
|
+
return jsonRespond(pre.error);
|
|
76
|
+
const { gitTop } = pre;
|
|
77
|
+
// Validate branch
|
|
78
|
+
if (!isSafeGitRefToken(args.branch)) {
|
|
79
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: args.branch });
|
|
80
|
+
}
|
|
81
|
+
if (isProtectedBranch(args.branch)) {
|
|
82
|
+
return jsonRespond({ error: "protected_branch", branch: args.branch });
|
|
83
|
+
}
|
|
84
|
+
if (args.baseRef !== undefined && !isSafeGitRefToken(args.baseRef)) {
|
|
85
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: args.baseRef });
|
|
86
|
+
}
|
|
87
|
+
// Resolve path
|
|
88
|
+
const wtPath = resolve(gitTop, args.path);
|
|
89
|
+
// Check if branch already exists
|
|
90
|
+
const branchCheck = await spawnGitAsync(gitTop, [
|
|
91
|
+
"show-ref",
|
|
92
|
+
"--verify",
|
|
93
|
+
"--quiet",
|
|
94
|
+
`refs/heads/${args.branch}`,
|
|
95
|
+
]);
|
|
96
|
+
const branchExists = branchCheck.ok;
|
|
97
|
+
let gitArgs;
|
|
98
|
+
if (branchExists) {
|
|
99
|
+
gitArgs = ["worktree", "add", wtPath, args.branch];
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
gitArgs = ["worktree", "add", "-b", args.branch, wtPath];
|
|
103
|
+
if (args.baseRef)
|
|
104
|
+
gitArgs.push(args.baseRef);
|
|
105
|
+
}
|
|
106
|
+
const r = await spawnGitAsync(gitTop, gitArgs);
|
|
107
|
+
if (!r.ok) {
|
|
108
|
+
return jsonRespond({
|
|
109
|
+
ok: false,
|
|
110
|
+
error: "worktree_add_failed",
|
|
111
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (args.format === "json") {
|
|
115
|
+
return jsonRespond({
|
|
116
|
+
ok: true,
|
|
117
|
+
path: wtPath,
|
|
118
|
+
branch: args.branch,
|
|
119
|
+
created: !branchExists,
|
|
120
|
+
...spreadDefined("baseRef", !branchExists ? (args.baseRef ?? "HEAD") : undefined),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const createdNote = branchExists ? "" : ` (new branch from ${args.baseRef ?? "HEAD"})`;
|
|
124
|
+
return `# Worktree added\n✓ ${wtPath} → ${args.branch}${createdNote}`;
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// git_worktree_remove
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
export function registerGitWorktreeRemoveTool(server) {
|
|
132
|
+
server.addTool({
|
|
133
|
+
name: "git_worktree_remove",
|
|
134
|
+
description: "Remove a git worktree (`git worktree remove`). " +
|
|
135
|
+
"Pass `force: true` to remove a worktree with uncommitted changes. " +
|
|
136
|
+
"Refuses to remove the main worktree.",
|
|
137
|
+
annotations: {
|
|
138
|
+
readOnlyHint: false,
|
|
139
|
+
destructiveHint: true,
|
|
140
|
+
idempotentHint: false,
|
|
141
|
+
},
|
|
142
|
+
parameters: WorkspacePickSchema.extend({
|
|
143
|
+
path: z
|
|
144
|
+
.string()
|
|
145
|
+
.min(1)
|
|
146
|
+
.describe("Path of the worktree to remove. Must not be the main worktree."),
|
|
147
|
+
force: z
|
|
148
|
+
.boolean()
|
|
149
|
+
.optional()
|
|
150
|
+
.default(false)
|
|
151
|
+
.describe("Pass `--force` to allow removal of worktrees with uncommitted changes."),
|
|
152
|
+
}),
|
|
153
|
+
execute: async (args) => {
|
|
154
|
+
const pre = requireSingleRepo(server, args);
|
|
155
|
+
if (!pre.ok)
|
|
156
|
+
return jsonRespond(pre.error);
|
|
157
|
+
const { gitTop } = pre;
|
|
158
|
+
// Refuse to remove the main worktree
|
|
159
|
+
const wtPath = resolve(gitTop, args.path);
|
|
160
|
+
if (wtPath === gitTop) {
|
|
161
|
+
return jsonRespond({ error: "cannot_remove_main_worktree", path: wtPath });
|
|
162
|
+
}
|
|
163
|
+
// Verify it exists in the worktree list
|
|
164
|
+
const trees = await listWorktrees(gitTop);
|
|
165
|
+
const isRegistered = trees.some((t) => t.path === wtPath || t.path === args.path);
|
|
166
|
+
if (!isRegistered) {
|
|
167
|
+
return jsonRespond({ error: "worktree_not_found", path: args.path });
|
|
168
|
+
}
|
|
169
|
+
const removeArgs = ["worktree", "remove", wtPath];
|
|
170
|
+
if (args.force)
|
|
171
|
+
removeArgs.push("--force");
|
|
172
|
+
const r = await spawnGitAsync(gitTop, removeArgs);
|
|
173
|
+
if (!r.ok) {
|
|
174
|
+
return jsonRespond({
|
|
175
|
+
ok: false,
|
|
176
|
+
error: "worktree_remove_failed",
|
|
177
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
178
|
+
...spreadWhen((r.stderr || r.stdout).includes("contains modified") ||
|
|
179
|
+
(r.stderr || r.stdout).includes("is not empty"), { hint: "Pass force: true to remove a worktree with uncommitted changes." }),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (args.format === "json") {
|
|
183
|
+
return jsonRespond({ ok: true, path: wtPath });
|
|
184
|
+
}
|
|
185
|
+
return `# Worktree removed\n✓ ${wtPath}`;
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -7,7 +7,10 @@ import { WorkspacePickSchema } from "./schemas.js";
|
|
|
7
7
|
export function registerListPresetsTool(server) {
|
|
8
8
|
server.addTool({
|
|
9
9
|
name: "list_presets",
|
|
10
|
-
description: "List presets from .rethunk/git-mcp-presets.json.
|
|
10
|
+
description: "List presets from .rethunk/git-mcp-presets.json.",
|
|
11
|
+
annotations: {
|
|
12
|
+
readOnlyHint: true,
|
|
13
|
+
},
|
|
11
14
|
parameters: WorkspacePickSchema.pick({
|
|
12
15
|
workspaceRoot: true,
|
|
13
16
|
rootIndex: true,
|
package/dist/server/roots.js
CHANGED
|
@@ -118,3 +118,20 @@ export function requireGitAndRoots(server, args, presetName) {
|
|
|
118
118
|
}
|
|
119
119
|
return { ok: true, roots: rootsRes.roots };
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Convenience wrapper for single-repo tools: gate git, resolve roots, pick the first
|
|
123
|
+
* root, and resolve its git toplevel. Returns `{ ok: true, gitTop }` or a structured
|
|
124
|
+
* error payload ready for `jsonRespond`.
|
|
125
|
+
*/
|
|
126
|
+
export function requireSingleRepo(server, args, presetName = undefined) {
|
|
127
|
+
const pre = requireGitAndRoots(server, args, presetName);
|
|
128
|
+
if (!pre.ok)
|
|
129
|
+
return pre;
|
|
130
|
+
const rootInput = pre.roots[0];
|
|
131
|
+
if (!rootInput)
|
|
132
|
+
return { ok: false, error: { error: "no_workspace_root" } };
|
|
133
|
+
const top = gitTopLevel(rootInput);
|
|
134
|
+
if (!top)
|
|
135
|
+
return { ok: false, error: { error: "not_a_git_repository", path: rootInput } };
|
|
136
|
+
return { ok: true, gitTop: top };
|
|
137
|
+
}
|
package/dist/server/schemas.js
CHANGED
|
@@ -3,8 +3,17 @@ import { MAX_INVENTORY_ROOTS_DEFAULT } from "./inventory.js";
|
|
|
3
3
|
const FormatSchema = z.enum(["markdown", "json"]).optional().default("markdown");
|
|
4
4
|
export const WorkspacePickSchema = z.object({
|
|
5
5
|
workspaceRoot: z.string().optional().describe("Highest-priority override."),
|
|
6
|
-
rootIndex: z
|
|
7
|
-
|
|
6
|
+
rootIndex: z
|
|
7
|
+
.number()
|
|
8
|
+
.int()
|
|
9
|
+
.min(0)
|
|
10
|
+
.optional()
|
|
11
|
+
.describe("0-based index into the MCP file roots list; ignored when workspaceRoot is set."),
|
|
12
|
+
allWorkspaceRoots: z
|
|
13
|
+
.boolean()
|
|
14
|
+
.optional()
|
|
15
|
+
.default(false)
|
|
16
|
+
.describe("Fan out across all MCP file roots."),
|
|
8
17
|
format: FormatSchema,
|
|
9
18
|
});
|
|
10
19
|
export { MAX_INVENTORY_ROOTS_DEFAULT };
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* const result = await tool({ workspaceRoot: dir, commits: [...] });
|
|
17
17
|
* // result is string (markdown) or JSON-parseable string
|
|
18
18
|
*/
|
|
19
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
19
22
|
// Stub context — no tool currently uses context
|
|
20
23
|
const STUB_CONTEXT = {
|
|
21
24
|
log: {
|
|
@@ -28,6 +31,30 @@ const STUB_CONTEXT = {
|
|
|
28
31
|
session: undefined,
|
|
29
32
|
};
|
|
30
33
|
// ---------------------------------------------------------------------------
|
|
34
|
+
// Tmp-dir lifecycle — prevents accumulating thousands of leaked test dirs.
|
|
35
|
+
// Each test file must register the hook itself:
|
|
36
|
+
// afterEach(cleanupTmpPaths);
|
|
37
|
+
// Module-scope afterEach(...) would only register once (first-importer wins)
|
|
38
|
+
// because the module is cached across test files in the same bun test run.
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
const tmpPaths = [];
|
|
41
|
+
export function mkTmpDir(prefix) {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
43
|
+
tmpPaths.push(dir);
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
export function trackTmpPath(path) {
|
|
47
|
+
tmpPaths.push(path);
|
|
48
|
+
return path;
|
|
49
|
+
}
|
|
50
|
+
export function cleanupTmpPaths() {
|
|
51
|
+
while (tmpPaths.length > 0) {
|
|
52
|
+
const p = tmpPaths.pop();
|
|
53
|
+
if (p)
|
|
54
|
+
rmSync(p, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
31
58
|
// Fake server
|
|
32
59
|
// ---------------------------------------------------------------------------
|
|
33
60
|
function makeFakeServer() {
|
package/dist/server/tools.js
CHANGED
|
@@ -5,18 +5,29 @@ import { registerGitInventoryTool } from "./git-inventory-tool.js";
|
|
|
5
5
|
import { registerGitLogTool } from "./git-log-tool.js";
|
|
6
6
|
import { registerGitMergeTool } from "./git-merge-tool.js";
|
|
7
7
|
import { registerGitParityTool } from "./git-parity-tool.js";
|
|
8
|
+
import { registerGitPushTool } from "./git-push-tool.js";
|
|
9
|
+
import { registerGitResetSoftTool } from "./git-reset-soft-tool.js";
|
|
8
10
|
import { registerGitStatusTool } from "./git-status-tool.js";
|
|
11
|
+
import { registerGitWorktreeAddTool, registerGitWorktreeListTool, registerGitWorktreeRemoveTool, } from "./git-worktree-tool.js";
|
|
9
12
|
import { registerListPresetsTool } from "./list-presets-tool.js";
|
|
10
13
|
import { registerPresetsResource } from "./presets-resource.js";
|
|
11
14
|
export function registerRethunkGitTools(server) {
|
|
15
|
+
// Read-only tools
|
|
12
16
|
registerGitStatusTool(server);
|
|
13
17
|
registerGitInventoryTool(server);
|
|
14
18
|
registerGitParityTool(server);
|
|
15
19
|
registerListPresetsTool(server);
|
|
16
|
-
registerBatchCommitTool(server);
|
|
17
|
-
registerGitDiffSummaryTool(server);
|
|
18
20
|
registerGitLogTool(server);
|
|
21
|
+
registerGitDiffSummaryTool(server);
|
|
22
|
+
registerGitWorktreeListTool(server);
|
|
23
|
+
// Mutating tools
|
|
24
|
+
registerBatchCommitTool(server);
|
|
25
|
+
registerGitPushTool(server);
|
|
19
26
|
registerGitMergeTool(server);
|
|
20
27
|
registerGitCherryPickTool(server);
|
|
28
|
+
registerGitResetSoftTool(server);
|
|
29
|
+
registerGitWorktreeAddTool(server);
|
|
30
|
+
registerGitWorktreeRemoveTool(server);
|
|
31
|
+
// Resources
|
|
21
32
|
registerPresetsResource(server);
|
|
22
33
|
}
|
package/docs/mcp-tools.md
CHANGED
|
@@ -11,12 +11,17 @@ MCP clients expose tools as `{serverName}_{toolName}`. With the server registere
|
|
|
11
11
|
|
|
12
12
|
| Short id | Client id (server `rethunk-git`) | Purpose |
|
|
13
13
|
|----------|-----------------------------------|---------|
|
|
14
|
-
| `git_status` | `rethunk-git_git_status` | `git status --short -b` per MCP root and optional submodules (`includeSubmodules`); parallel submodule status. Args include `allWorkspaceRoots`, `rootIndex`, `workspaceRoot`, `format`. |
|
|
15
|
-
| `git_inventory` | `rethunk-git_git_inventory` | Status + ahead/behind per path; default upstream each repo’s `@{u}`; pass **both** `remote` and `branch` for fixed tracking. `nestedRoots`, `preset`, `presetMerge`, `maxRoots`, `format`, plus workspace pick args. |
|
|
16
|
-
| `git_parity` | `rethunk-git_git_parity` | Compare `git rev-parse HEAD` for path pairs. `pairs`, `preset`, `presetMerge`, `format`, plus workspace pick args. |
|
|
17
|
-
| `list_presets` | `rethunk-git_list_presets` | List preset names/counts from `.rethunk/git-mcp-presets.json`; invalid JSON/schema surface as errors. Workspace pick + `format` only. |
|
|
18
|
-
| `git_log` | `rethunk-git_git_log` | Path-filtered, time-windowed `git log` across one or more workspace roots. Returns commit history with author, date, subject, and shortstat. Args: `since`, `paths`, `grep`, `author`, `maxCommits`, `branch`, plus workspace pick args + `format`. |
|
|
14
|
+
| `git_status` | `rethunk-git_git_status` | `git status --short -b` per MCP root and optional submodules (`includeSubmodules`); parallel submodule status. Args include `allWorkspaceRoots`, `rootIndex`, `workspaceRoot`, `format`. **Read-only.** |
|
|
15
|
+
| `git_inventory` | `rethunk-git_git_inventory` | Status + ahead/behind per path; default upstream each repo’s `@{u}`; pass **both** `remote` and `branch` for fixed tracking. `nestedRoots`, `preset`, `presetMerge`, `maxRoots`, `format`, plus workspace pick args. **Read-only.** |
|
|
16
|
+
| `git_parity` | `rethunk-git_git_parity` | Compare `git rev-parse HEAD` for path pairs. `pairs`, `preset`, `presetMerge`, `format`, plus workspace pick args. **Read-only.** |
|
|
17
|
+
| `list_presets` | `rethunk-git_list_presets` | List preset names/counts from `.rethunk/git-mcp-presets.json`; invalid JSON/schema surface as errors. Workspace pick + `format` only. **Read-only.** |
|
|
18
|
+
| `git_log` | `rethunk-git_git_log` | Path-filtered, time-windowed `git log` across one or more workspace roots. Returns commit history with author, date, subject, and shortstat. Args: `since`, `paths`, `grep`, `author`, `maxCommits`, `branch`, plus workspace pick args + `format`. **Read-only.** |
|
|
19
19
|
| `git_diff_summary` | `rethunk-git_git_diff_summary` | Structured, token-efficient diff viewer. Returns per-file diffs with additions/deletions counts, truncated to configurable line limits, with lock files/dist/vendor excluded by default. Args: `range`, `fileFilter`, `maxLinesPerFile`, `maxFiles`, `excludePatterns`, plus workspace pick args + `format`. **Read-only.** |
|
|
20
|
+
| `git_worktree_list` | `rethunk-git_git_worktree_list` | List all worktrees (`git worktree list --porcelain`). Workspace pick + `format`. **Read-only.** |
|
|
21
|
+
| `git_push` | `rethunk-git_git_push` | Push the current branch to its upstream. Optional `remote`, `branch`, `setUpstream` (passes `-u`). Refuses on detached HEAD; never force-pushes. Workspace pick + `format`. **Mutating.** |
|
|
22
|
+
| `git_worktree_add` | `rethunk-git_git_worktree_add` | Create a new linked worktree, creating the branch from `baseRef` if it does not yet exist. Refuses on protected branch names. Args: `path`, `branch`, `baseRef?`, plus workspace pick + `format`. **Mutating.** |
|
|
23
|
+
| `git_worktree_remove` | `rethunk-git_git_worktree_remove` | Remove a registered worktree; refuses to remove the main worktree. Optional `force: true` for dirty trees. Args: `path`, `force?`, plus workspace pick + `format`. **Mutating.** |
|
|
24
|
+
| `git_reset_soft` | `rethunk-git_git_reset_soft` | Soft-reset the current branch to a ref (`HEAD~N`, SHA, branch). Rewound changes land in the staging index; requires a clean working tree. Args: `ref`, plus workspace pick + `format`. **Mutating — not idempotent.** |
|
|
20
25
|
| `batch_commit` | `rethunk-git_batch_commit` | Create multiple sequential git commits in a single call. Each entry stages the listed files then commits with the given message. Stops on first failure. Optional `push: "after"` pushes the current branch to its upstream once every commit lands. Args: `commits` (array of `{message, files}`), `push?`, plus workspace pick args + `format`. **Mutating — not idempotent.** |
|
|
21
26
|
| `git_merge` | `rethunk-git_git_merge` | Merge one or more source branches into a destination. Default strategy `auto` cascades fast-forward → rebase → merge-commit per source, preferring linear history. Refuses on dirty tree; stops on first conflict with structured path report. Optional `deleteMergedBranches` / `deleteMergedWorktrees` cascade cleanup, always skipping protected names (main/master/dev/develop/stable/trunk/prod/production/release\*/hotfix\*). Args: `sources`, `into?`, `strategy?`, `message?`, cleanup flags + workspace pick + `format`. **Mutating.** |
|
|
22
27
|
| `git_cherry_pick` | `rethunk-git_git_cherry_pick` | Play commits from one or more sources onto a destination. Sources may be SHAs, `A..B` ranges, or branch names (expanded to `onto..<branch>`, oldest-first). Uses `--empty=drop` so patch-equivalent re-applies add nothing. Refuses on dirty tree; stops on first conflict, aborting cleanly. Same cleanup flags as `git_merge` (branch-kind sources only, protected names skipped). Args: `sources`, `onto?`, cleanup flags + workspace pick + `format`. **Mutating.** |
|
|
@@ -25,9 +30,9 @@ Pass **`format: "json"`** on any tool for structured JSON instead of markdown (d
|
|
|
25
30
|
|
|
26
31
|
## JSON responses
|
|
27
32
|
|
|
28
|
-
Tool JSON bodies are minified and contain only the payload — no `rethunkGitMcp` envelope. Current `MCP_JSON_FORMAT_VERSION` is **`"
|
|
33
|
+
Tool JSON bodies are minified and contain only the payload — no `rethunkGitMcp` envelope. Current `MCP_JSON_FORMAT_VERSION` is **`"3"`**; server + format version are discoverable via MCP `initialize`. Payload keys (`groups`, `inventories`, `parity`, `roots`) are stable within a given format version. Preset-related responses may include **`presetSchemaVersion`**.
|
|
29
34
|
|
|
30
|
-
### v2 field omission (consumer contract)
|
|
35
|
+
### v2/v3 field omission (consumer contract)
|
|
31
36
|
|
|
32
37
|
To keep responses compact, **optional fields are omitted when they would be empty, `null`, or `false`** — they are not emitted as `null`. Consumers must test for *presence*, not compare to `null`.
|
|
33
38
|
|
|
@@ -68,17 +73,15 @@ To keep responses compact, **optional fields are omitted when they would be empt
|
|
|
68
73
|
```json
|
|
69
74
|
{
|
|
70
75
|
"groups": [{
|
|
71
|
-
"
|
|
76
|
+
"workspaceRoot": "/abs/path",
|
|
72
77
|
"repo": "my-repo",
|
|
73
78
|
"branch": "main",
|
|
74
79
|
"commits": [{
|
|
75
|
-
"
|
|
76
|
-
"shaFull": "a1bf184c3d...",
|
|
80
|
+
"sha": "a1bf184c3d…",
|
|
77
81
|
"subject": "feat(satcom): upgrade to PROTOCOL_VERSION 4",
|
|
78
82
|
"author": "Damon Blais",
|
|
79
83
|
"email": "damon@example.com",
|
|
80
84
|
"date": "2026-04-12T18:32:01-07:00",
|
|
81
|
-
"ageRelative": "42m ago",
|
|
82
85
|
"filesChanged": 4,
|
|
83
86
|
"insertions": 16,
|
|
84
87
|
"deletions": 5
|
|
@@ -89,14 +92,16 @@ To keep responses compact, **optional fields are omitted when they would be empt
|
|
|
89
92
|
}
|
|
90
93
|
```
|
|
91
94
|
|
|
92
|
-
|
|
95
|
+
v3 changes from v2: `sha7` removed (use `sha.slice(0,7)` for display); `ageRelative` removed (use `date` — ISO 8601); `email` omitted when empty; `workspace_root` renamed to `workspaceRoot` (camelCase consistency).
|
|
96
|
+
|
|
97
|
+
v2 field-omission rules still apply: `filesChanged`, `insertions`, `deletions` omitted when zero/absent. `truncated` and `omittedCount` omitted when `false`/`0`. A group emits `error` instead of `commits` when git fails for that root.
|
|
93
98
|
|
|
94
99
|
### `git_log` — error codes
|
|
95
100
|
|
|
96
101
|
| Code | Meaning |
|
|
97
102
|
|------|---------|
|
|
98
103
|
| `git_not_found` | `git` binary not on `PATH`. |
|
|
99
|
-
| `
|
|
104
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
100
105
|
| `invalid_since` | The `since` string contains shell metacharacters and was rejected. |
|
|
101
106
|
| `invalid_paths` | One of the `paths` entries contains shell metacharacters and was rejected. |
|
|
102
107
|
| `git_log_failed` | `git log` exited non-zero (e.g. unknown branch ref). |
|
|
@@ -339,6 +344,110 @@ Repo state is cleaned (`git cherry-pick --abort`) before returning — no partia
|
|
|
339
344
|
|
|
340
345
|
---
|
|
341
346
|
|
|
347
|
+
### `git_push` — parameters
|
|
348
|
+
|
|
349
|
+
| Parameter | Type | Notes |
|
|
350
|
+
|-----------|------|-------|
|
|
351
|
+
| `remote` | string | Remote to push to. Defaults to the remote inferred from the upstream tracking ref, or `origin` when `setUpstream` is true. |
|
|
352
|
+
| `branch` | string | Branch to push. Defaults to the currently checked-out branch. Rejected on detached HEAD. |
|
|
353
|
+
| `setUpstream` | boolean | Default `false`. Pass `-u` to set the upstream tracking ref; remote defaults to `origin`. |
|
|
354
|
+
| `workspaceRoot`, `rootIndex`, `format` | — | Standard workspace pick + output format. |
|
|
355
|
+
|
|
356
|
+
### `git_push` — JSON shape (`format: "json"`)
|
|
357
|
+
|
|
358
|
+
```json
|
|
359
|
+
{ "ok": true, "branch": "feature/x", "remote": "origin", "upstream": "origin/feature/x" }
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### `git_push` — error codes
|
|
363
|
+
|
|
364
|
+
| Code | Meaning |
|
|
365
|
+
|------|---------|
|
|
366
|
+
| `push_detached_head` | HEAD is detached; no branch name to push. |
|
|
367
|
+
| `push_no_upstream` | Branch has no configured upstream and `setUpstream` was not requested. |
|
|
368
|
+
| `push_failed` | `git push` exited non-zero. `detail` carries stderr. |
|
|
369
|
+
| `unsafe_ref_token` | `branch` value contains characters outside the safe token set. |
|
|
370
|
+
| `unsafe_remote_token` | `remote` value contains disallowed characters. |
|
|
371
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
### `git_reset_soft` — parameters
|
|
376
|
+
|
|
377
|
+
| Parameter | Type | Notes |
|
|
378
|
+
|-----------|------|-------|
|
|
379
|
+
| `ref` | string | Target commit: `HEAD~N`, branch name, or full/short SHA. |
|
|
380
|
+
| `workspaceRoot`, `rootIndex`, `format` | — | Standard workspace pick + output format. |
|
|
381
|
+
|
|
382
|
+
### `git_reset_soft` — JSON shape (`format: "json"`)
|
|
383
|
+
|
|
384
|
+
```json
|
|
385
|
+
{ "ok": true, "ref": "HEAD~2", "beforeSha": "a1b2c3d…", "afterSha": "f9e8d7c…", "stagedCount": 5 }
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### `git_reset_soft` — error codes
|
|
389
|
+
|
|
390
|
+
| Code | Meaning |
|
|
391
|
+
|------|---------|
|
|
392
|
+
| `unsafe_ref_token` | `ref` contains characters outside the ancestor-safe token set. |
|
|
393
|
+
| `working_tree_dirty` | Working tree has uncommitted/unstaged changes; clean up before resetting. |
|
|
394
|
+
| `status_failed` | `git status` failed unexpectedly. |
|
|
395
|
+
| `reset_failed` | `git reset --soft` failed (e.g. ref does not exist). |
|
|
396
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
### `git_worktree_list` — JSON shape (`format: "json"`)
|
|
401
|
+
|
|
402
|
+
```json
|
|
403
|
+
{ "worktrees": [{ "path": "/abs/path", "branch": "feature/x", "head": "a1b2c3d…" }] }
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
`branch` is `null` for detached-HEAD worktrees.
|
|
407
|
+
|
|
408
|
+
### `git_worktree_add` — parameters
|
|
409
|
+
|
|
410
|
+
| Parameter | Type | Notes |
|
|
411
|
+
|-----------|------|-------|
|
|
412
|
+
| `path` | string | Filesystem path for the new worktree. Relative paths are resolved from the git toplevel. |
|
|
413
|
+
| `branch` | string | Branch to check out. Created from `baseRef` if it does not already exist. |
|
|
414
|
+
| `baseRef` | string | Commit-ish for branch creation. Default: `HEAD`. Ignored when `branch` already exists. |
|
|
415
|
+
| `workspaceRoot`, `rootIndex`, `format` | — | Standard workspace pick + output format. |
|
|
416
|
+
|
|
417
|
+
### `git_worktree_add` — JSON shape (`format: "json"`)
|
|
418
|
+
|
|
419
|
+
```json
|
|
420
|
+
{ "ok": true, "path": "/abs/worktree", "branch": "feature/x", "created": true, "baseRef": "main" }
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### `git_worktree_add` — error codes
|
|
424
|
+
|
|
425
|
+
| Code | Meaning |
|
|
426
|
+
|------|---------|
|
|
427
|
+
| `unsafe_ref_token` | `branch` or `baseRef` contains disallowed characters. |
|
|
428
|
+
| `protected_branch` | `branch` is on the protected names list. |
|
|
429
|
+
| `worktree_add_failed` | `git worktree add` exited non-zero. `detail` carries stderr. |
|
|
430
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
431
|
+
|
|
432
|
+
### `git_worktree_remove` — parameters
|
|
433
|
+
|
|
434
|
+
| Parameter | Type | Notes |
|
|
435
|
+
|-----------|------|-------|
|
|
436
|
+
| `path` | string | Path of the worktree to remove. |
|
|
437
|
+
| `force` | boolean | Default `false`. Pass `--force` to allow removal with uncommitted changes. |
|
|
438
|
+
| `workspaceRoot`, `rootIndex`, `format` | — | Standard workspace pick + output format. |
|
|
439
|
+
|
|
440
|
+
### `git_worktree_remove` — error codes
|
|
441
|
+
|
|
442
|
+
| Code | Meaning |
|
|
443
|
+
|------|---------|
|
|
444
|
+
| `cannot_remove_main_worktree` | `path` resolves to the main (non-linked) worktree. |
|
|
445
|
+
| `worktree_not_found` | `path` is not registered as a worktree in this repo. |
|
|
446
|
+
| `worktree_remove_failed` | `git worktree remove` failed. Pass `force: true` if there are uncommitted changes. |
|
|
447
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
342
451
|
## Resource
|
|
343
452
|
|
|
344
453
|
| URI | Purpose |
|