@rethunk/mcp-multi-root-git 2.8.0 → 2.9.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 +2 -2
- package/CHANGELOG.md +8 -0
- package/HUMANS.md +1 -1
- package/dist/server/batch-commit-tool.js +9 -14
- package/dist/server/git-branch-list-tool.js +2 -4
- package/dist/server/git-cherry-pick-tool.js +11 -21
- package/dist/server/git-diff-summary-tool.js +3 -4
- package/dist/server/git-diff-tool.js +8 -16
- package/dist/server/git-log-tool.js +8 -14
- package/dist/server/git-merge-tool.js +6 -10
- package/dist/server/git-reset-soft-tool.js +3 -6
- package/dist/server/git-stash-tool.js +3 -5
- package/dist/server/git-worktree-tool.js +6 -9
- package/dist/server/schemas.js +4 -4
- package/dist/server/tools.js +66 -25
- package/docs/install.md +1 -0
- package/docs/mcp-tools.md +2 -1
- package/package.json +9 -12
- package/tool-parameters.schema.json +94 -94
package/AGENTS.md
CHANGED
|
@@ -23,7 +23,7 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
23
23
|
| [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT`, **`MAX_ABSOLUTE_GIT_ROOTS`** (256), optional **`absoluteGitRoots`** on workspace pick |
|
|
24
24
|
| [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
|
|
25
25
|
| [`src/server/git-refs.ts`](src/server/git-refs.ts) | `isProtectedBranch`, `isSafeGitRefToken`, `isSafeGitRangeToken`, `isSafeGitAncestorRef`; `getCurrentBranch`, `resolveRef`, `isWorkingTreeClean`, `isFullyMergedInto`, `isContentEquivalentlyMergedInto`, `commitListBetween`; `listWorktrees`, `worktreeForBranch`; `inferRemoteFromUpstream`; `conflictPaths` |
|
|
26
|
-
| [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below |
|
|
26
|
+
| [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below; `selectToolRegistrars(envValue, registrars)` — pure parse/filter fn for `RETHUNK_GIT_TOOLS` (reads env inside `registerRethunkGitTools`, not at module scope); `TOOL_REGISTRARS` ordered array |
|
|
27
27
|
| [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `git_status` |
|
|
28
28
|
| [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `git_inventory` |
|
|
29
29
|
| [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `git_parity` |
|
|
@@ -54,7 +54,7 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
54
54
|
|
|
55
55
|
## Validate + CI
|
|
56
56
|
|
|
57
|
-
Local: `bun run build` | `bun run
|
|
57
|
+
Local: `bun run build` | `bun run lint` | `bun run schema:tools:check` | `bun run test`. CI ([`ci.yml`](.github/workflows/ci.yml)) runs same on PRs + `main` after `bun install --frozen-lockfile`, then `bun run test:coverage` + `bun run coverage:check`, and uploads prerelease `npm pack` artifact. Tag `v*.*.*` matching `package.json` version → [`release.yml`](.github/workflows/release.yml) publishes to GitHub Packages as `@rethunk-ai/mcp-multi-root-git` + cuts GitHub Release. npmjs publish is manual (see [HUMANS.md](HUMANS.md)).
|
|
58
58
|
|
|
59
59
|
Optional [`.githooks/`](.githooks): `bun run setup-hooks` once per clone. pre-commit=`check`; pre-push=frozen install + build + check + test.
|
|
60
60
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@rethunk/mcp-multi-root-git` are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com); the project uses [Semantic Versioning](https://semver.org).
|
|
4
4
|
|
|
5
|
+
## [2.9.0] — 2026-06-05
|
|
6
|
+
|
|
7
|
+
Feature release: `RETHUNK_GIT_TOOLS` allowlist env var. Additive — no JSON format-version bump.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`RETHUNK_GIT_TOOLS`** — comma-separated allowlist of exact tool names. When set to a non-empty value, only the listed tools are registered; unknown names are warned to stderr and ignored. When unset or empty (default), all 23 tools are registered — zero behavioral change for existing consumers. If every name in the list is unrecognized, zero tools are registered and a loud warning is emitted (the restriction is honored literally rather than falling back to all tools). The presets resource (`rethunk-git://presets`) is always registered regardless of this setting. Cuts MCP token cost for agents that only need a small subset of tools. Example: `RETHUNK_GIT_TOOLS=git_status,git_diff_summary,git_diff,git_log,batch_commit,git_push`.
|
|
12
|
+
|
|
5
13
|
## [2.8.0] — 2026-05-29
|
|
6
14
|
|
|
7
15
|
Feature release: three new read-only inspection tools (tool count 20 → 23). Additive — no JSON format-version bump.
|
package/HUMANS.md
CHANGED
|
@@ -205,7 +205,7 @@ It verifies the `package.json` version has a matching `CHANGELOG.md` section, bo
|
|
|
205
205
|
|
|
206
206
|
npmjs no longer fits an unattended CI publish flow for this org; **do not** rely on automation to [npmjs](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git). To publish the **same** package name consumers already use (**`@rethunk/mcp-multi-root-git`**):
|
|
207
207
|
|
|
208
|
-
1. On a clean checkout at the release commit (usually **`main`** after bumping version), run **`bun run prepublishOnly`** (or `bun run build && bun run
|
|
208
|
+
1. On a clean checkout at the release commit (usually **`main`** after bumping version), run **`bun run prepublishOnly`** (or `bun run build && bun run lint && bun run test`).
|
|
209
209
|
2. Log in to the public registry once per machine: **`npm login`** (or `npm adduser`) so **`npm whoami`** shows the account that owns **`@rethunk`** on npmjs.
|
|
210
210
|
3. Ensure **`package.json`** still has **`"name": "@rethunk/mcp-multi-root-git"`** and **`publishConfig.access`** is **`"public"`** (no **`publishConfig.registry`** pointing at GitHub — leave default registry for npmjs).
|
|
211
211
|
4. Publish: **`npm publish --access public`** (runs **`prepublishOnly`** again unless you pass **`--ignore-scripts`** after you already verified locally).
|
|
@@ -24,23 +24,20 @@ const CommitEntrySchema = z.object({
|
|
|
24
24
|
files: z
|
|
25
25
|
.array(FileEntrySchema)
|
|
26
26
|
.min(1)
|
|
27
|
-
.describe("Paths to stage, relative to
|
|
28
|
-
"Deleted
|
|
29
|
-
"Combining { path, lines } with a deleted file is an error."),
|
|
27
|
+
.describe("Paths to stage, relative to git root. String or `{ path, lines }` for hunk-level staging. " +
|
|
28
|
+
"Deleted tracked files are staged via `git rm --cached`. Cannot combine `lines` with a deleted file."),
|
|
30
29
|
});
|
|
31
30
|
const PushModeSchema = z
|
|
32
31
|
.enum(["never", "after"])
|
|
33
32
|
.optional()
|
|
34
33
|
.default("never")
|
|
35
|
-
.describe("`never` (default): no push. `after`: push
|
|
36
|
-
"fails with `push_no_upstream` if
|
|
37
|
-
"Enum reserved for future modes such as `force-with-lease`.");
|
|
34
|
+
.describe("`never` (default): no push. `after`: push current branch to upstream after all commits succeed; " +
|
|
35
|
+
"fails with `push_no_upstream` if no upstream (commits are NOT rolled back).");
|
|
38
36
|
const DryRunSchema = z
|
|
39
37
|
.boolean()
|
|
40
38
|
.optional()
|
|
41
39
|
.default(false)
|
|
42
|
-
.describe("
|
|
43
|
-
"Unstages any files that were staged for the preview. Response indicates DRY RUN mode.");
|
|
40
|
+
.describe("Stage files and return a preview without writing commits; unstages afterwards. Response is marked DRY RUN.");
|
|
44
41
|
/**
|
|
45
42
|
* Parses a unified diff to extract hunks that overlap with a given line range.
|
|
46
43
|
* Returns a partial patch containing only the overlapping hunks, including header lines.
|
|
@@ -220,11 +217,9 @@ export async function runPushAfter(gitTop) {
|
|
|
220
217
|
export function registerBatchCommitTool(server) {
|
|
221
218
|
server.addTool({
|
|
222
219
|
name: "batch_commit",
|
|
223
|
-
description: "Create multiple sequential git commits in
|
|
224
|
-
"Each entry stages
|
|
225
|
-
'
|
|
226
|
-
"to its upstream once all commits succeed. " +
|
|
227
|
-
"Optional `dryRun: true` previews what would be staged/committed without writing commits.",
|
|
220
|
+
description: "Create multiple sequential git commits in one call. " +
|
|
221
|
+
"Each entry stages its files then commits. Stops on first failure. " +
|
|
222
|
+
'Optional `push: "after"` pushes after all commits succeed. `dryRun: true` previews without writing.',
|
|
228
223
|
annotations: {
|
|
229
224
|
readOnlyHint: false,
|
|
230
225
|
destructiveHint: false,
|
|
@@ -235,7 +230,7 @@ export function registerBatchCommitTool(server) {
|
|
|
235
230
|
.array(CommitEntrySchema)
|
|
236
231
|
.min(1)
|
|
237
232
|
.max(50)
|
|
238
|
-
.describe("
|
|
233
|
+
.describe("Ordered list of commits to create."),
|
|
239
234
|
push: PushModeSchema,
|
|
240
235
|
dryRun: DryRunSchema,
|
|
241
236
|
}),
|
|
@@ -100,9 +100,7 @@ function renderBranchListMarkdown(result) {
|
|
|
100
100
|
export function registerGitBranchListTool(server) {
|
|
101
101
|
server.addTool({
|
|
102
102
|
name: "git_branch_list",
|
|
103
|
-
description: "List local
|
|
104
|
-
"Returns `{ branches: [{ name, sha, current, upstream? }], remotes?: [{ name, sha }] }`. " +
|
|
105
|
-
"The current branch is marked with `current: true`. Remote branches are included when `includeRemotes: true`.",
|
|
103
|
+
description: "List local (and optionally remote-tracking) branches. Current branch marked `current: true`. Set `includeRemotes: true` for remotes.",
|
|
106
104
|
annotations: {
|
|
107
105
|
readOnlyHint: true,
|
|
108
106
|
},
|
|
@@ -117,7 +115,7 @@ export function registerGitBranchListTool(server) {
|
|
|
117
115
|
.boolean()
|
|
118
116
|
.optional()
|
|
119
117
|
.default(false)
|
|
120
|
-
.describe("
|
|
118
|
+
.describe("Include remote-tracking branches from refs/remotes (symbolic origin/HEAD excluded)."),
|
|
121
119
|
}),
|
|
122
120
|
execute: async (args) => {
|
|
123
121
|
const pre = requireSingleRepo(server, args);
|
|
@@ -94,14 +94,11 @@ async function filterAndDedupe(gitTop, onto, resolved) {
|
|
|
94
94
|
export function registerGitCherryPickTool(server) {
|
|
95
95
|
server.addTool({
|
|
96
96
|
name: "git_cherry_pick",
|
|
97
|
-
description: "
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
"(content-identical commits with different SHAs are treated as merged, which is the " +
|
|
103
|
-
"normal cherry-pick outcome). Pass `strictMergedRefEquality: true` for strict `git branch -d` " +
|
|
104
|
-
"ancestry semantics. Protected branch names always skipped.",
|
|
97
|
+
description: "Cherry-pick commits from one or more sources onto a destination. Sources: SHAs, `A..B` ranges, " +
|
|
98
|
+
"or branch names (expanded to `onto..<branch>`, oldest-first). Already-reachable commits skipped. " +
|
|
99
|
+
"Refuses on dirty tree; stops on first conflict. Optional flags delete source branches/worktrees " +
|
|
100
|
+
"after success using patch-id equivalence (set `strictMergedRefEquality: true` for strict ancestry). " +
|
|
101
|
+
"Protected names always skipped.",
|
|
105
102
|
annotations: {
|
|
106
103
|
readOnlyHint: false,
|
|
107
104
|
destructiveHint: false,
|
|
@@ -112,8 +109,7 @@ export function registerGitCherryPickTool(server) {
|
|
|
112
109
|
.array(z.string().min(1))
|
|
113
110
|
.min(1)
|
|
114
111
|
.max(50)
|
|
115
|
-
.describe("Sources
|
|
116
|
-
"resolve to `onto..<branch>` (only commits missing from destination)."),
|
|
112
|
+
.describe("Sources: SHA, `A..B` range, or branch name (resolves to `onto..<branch>`)."),
|
|
117
113
|
onto: z
|
|
118
114
|
.string()
|
|
119
115
|
.optional()
|
|
@@ -122,24 +118,18 @@ export function registerGitCherryPickTool(server) {
|
|
|
122
118
|
.boolean()
|
|
123
119
|
.optional()
|
|
124
120
|
.default(false)
|
|
125
|
-
.describe("
|
|
126
|
-
"(`git branch -d`) when it is fully merged into the destination. " +
|
|
127
|
-
"Protected names always skipped; never touches remote refs."),
|
|
121
|
+
.describe("Delete branch-kind sources locally after success. Protected names and remote refs unaffected."),
|
|
128
122
|
deleteMergedWorktrees: z
|
|
129
123
|
.boolean()
|
|
130
124
|
.optional()
|
|
131
125
|
.default(false)
|
|
132
|
-
.describe("
|
|
133
|
-
"(`git worktree remove`). Protected tails always skipped."),
|
|
126
|
+
.describe("Remove local worktrees on branch-kind sources after success. Protected tails skipped."),
|
|
134
127
|
strictMergedRefEquality: z
|
|
135
128
|
.boolean()
|
|
136
129
|
.optional()
|
|
137
130
|
.default(false)
|
|
138
|
-
.describe("
|
|
139
|
-
"
|
|
140
|
-
"destination (same diff, different SHA — the normal cherry-pick outcome). " +
|
|
141
|
-
"Set to true to require strict ref ancestry (`git branch -d` semantics), which " +
|
|
142
|
-
"will refuse deletion after a cherry-pick because the SHA differs."),
|
|
131
|
+
.describe("false (default): delete branch when every commit is content-equivalent on destination (patch-id, normal cherry-pick outcome). " +
|
|
132
|
+
"true: require strict ref ancestry (`git branch -d` semantics — will refuse after cherry-pick due to SHA mismatch)."),
|
|
143
133
|
}),
|
|
144
134
|
execute: async (args) => {
|
|
145
135
|
const pre = requireSingleRepo(server, args);
|
|
@@ -218,7 +208,7 @@ export function registerGitCherryPickTool(server) {
|
|
|
218
208
|
if (allOk) {
|
|
219
209
|
for (let i = 0; i < perSourceReport.length; i++) {
|
|
220
210
|
const src = perSourceReport[i];
|
|
221
|
-
if (
|
|
211
|
+
if (src?.kind !== "branch")
|
|
222
212
|
continue;
|
|
223
213
|
if (isProtectedBranch(src.raw))
|
|
224
214
|
continue;
|
|
@@ -179,9 +179,8 @@ function rangeLabel(range, diffArgs) {
|
|
|
179
179
|
export function registerGitDiffSummaryTool(server) {
|
|
180
180
|
server.addTool({
|
|
181
181
|
name: "git_diff_summary",
|
|
182
|
-
description: "Structured
|
|
183
|
-
"
|
|
184
|
-
"Use `range` to target staged, HEAD, or any revision range.",
|
|
182
|
+
description: "Structured diff viewer: per-file diffs with counts, truncated to configurable limits. " +
|
|
183
|
+
"Noise files (lock files, dist, etc.) excluded by default. Use `range` to target staged, HEAD, or a revision range.",
|
|
185
184
|
annotations: {
|
|
186
185
|
readOnlyHint: true,
|
|
187
186
|
},
|
|
@@ -214,7 +213,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
214
213
|
excludePatterns: z
|
|
215
214
|
.array(z.string())
|
|
216
215
|
.optional()
|
|
217
|
-
.describe("Glob patterns to exclude.
|
|
216
|
+
.describe("Glob patterns to exclude. Default: lock files, dist, vendor, etc."),
|
|
218
217
|
}),
|
|
219
218
|
execute: async (args) => {
|
|
220
219
|
const pre = requireSingleRepo(server, args);
|
|
@@ -61,10 +61,8 @@ function rangeLabel(opts) {
|
|
|
61
61
|
export function registerGitDiffTool(server) {
|
|
62
62
|
server.addTool({
|
|
63
63
|
name: "git_diff",
|
|
64
|
-
description: "
|
|
65
|
-
"
|
|
66
|
-
"`path` to scope to a single file, `paths` to scope to multiple files, " +
|
|
67
|
-
"and `unified` to control the number of context lines.",
|
|
64
|
+
description: "Raw diff text for scoped file(s) or range. `staged: true` for staged changes, " +
|
|
65
|
+
"`base`/`head` for revision ranges, `path`/`paths` to scope, `unified` for context lines.",
|
|
68
66
|
annotations: {
|
|
69
67
|
readOnlyHint: true,
|
|
70
68
|
},
|
|
@@ -75,37 +73,31 @@ export function registerGitDiffTool(server) {
|
|
|
75
73
|
base: z
|
|
76
74
|
.string()
|
|
77
75
|
.optional()
|
|
78
|
-
.describe('Base ref (e.g. "main", "HEAD~3").
|
|
79
|
-
"If omitted and `staged: false`, shows unstaged changes."),
|
|
76
|
+
.describe('Base ref (e.g. "main", "HEAD~3"). Omit for unstaged changes.'),
|
|
80
77
|
head: z
|
|
81
78
|
.string()
|
|
82
79
|
.optional()
|
|
83
|
-
.describe('Head ref (e.g. "feature-branch").
|
|
84
|
-
"Only used if `base` is provided."),
|
|
80
|
+
.describe('Head ref (e.g. "feature-branch"). Defaults to HEAD. Used only when `base` is set.'),
|
|
85
81
|
path: z
|
|
86
82
|
.string()
|
|
87
83
|
.optional()
|
|
88
|
-
.describe(
|
|
89
|
-
"For multiple files, prefer `paths`. If both `path` and `paths` are given, they are unioned."),
|
|
84
|
+
.describe("Scope to a single file. Unioned with `paths` if both given."),
|
|
90
85
|
paths: z
|
|
91
86
|
.array(z.string())
|
|
92
87
|
.optional()
|
|
93
|
-
.describe(
|
|
94
|
-
"Each path is validated and must lie within the repository root. " +
|
|
95
|
-
"If both `path` and `paths` are given, they are unioned."),
|
|
88
|
+
.describe("Scope to multiple files (must be within repo root). Unioned with `path` if both given."),
|
|
96
89
|
staged: z
|
|
97
90
|
.boolean()
|
|
98
91
|
.optional()
|
|
99
92
|
.default(false)
|
|
100
|
-
.describe("
|
|
93
|
+
.describe("Show staged changes (`git diff --staged`). Ignored if `base` is set."),
|
|
101
94
|
unified: z
|
|
102
95
|
.number()
|
|
103
96
|
.int()
|
|
104
97
|
.min(0)
|
|
105
98
|
.max(100)
|
|
106
99
|
.optional()
|
|
107
|
-
.describe("
|
|
108
|
-
"Defaults to git's built-in default (3). Use 0 for no context."),
|
|
100
|
+
.describe("Context lines around each change (`-U<n>`). Default: 3. Use 0 for no context."),
|
|
109
101
|
}),
|
|
110
102
|
execute: async (args) => {
|
|
111
103
|
const pre = requireSingleRepo(server, args);
|
|
@@ -176,8 +176,7 @@ function renderLogMarkdown(group, filterSummary) {
|
|
|
176
176
|
export function registerGitLogTool(server) {
|
|
177
177
|
server.addTool({
|
|
178
178
|
name: "git_log",
|
|
179
|
-
description: "
|
|
180
|
-
"Returns structured commit history with author, date, subject, and optional diff stats.",
|
|
179
|
+
description: "Read-only `git log` across one or more roots. Returns author, date, subject, optional diff stats.",
|
|
181
180
|
annotations: {
|
|
182
181
|
readOnlyHint: true,
|
|
183
182
|
},
|
|
@@ -186,26 +185,21 @@ export function registerGitLogTool(server) {
|
|
|
186
185
|
.enum(["markdown", "json", "oneline"])
|
|
187
186
|
.optional()
|
|
188
187
|
.default("markdown")
|
|
189
|
-
.describe("`markdown` (default): headed sections per root. " +
|
|
190
|
-
"`
|
|
191
|
-
"`oneline`: `<sha7> <subject>` per line, no headers (single-root) or `### repo (branch)` separator per group (multi-root). Lowest-token option for post-commit verification."),
|
|
188
|
+
.describe("`markdown` (default): headed sections per root. `json`: groups array. " +
|
|
189
|
+
"`oneline`: `<sha7> <subject>` per line; lowest-token option for post-commit verification."),
|
|
192
190
|
since: z
|
|
193
191
|
.string()
|
|
194
192
|
.optional()
|
|
195
|
-
.describe("Passed to
|
|
196
|
-
"`48.hours` or `2.weeks.ago`. Default: `7.days`."),
|
|
193
|
+
.describe("Passed to `--since=`. ISO timestamp or git relative form (`48.hours`, `2.weeks.ago`). Default: `7.days`."),
|
|
197
194
|
paths: z
|
|
198
195
|
.array(z.string())
|
|
199
196
|
.optional()
|
|
200
|
-
.describe("Limit to commits touching these paths (
|
|
197
|
+
.describe("Limit to commits touching these paths (`-- <paths>`)."),
|
|
201
198
|
grep: z
|
|
202
199
|
.string()
|
|
203
200
|
.optional()
|
|
204
|
-
.describe("Filter
|
|
205
|
-
author: z
|
|
206
|
-
.string()
|
|
207
|
-
.optional()
|
|
208
|
-
.describe("Filter by author name or email (passed as `--author=`)."),
|
|
201
|
+
.describe("Filter by commit message regex (git `--grep`, case-insensitive)."),
|
|
202
|
+
author: z.string().optional().describe("Filter by author name or email (`--author=`)."),
|
|
209
203
|
maxCommits: z
|
|
210
204
|
.number()
|
|
211
205
|
.int()
|
|
@@ -213,7 +207,7 @@ export function registerGitLogTool(server) {
|
|
|
213
207
|
.max(MAX_COMMITS_HARD_CAP)
|
|
214
208
|
.optional()
|
|
215
209
|
.default(DEFAULT_MAX_COMMITS)
|
|
216
|
-
.describe(`
|
|
210
|
+
.describe(`Max commits per root (hard cap ${MAX_COMMITS_HARD_CAP}, default ${DEFAULT_MAX_COMMITS}).`),
|
|
217
211
|
branch: z.string().optional().describe("Ref/branch to log from. Default: HEAD."),
|
|
218
212
|
}),
|
|
219
213
|
execute: async (args) => {
|
|
@@ -207,12 +207,10 @@ async function maybeDeleteBranch(gitTop, source, enabled, into) {
|
|
|
207
207
|
export function registerGitMergeTool(server) {
|
|
208
208
|
server.addTool({
|
|
209
209
|
name: "git_merge",
|
|
210
|
-
description: "Merge one or more source branches into a destination.
|
|
211
|
-
"
|
|
212
|
-
"Refuses
|
|
213
|
-
"
|
|
214
|
-
"skipping protected names (main, master, dev, develop, stable, trunk, prod, " +
|
|
215
|
-
"production, release/*, hotfix/*).",
|
|
210
|
+
description: "Merge one or more source branches into a destination. `auto` cascades " +
|
|
211
|
+
"fast-forward → rebase → merge-commit, preferring linear history. " +
|
|
212
|
+
"Refuses on dirty tree; stops on first conflict. Optional flags delete merged branches/worktrees " +
|
|
213
|
+
"(protected names skipped: main, master, dev, develop, stable, trunk, prod, production, release/*, hotfix/*).",
|
|
216
214
|
annotations: {
|
|
217
215
|
readOnlyHint: false,
|
|
218
216
|
destructiveHint: false,
|
|
@@ -237,14 +235,12 @@ export function registerGitMergeTool(server) {
|
|
|
237
235
|
.boolean()
|
|
238
236
|
.optional()
|
|
239
237
|
.default(false)
|
|
240
|
-
.describe("
|
|
241
|
-
"Protected names always skipped. Never affects remote branches."),
|
|
238
|
+
.describe("Delete each source branch locally after clean merge (`git branch -d`). Protected names and remote refs unaffected."),
|
|
242
239
|
deleteMergedWorktrees: z
|
|
243
240
|
.boolean()
|
|
244
241
|
.optional()
|
|
245
242
|
.default(false)
|
|
246
|
-
.describe("
|
|
247
|
-
"on a source branch (`git worktree remove`). Protected tails always skipped."),
|
|
243
|
+
.describe("Remove local worktrees on source branches after clean merge (`git worktree remove`). Protected tails skipped."),
|
|
248
244
|
}),
|
|
249
245
|
execute: async (args) => {
|
|
250
246
|
const pre = requireSingleRepo(server, args);
|
|
@@ -8,10 +8,8 @@ import { WorkspacePickSchema } from "./schemas.js";
|
|
|
8
8
|
export function registerGitResetSoftTool(server) {
|
|
9
9
|
server.addTool({
|
|
10
10
|
name: "git_reset_soft",
|
|
11
|
-
description: "
|
|
12
|
-
"
|
|
13
|
-
"in the staging index — use this to re-split an already-committed chunk. " +
|
|
14
|
-
"Refuses when the working tree has any uncommitted or unstaged changes (run on a clean tree).",
|
|
11
|
+
description: "`git reset --soft <ref>`: moves HEAD back while keeping rewound changes staged. " +
|
|
12
|
+
"Use to re-split committed work. Refuses on a dirty tree.",
|
|
15
13
|
annotations: {
|
|
16
14
|
readOnlyHint: false,
|
|
17
15
|
destructiveHint: false,
|
|
@@ -21,8 +19,7 @@ export function registerGitResetSoftTool(server) {
|
|
|
21
19
|
ref: z
|
|
22
20
|
.string()
|
|
23
21
|
.min(1)
|
|
24
|
-
.describe("Commit to reset to
|
|
25
|
-
"branch names, or full SHAs."),
|
|
22
|
+
.describe("Commit to reset to: ancestor notation (`HEAD~1`, `HEAD~3`), branch name, or SHA."),
|
|
26
23
|
}),
|
|
27
24
|
execute: async (args) => {
|
|
28
25
|
const pre = requireSingleRepo(server, args);
|
|
@@ -10,7 +10,7 @@ import { WorkspacePickSchema } from "./schemas.js";
|
|
|
10
10
|
export function registerGitStashListTool(server) {
|
|
11
11
|
server.addTool({
|
|
12
12
|
name: "git_stash_list",
|
|
13
|
-
description: "List all git stashes.
|
|
13
|
+
description: "List all git stashes.",
|
|
14
14
|
annotations: {
|
|
15
15
|
readOnlyHint: true,
|
|
16
16
|
},
|
|
@@ -77,9 +77,7 @@ export function registerGitStashListTool(server) {
|
|
|
77
77
|
export function registerGitStashApplyTool(server) {
|
|
78
78
|
server.addTool({
|
|
79
79
|
name: "git_stash_apply",
|
|
80
|
-
description: "Apply or pop a git stash. `index` defaults to 0
|
|
81
|
-
"Set `pop: true` to run `git stash pop` instead of `git stash apply` (removes stash after applying). " +
|
|
82
|
-
"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.",
|
|
83
81
|
annotations: {
|
|
84
82
|
readOnlyHint: false,
|
|
85
83
|
destructiveHint: false,
|
|
@@ -103,7 +101,7 @@ export function registerGitStashApplyTool(server) {
|
|
|
103
101
|
.boolean()
|
|
104
102
|
.optional()
|
|
105
103
|
.default(false)
|
|
106
|
-
.describe("
|
|
104
|
+
.describe("Run `git stash pop` instead of `git stash apply` (removes stash after applying)."),
|
|
107
105
|
}),
|
|
108
106
|
execute: async (args) => {
|
|
109
107
|
const pre = requireSingleRepo(server, args);
|
|
@@ -49,8 +49,7 @@ export function registerGitWorktreeListTool(server) {
|
|
|
49
49
|
export function registerGitWorktreeAddTool(server) {
|
|
50
50
|
server.addTool({
|
|
51
51
|
name: "git_worktree_add",
|
|
52
|
-
description: "Add a new git worktree.
|
|
53
|
-
"(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.",
|
|
54
53
|
annotations: {
|
|
55
54
|
readOnlyHint: false,
|
|
56
55
|
destructiveHint: false,
|
|
@@ -60,15 +59,15 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
60
59
|
path: z
|
|
61
60
|
.string()
|
|
62
61
|
.min(1)
|
|
63
|
-
.describe("Filesystem path for the new worktree
|
|
62
|
+
.describe("Filesystem path for the new worktree (relative paths resolved from git toplevel)."),
|
|
64
63
|
branch: z
|
|
65
64
|
.string()
|
|
66
65
|
.min(1)
|
|
67
|
-
.describe("Branch to check out
|
|
66
|
+
.describe("Branch to check out; created from `baseRef` if it doesn't exist."),
|
|
68
67
|
baseRef: z
|
|
69
68
|
.string()
|
|
70
69
|
.optional()
|
|
71
|
-
.describe("Commit-ish to base the new branch on
|
|
70
|
+
.describe("Commit-ish to base the new branch on. Default: HEAD."),
|
|
72
71
|
}),
|
|
73
72
|
execute: async (args) => {
|
|
74
73
|
const pre = requireSingleRepo(server, args);
|
|
@@ -132,9 +131,7 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
132
131
|
export function registerGitWorktreeRemoveTool(server) {
|
|
133
132
|
server.addTool({
|
|
134
133
|
name: "git_worktree_remove",
|
|
135
|
-
description: "Remove a git worktree
|
|
136
|
-
"Pass `force: true` to remove a worktree with uncommitted changes. " +
|
|
137
|
-
"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.",
|
|
138
135
|
annotations: {
|
|
139
136
|
readOnlyHint: false,
|
|
140
137
|
destructiveHint: true,
|
|
@@ -149,7 +146,7 @@ export function registerGitWorktreeRemoveTool(server) {
|
|
|
149
146
|
.boolean()
|
|
150
147
|
.optional()
|
|
151
148
|
.default(false)
|
|
152
|
-
.describe("
|
|
149
|
+
.describe("Allow removal of worktrees with uncommitted changes (`--force`)."),
|
|
153
150
|
}),
|
|
154
151
|
execute: async (args) => {
|
|
155
152
|
const pre = requireSingleRepo(server, args);
|
package/dist/server/schemas.js
CHANGED
|
@@ -4,24 +4,24 @@ const FormatSchema = z.enum(["markdown", "json"]).optional().default("markdown")
|
|
|
4
4
|
/** Max paths in `absoluteGitRoots` (matches `git_inventory` `maxRoots` hard cap). */
|
|
5
5
|
export const MAX_ABSOLUTE_GIT_ROOTS = 256;
|
|
6
6
|
export const WorkspacePickSchema = z.object({
|
|
7
|
-
workspaceRoot: z.string().optional().describe("Highest-priority override."),
|
|
7
|
+
workspaceRoot: z.string().optional().describe("Highest-priority root override."),
|
|
8
8
|
rootIndex: z
|
|
9
9
|
.number()
|
|
10
10
|
.int()
|
|
11
11
|
.min(0)
|
|
12
12
|
.optional()
|
|
13
|
-
.describe("0-based index into
|
|
13
|
+
.describe("0-based index into MCP roots; ignored if workspaceRoot set."),
|
|
14
14
|
allWorkspaceRoots: z
|
|
15
15
|
.boolean()
|
|
16
16
|
.optional()
|
|
17
17
|
.default(false)
|
|
18
|
-
.describe("Fan out across all MCP
|
|
18
|
+
.describe("Fan out across all MCP roots."),
|
|
19
19
|
/** Independent git worktrees (sibling clones). Mutually exclusive with workspaceRoot, rootIndex, allWorkspaceRoots, and (git_inventory) preset/nestedRoots. */
|
|
20
20
|
absoluteGitRoots: z
|
|
21
21
|
.array(z.string())
|
|
22
22
|
.max(MAX_ABSOLUTE_GIT_ROOTS)
|
|
23
23
|
.optional()
|
|
24
|
-
.describe("Absolute paths to git repo roots
|
|
24
|
+
.describe("Absolute paths to git repo roots; use for sibling clones under a non-git parent."),
|
|
25
25
|
format: FormatSchema,
|
|
26
26
|
});
|
|
27
27
|
export { MAX_INVENTORY_ROOTS_DEFAULT };
|
package/dist/server/tools.js
CHANGED
|
@@ -19,32 +19,73 @@ import { registerGitTagTool } from "./git-tag-tool.js";
|
|
|
19
19
|
import { registerGitWorktreeAddTool, registerGitWorktreeListTool, registerGitWorktreeRemoveTool, } from "./git-worktree-tool.js";
|
|
20
20
|
import { registerListPresetsTool } from "./list-presets-tool.js";
|
|
21
21
|
import { registerPresetsResource } from "./presets-resource.js";
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Ordered registry of all 23 MCP tools. Registration order is preserved for
|
|
24
|
+
* both full and filtered (RETHUNK_GIT_TOOLS) subsets.
|
|
25
|
+
*/
|
|
26
|
+
const TOOL_REGISTRARS = [
|
|
23
27
|
// Read-only tools
|
|
24
|
-
registerGitStatusTool
|
|
25
|
-
registerGitInventoryTool
|
|
26
|
-
registerGitParityTool
|
|
27
|
-
registerListPresetsTool
|
|
28
|
-
registerGitLogTool
|
|
29
|
-
registerGitDiffSummaryTool
|
|
30
|
-
registerGitDiffTool
|
|
31
|
-
registerGitShowTool
|
|
32
|
-
registerGitWorktreeListTool
|
|
33
|
-
registerGitStashListTool
|
|
34
|
-
registerGitFetchTool
|
|
35
|
-
registerGitBlameTool
|
|
36
|
-
registerGitBranchListTool
|
|
37
|
-
registerGitReflogTool
|
|
28
|
+
{ name: "git_status", register: registerGitStatusTool },
|
|
29
|
+
{ name: "git_inventory", register: registerGitInventoryTool },
|
|
30
|
+
{ name: "git_parity", register: registerGitParityTool },
|
|
31
|
+
{ name: "list_presets", register: registerListPresetsTool },
|
|
32
|
+
{ name: "git_log", register: registerGitLogTool },
|
|
33
|
+
{ name: "git_diff_summary", register: registerGitDiffSummaryTool },
|
|
34
|
+
{ name: "git_diff", register: registerGitDiffTool },
|
|
35
|
+
{ name: "git_show", register: registerGitShowTool },
|
|
36
|
+
{ name: "git_worktree_list", register: registerGitWorktreeListTool },
|
|
37
|
+
{ name: "git_stash_list", register: registerGitStashListTool },
|
|
38
|
+
{ name: "git_fetch", register: registerGitFetchTool },
|
|
39
|
+
{ name: "git_blame", register: registerGitBlameTool },
|
|
40
|
+
{ name: "git_branch_list", register: registerGitBranchListTool },
|
|
41
|
+
{ name: "git_reflog", register: registerGitReflogTool },
|
|
38
42
|
// Mutating tools
|
|
39
|
-
registerBatchCommitTool
|
|
40
|
-
registerGitPushTool
|
|
41
|
-
registerGitMergeTool
|
|
42
|
-
registerGitCherryPickTool
|
|
43
|
-
registerGitResetSoftTool
|
|
44
|
-
registerGitTagTool
|
|
45
|
-
registerGitWorktreeAddTool
|
|
46
|
-
registerGitWorktreeRemoveTool
|
|
47
|
-
registerGitStashApplyTool
|
|
48
|
-
|
|
43
|
+
{ name: "batch_commit", register: registerBatchCommitTool },
|
|
44
|
+
{ name: "git_push", register: registerGitPushTool },
|
|
45
|
+
{ name: "git_merge", register: registerGitMergeTool },
|
|
46
|
+
{ name: "git_cherry_pick", register: registerGitCherryPickTool },
|
|
47
|
+
{ name: "git_reset_soft", register: registerGitResetSoftTool },
|
|
48
|
+
{ name: "git_tag", register: registerGitTagTool },
|
|
49
|
+
{ name: "git_worktree_add", register: registerGitWorktreeAddTool },
|
|
50
|
+
{ name: "git_worktree_remove", register: registerGitWorktreeRemoveTool },
|
|
51
|
+
{ name: "git_stash_apply", register: registerGitStashApplyTool },
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* Parse the RETHUNK_GIT_TOOLS env var and return the matching subset of
|
|
55
|
+
* registrars plus any unrecognized token names.
|
|
56
|
+
*
|
|
57
|
+
* @param envValue Raw value of process.env.RETHUNK_GIT_TOOLS (may be undefined).
|
|
58
|
+
* @param registrars Full ordered registrar list (injectable for tests).
|
|
59
|
+
*/
|
|
60
|
+
export function selectToolRegistrars(envValue, registrars) {
|
|
61
|
+
const tokens = (envValue ?? "")
|
|
62
|
+
.split(",")
|
|
63
|
+
.map((t) => t.trim())
|
|
64
|
+
.filter((t) => t.length > 0);
|
|
65
|
+
// Unset, empty, or whitespace-only → register all tools.
|
|
66
|
+
if (tokens.length === 0) {
|
|
67
|
+
return { selected: registrars, unknown: [] };
|
|
68
|
+
}
|
|
69
|
+
const knownNames = new Set(registrars.map((r) => r.name));
|
|
70
|
+
const requested = new Set(tokens);
|
|
71
|
+
const unknown = tokens.filter((t) => !knownNames.has(t));
|
|
72
|
+
// Preserve canonical order; deduplicate duplicate tokens automatically.
|
|
73
|
+
const selected = registrars.filter((r) => requested.has(r.name));
|
|
74
|
+
return { selected, unknown };
|
|
75
|
+
}
|
|
76
|
+
export function registerRethunkGitTools(server) {
|
|
77
|
+
const env = process.env.RETHUNK_GIT_TOOLS;
|
|
78
|
+
const { selected, unknown } = selectToolRegistrars(env, TOOL_REGISTRARS);
|
|
79
|
+
if (unknown.length > 0) {
|
|
80
|
+
process.stderr.write(`[rethunk-git] RETHUNK_GIT_TOOLS: unknown tool name(s) ignored: ${unknown.map((n) => JSON.stringify(n)).join(", ")}\n`);
|
|
81
|
+
}
|
|
82
|
+
if (selected.length === 0 && (env ?? "").trim().length > 0) {
|
|
83
|
+
process.stderr.write(`[rethunk-git] RETHUNK_GIT_TOOLS: every listed name was unrecognized — registering NO tools. ` +
|
|
84
|
+
`Set RETHUNK_GIT_TOOLS to a comma-separated list of valid tool names, or unset it to register all tools.\n`);
|
|
85
|
+
}
|
|
86
|
+
for (const { register } of selected) {
|
|
87
|
+
register(server);
|
|
88
|
+
}
|
|
89
|
+
// The presets RESOURCE is always registered, regardless of the tool allowlist.
|
|
49
90
|
registerPresetsResource(server);
|
|
50
91
|
}
|
package/docs/install.md
CHANGED
|
@@ -92,6 +92,7 @@ Omit any `cwd` / `workingDirectory` field unless your client requires it for unr
|
|
|
92
92
|
| Variable | Default | Purpose |
|
|
93
93
|
|----------|---------|---------|
|
|
94
94
|
| `GIT_SUBPROCESS_PARALLELISM` | `4` | Number of parallel git subprocesses for inventory operations and `git_status` submodule rows. Valid range: 1 to 2×CPU count (auto-clamped to prevent runaway spawning). Increase on high-core machines to accelerate large fleet scans; decrease if system resources are constrained. |
|
|
95
|
+
| `RETHUNK_GIT_TOOLS` | _(unset)_ | Comma-separated list of tool names to register. When unset or empty, all 23 tools are registered (default). When set, only the listed tools are exposed — unknown names are warned and ignored. If every name is unknown, **zero** tools are registered and a loud warning is emitted (the restriction is honored literally). The presets resource (`rethunk-git://presets`) is always registered regardless of this setting. Example: `RETHUNK_GIT_TOOLS=git_status,git_diff_summary,git_diff,git_log,batch_commit,git_push`. Full tool-name list: `git_status`, `git_inventory`, `git_parity`, `list_presets`, `git_log`, `git_diff_summary`, `git_diff`, `git_show`, `git_worktree_list`, `git_stash_list`, `git_fetch`, `git_blame`, `git_branch_list`, `git_reflog`, `batch_commit`, `git_push`, `git_merge`, `git_cherry_pick`, `git_reset_soft`, `git_tag`, `git_worktree_add`, `git_worktree_remove`, `git_stash_apply`. |
|
|
95
96
|
|
|
96
97
|
Set these in the environment where the MCP client launches the server (e.g. in your shell, in the MCP client config as `env`, or in a startup script).
|
|
97
98
|
|