@rethunk/mcp-multi-root-git 2.2.0 → 2.3.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 +10 -8
- package/CHANGELOG.md +47 -0
- 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 +112 -8
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -16,25 +16,31 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
16
16
|
| [`src/server.ts`](src/server.ts) | `FastMCP` + `roots: { enabled: true }`; `readMcpServerVersion()`; `registerRethunkGitTools` |
|
|
17
17
|
| [`src/server/json.ts`](src/server/json.ts) | `jsonRespond()` (minified, no envelope), `spreadWhen`, `spreadDefined` |
|
|
18
18
|
| [`src/server/git.ts`](src/server/git.ts) | `gateGit`, `spawnGitAsync`, `asyncPool`, `GIT_SUBPROCESS_PARALLELISM`, `gitTopLevel`, `gitRevParseGitDir`, `gitRevParseHead`, `parseGitSubmodulePaths`, `hasGitMetadata`, `gitStatusSnapshotAsync`, `gitStatusShortBranchAsync`, `fetchAheadBehind`, `isSafeGitUpstreamToken` |
|
|
19
|
-
| [`src/server/roots.ts`](src/server/roots.ts) | `requireGitAndRoots` —
|
|
19
|
+
| [`src/server/roots.ts`](src/server/roots.ts) | `requireGitAndRoots`, `requireSingleRepo` — shared tool preludes; session root resolution |
|
|
20
20
|
| [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `loadPresetsFromGitTop`, `presetLoadErrorPayload`, `applyPresetNestedRoots`, `applyPresetParityPairs`; Zod schemas must match [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json) |
|
|
21
21
|
| [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT` |
|
|
22
22
|
| [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
|
|
23
|
+
| [`src/server/git-refs.ts`](src/server/git-refs.ts) | `isProtectedBranch`, `isSafeGitRefToken`, `isSafeGitRangeToken`, `isSafeGitAncestorRef`; `getCurrentBranch`, `resolveRef`, `isWorkingTreeClean`, `isFullyMergedInto`, `commitListBetween`; `listWorktrees`, `worktreeForBranch`; `conflictPaths` |
|
|
23
24
|
| [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below |
|
|
24
25
|
| [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `git_status` |
|
|
25
26
|
| [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `git_inventory` |
|
|
26
27
|
| [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `git_parity` |
|
|
27
28
|
| [`src/server/list-presets-tool.ts`](src/server/list-presets-tool.ts) | `list_presets` |
|
|
28
|
-
| [`src/server/git-log-tool.ts`](src/server/git-log-tool.ts) | `git_log` |
|
|
29
|
+
| [`src/server/git-log-tool.ts`](src/server/git-log-tool.ts) | `git_log` — v3 JSON shape: `sha` (full), `workspaceRoot`, no `sha7`/`ageRelative`, optional `email` |
|
|
29
30
|
| [`src/server/git-diff-summary-tool.ts`](src/server/git-diff-summary-tool.ts) | `git_diff_summary` — structured token-efficient diff viewer; read-only |
|
|
30
|
-
| [`src/server/
|
|
31
|
+
| [`src/server/git-worktree-tool.ts`](src/server/git-worktree-tool.ts) | `git_worktree_list`, `git_worktree_add`, `git_worktree_remove` |
|
|
32
|
+
| [`src/server/batch-commit-tool.ts`](src/server/batch-commit-tool.ts) | `batch_commit` — sequential multi-commit; mutating; exports `PushReport`, `runPushAfter` |
|
|
33
|
+
| [`src/server/git-push-tool.ts`](src/server/git-push-tool.ts) | `git_push` — standalone push with optional upstream tracking |
|
|
34
|
+
| [`src/server/git-merge-tool.ts`](src/server/git-merge-tool.ts) | `git_merge` — mutating |
|
|
35
|
+
| [`src/server/git-cherry-pick-tool.ts`](src/server/git-cherry-pick-tool.ts) | `git_cherry_pick` — mutating |
|
|
36
|
+
| [`src/server/git-reset-soft-tool.ts`](src/server/git-reset-soft-tool.ts) | `git_reset_soft` — soft-reset; mutating |
|
|
31
37
|
| [`src/server/presets-resource.ts`](src/server/presets-resource.ts) | `rethunk-git://presets` resource |
|
|
32
38
|
| [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop` |
|
|
33
39
|
|
|
34
40
|
## Changing contracts
|
|
35
41
|
|
|
36
42
|
- **No banner paragraphs** in shipped docs. Use normal titles + cross-links.
|
|
37
|
-
- **JSON format version** (currently `"
|
|
43
|
+
- **JSON format version** (currently `"3"`, discoverable via MCP `initialize`): bump on incompatible JSON changes (renamed/nested/omitted fields). Document migration here + [docs/mcp-tools.md](docs/mcp-tools.md). v2 removed the `rethunkGitMcp` envelope; payloads are minified; optional fields omitted when empty/null/false. v3 changes in `git_log`: `sha7` → `sha` (full SHA), `workspace_root` → `workspaceRoot`, `ageRelative` removed, `email` omitted when empty.
|
|
38
44
|
- **Preset file:** keep `presets.ts` Zod schemas aligned with [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json).
|
|
39
45
|
- **Public tool surface:** rename/add → update [docs/mcp-tools.md](docs/mcp-tools.md) + [README.md](README.md) (if mentioned). Install/client wiring → [docs/install.md](docs/install.md) only. `.cursor/rules/rethunk-git-mcp.mdc` → only when *MCP-vs-shell* guidance changes.
|
|
40
46
|
|
|
@@ -53,7 +59,3 @@ Dogfood from clone: [docs/install.md](docs/install.md) — *From source*.
|
|
|
53
59
|
Repo ships `.cursor/` with alwaysApply rule [`.cursor/rules/rethunk-git-mcp.mdc`](.cursor/rules/rethunk-git-mcp.mdc) covering MCP-vs-shell usage. Rule does not re-link this file (already injected).
|
|
54
60
|
|
|
55
61
|
User-level skills may mention README for discovery. Canonical refs: tools/JSON → [docs/mcp-tools.md](docs/mcp-tools.md); install → [docs/install.md](docs/install.md); presets → [HUMANS.md](HUMANS.md).
|
|
56
|
-
|
|
57
|
-
## Commits
|
|
58
|
-
|
|
59
|
-
Conventional Commits. Small themed commits. Why-focused messages. Stage + commit in one invocation per batch.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,52 @@
|
|
|
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.3.0] — 2026-04-21
|
|
6
|
+
|
|
7
|
+
Five new tools, a token-efficiency sweep, a targeted breaking change to the `git_log` JSON contract, and test coverage raised from 70% to 89%.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`git_push`** — push the current branch to its configured upstream. Accepts explicit `remote` / `branch` overrides and a `setUpstream: true` flag for first-push (`git push -u`). Refuses on detached HEAD; does not force-push.
|
|
12
|
+
- **`git_worktree_list`** — list all git worktrees (`git worktree list --porcelain`); annotated `readOnlyHint: true`.
|
|
13
|
+
- **`git_worktree_add`** — create a new linked worktree, creating the branch from `baseRef` if it does not yet exist. Refuses on protected branch names (`main`, `master`, `dev`, `release*`, `hotfix*`, …).
|
|
14
|
+
- **`git_worktree_remove`** — remove a registered worktree; refuses to remove the main worktree. Optional `force: true` for dirty trees.
|
|
15
|
+
- **`git_reset_soft`** — soft-reset the current branch to a ref (`HEAD~1`, `HEAD~N`, SHA, branch name). Preserves rewound changes in the staging index — the canonical way to re-split an already-committed chunk. Requires a clean working tree.
|
|
16
|
+
|
|
17
|
+
### Changed — breaking (`git_log` JSON, `MCP_JSON_FORMAT_VERSION` → `"3"`)
|
|
18
|
+
|
|
19
|
+
Consumers using `format: "json"` with `git_log` must update field names. All other tools are unaffected.
|
|
20
|
+
|
|
21
|
+
- `sha7` removed — was always derivable as `sha.slice(0, 7)`.
|
|
22
|
+
- `ageRelative` removed — was human-readable noise in machine output.
|
|
23
|
+
- `workspace_root` renamed to `workspaceRoot` — consistent camelCase with all other fields.
|
|
24
|
+
- `email` is now omitted when empty rather than always present.
|
|
25
|
+
- Error code `not_a_git_repo` corrected to `not_a_git_repository` — consistent with all other tools.
|
|
26
|
+
|
|
27
|
+
### Changed — non-breaking
|
|
28
|
+
|
|
29
|
+
- **Token efficiency:** `readOnlyHint: true` added to `git_status`, `git_inventory`, `git_parity`, and `list_presets`. "See docs/mcp-tools.md" suffix dropped from all 9 tool descriptions; descriptions are now self-contained.
|
|
30
|
+
- **`WorkspacePickSchema`** — `rootIndex` and `allWorkspaceRoots` parameters carry inline descriptions so LLMs can pick them without consulting external docs.
|
|
31
|
+
- **`git_merge`** — protected-branch list in the description collapsed to a single canonical reference (was duplicated inline).
|
|
32
|
+
|
|
33
|
+
### Internal
|
|
34
|
+
|
|
35
|
+
- `requireSingleRepo` helper extracted to `roots.ts`; replaces copy-paste preludes across `batch_commit`, `git_diff_summary`, `git_merge`, `git_cherry_pick`, `git_push`, `git_reset_soft`, and all `git_worktree_*`.
|
|
36
|
+
- `conflictPaths` extracted from `git-merge-tool.ts` to `git-refs.ts`; shared by `git_merge` and `git_cherry_pick`.
|
|
37
|
+
- `inferRemoteFromUpstream` extracted to `git-refs.ts`; shared by `runPushAfter` (`batch_commit`) and `git_push`.
|
|
38
|
+
- `isWorkingTreeClean` used consistently everywhere (was inlined in `git_reset_soft`).
|
|
39
|
+
|
|
40
|
+
### Tests
|
|
41
|
+
|
|
42
|
+
- Coverage: **88.6% lines / 92.8% functions** (up from 69.9% / 71.4%).
|
|
43
|
+
- 262 tests across 15 files (up from 134 across 7 files).
|
|
44
|
+
- New test files: `presets.test.ts`, `inventory.test.ts`, `git-utils.test.ts`, `json.test.ts`, `git-reset-soft-tool.test.ts`, `git-push-tool.test.ts`, `git-worktree-tool.test.ts`, `roots.test.ts`. `git-refs.test.ts` extended with `isSafeGitAncestorRef` cases.
|
|
45
|
+
|
|
46
|
+
### Documentation
|
|
47
|
+
|
|
48
|
+
- `docs/mcp-tools.md` — all new tools documented with parameter tables, JSON shapes, and error-code tables.
|
|
49
|
+
- `AGENTS.md` — implementation map updated with all new and refactored modules.
|
|
50
|
+
|
|
5
51
|
## [2.2.0] — 2026-04-17
|
|
6
52
|
|
|
7
53
|
Mutating git operations: merge, cherry-pick, and optional push-after for `batch_commit`.
|
|
@@ -41,6 +87,7 @@ Mutating git operations: merge, cherry-pick, and optional push-after for `batch_
|
|
|
41
87
|
|
|
42
88
|
- Initial release: `git_status`, `git_inventory`, `git_parity`, `list_presets`.
|
|
43
89
|
|
|
90
|
+
[2.3.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.0
|
|
44
91
|
[2.2.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.2.0
|
|
45
92
|
[2.1.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.1.0
|
|
46
93
|
[2.0.1]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.0.1
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
|
-
import {
|
|
4
|
-
import { getCurrentBranch } from "./git-refs.js";
|
|
3
|
+
import { spawnGitAsync } from "./git.js";
|
|
4
|
+
import { getCurrentBranch, inferRemoteFromUpstream } from "./git-refs.js";
|
|
5
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
6
|
-
import {
|
|
6
|
+
import { requireSingleRepo } from "./roots.js";
|
|
7
7
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
8
8
|
const CommitEntrySchema = z.object({
|
|
9
9
|
message: z.string().min(1).describe("Commit message."),
|
|
@@ -20,41 +20,26 @@ const PushModeSchema = z
|
|
|
20
20
|
* After all commits succeed, push the current branch to its upstream.
|
|
21
21
|
* Commits are already applied at this point — do NOT attempt rollback on push failure.
|
|
22
22
|
*/
|
|
23
|
-
async function runPushAfter(gitTop) {
|
|
23
|
+
export async function runPushAfter(gitTop) {
|
|
24
24
|
const branch = await getCurrentBranch(gitTop);
|
|
25
25
|
if (!branch) {
|
|
26
26
|
return { ok: false, error: "push_detached_head" };
|
|
27
27
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
"--abbrev-ref",
|
|
32
|
-
"--symbolic-full-name",
|
|
33
|
-
"@{u}",
|
|
34
|
-
]);
|
|
35
|
-
if (!upstreamProbe.ok) {
|
|
36
|
-
return {
|
|
37
|
-
ok: false,
|
|
38
|
-
branch,
|
|
39
|
-
error: "push_no_upstream",
|
|
40
|
-
detail: (upstreamProbe.stderr || upstreamProbe.stdout).trim(),
|
|
41
|
-
};
|
|
28
|
+
const t = await inferRemoteFromUpstream(gitTop);
|
|
29
|
+
if (!t.ok) {
|
|
30
|
+
return { ok: false, branch, error: "push_no_upstream", detail: t.detail };
|
|
42
31
|
}
|
|
43
|
-
const
|
|
44
|
-
// Explicit remote + branch so a future push refspec change to the upstream does not surprise us.
|
|
45
|
-
const slash = upstream.indexOf("/");
|
|
46
|
-
const remote = slash > 0 ? upstream.slice(0, slash) : "origin";
|
|
47
|
-
const pushResult = await spawnGitAsync(gitTop, ["push", remote, branch]);
|
|
32
|
+
const pushResult = await spawnGitAsync(gitTop, ["push", t.remote, branch]);
|
|
48
33
|
if (!pushResult.ok) {
|
|
49
34
|
return {
|
|
50
35
|
ok: false,
|
|
51
36
|
branch,
|
|
52
|
-
upstream,
|
|
37
|
+
upstream: t.upstream,
|
|
53
38
|
error: "push_failed",
|
|
54
39
|
detail: (pushResult.stderr || pushResult.stdout).trim(),
|
|
55
40
|
};
|
|
56
41
|
}
|
|
57
|
-
return { ok: true, branch, upstream };
|
|
42
|
+
return { ok: true, branch, upstream: t.upstream };
|
|
58
43
|
}
|
|
59
44
|
export function registerBatchCommitTool(server) {
|
|
60
45
|
server.addTool({
|
|
@@ -62,7 +47,7 @@ export function registerBatchCommitTool(server) {
|
|
|
62
47
|
description: "Create multiple sequential git commits in a single call. " +
|
|
63
48
|
"Each entry stages the listed files then commits with the given message. " +
|
|
64
49
|
'Stops on first failure. Optional `push: "after"` pushes the current branch ' +
|
|
65
|
-
"to its upstream once all commits succeed.
|
|
50
|
+
"to its upstream once all commits succeed.",
|
|
66
51
|
annotations: {
|
|
67
52
|
readOnlyHint: false,
|
|
68
53
|
destructiveHint: false,
|
|
@@ -77,17 +62,10 @@ export function registerBatchCommitTool(server) {
|
|
|
77
62
|
push: PushModeSchema,
|
|
78
63
|
}),
|
|
79
64
|
execute: async (args) => {
|
|
80
|
-
const pre =
|
|
65
|
+
const pre = requireSingleRepo(server, args);
|
|
81
66
|
if (!pre.ok)
|
|
82
67
|
return jsonRespond(pre.error);
|
|
83
|
-
const
|
|
84
|
-
if (!rootInput) {
|
|
85
|
-
return jsonRespond({ error: "no_workspace_root" });
|
|
86
|
-
}
|
|
87
|
-
const gitTop = gitTopLevel(rootInput);
|
|
88
|
-
if (!gitTop) {
|
|
89
|
-
return jsonRespond({ error: "not_a_git_repository", path: rootInput });
|
|
90
|
-
}
|
|
68
|
+
const gitTop = pre.gitTop;
|
|
91
69
|
const results = [];
|
|
92
70
|
for (let i = 0; i < args.commits.length; i++) {
|
|
93
71
|
const entry = args.commits[i];
|
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
3
|
-
import { commitListBetween, getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
2
|
+
import { spawnGitAsync } from "./git.js";
|
|
3
|
+
import { commitListBetween, conflictPaths, getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
4
4
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
|
-
import {
|
|
5
|
+
import { requireSingleRepo } from "./roots.js";
|
|
6
6
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Helpers
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
|
-
async function conflictPaths(gitTop) {
|
|
11
|
-
const r = await spawnGitAsync(gitTop, ["diff", "--name-only", "--diff-filter=U"]);
|
|
12
|
-
if (!r.ok)
|
|
13
|
-
return [];
|
|
14
|
-
return r.stdout
|
|
15
|
-
.split("\n")
|
|
16
|
-
.map((l) => l.trim())
|
|
17
|
-
.filter((l) => l.length > 0);
|
|
18
|
-
}
|
|
19
10
|
async function cherryPickHead(gitTop) {
|
|
20
11
|
const r = await spawnGitAsync(gitTop, ["rev-parse", "--verify", "--quiet", "CHERRY_PICK_HEAD"]);
|
|
21
12
|
if (!r.ok)
|
|
@@ -106,7 +97,7 @@ export function registerGitCherryPickTool(server) {
|
|
|
106
97
|
"`A..B` ranges, or branch names (expanded to `onto..<branch>`, oldest-first). " +
|
|
107
98
|
"Commits already reachable from the destination are skipped. Refuses on dirty tree; " +
|
|
108
99
|
"stops on the first conflict and reports paths. Optional flags auto-delete fully " +
|
|
109
|
-
"merged source branches and their worktrees, skipping protected names.
|
|
100
|
+
"merged source branches and their worktrees, skipping protected names.",
|
|
110
101
|
annotations: {
|
|
111
102
|
readOnlyHint: false,
|
|
112
103
|
destructiveHint: false,
|
|
@@ -138,15 +129,10 @@ export function registerGitCherryPickTool(server) {
|
|
|
138
129
|
"(`git worktree remove`). Protected tails always skipped."),
|
|
139
130
|
}),
|
|
140
131
|
execute: async (args) => {
|
|
141
|
-
const pre =
|
|
132
|
+
const pre = requireSingleRepo(server, args);
|
|
142
133
|
if (!pre.ok)
|
|
143
134
|
return jsonRespond(pre.error);
|
|
144
|
-
const
|
|
145
|
-
if (!rootInput)
|
|
146
|
-
return jsonRespond({ error: "no_workspace_root" });
|
|
147
|
-
const gitTop = gitTopLevel(rootInput);
|
|
148
|
-
if (!gitTop)
|
|
149
|
-
return jsonRespond({ error: "not_a_git_repository", path: rootInput });
|
|
135
|
+
const gitTop = pre.gitTop;
|
|
150
136
|
// --- Resolve destination ---
|
|
151
137
|
const startBranch = await getCurrentBranch(gitTop);
|
|
152
138
|
const onto = args.onto?.trim() || startBranch;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { matchesGlob } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
4
4
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
|
-
import {
|
|
5
|
+
import { requireSingleRepo } from "./roots.js";
|
|
6
6
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Constants
|
|
@@ -156,7 +156,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
156
156
|
name: "git_diff_summary",
|
|
157
157
|
description: "Structured, token-efficient diff viewer. Returns per-file diffs with additions/deletions, " +
|
|
158
158
|
"truncated to configurable line limits, with noise files (lock files, dist, etc.) excluded by default. " +
|
|
159
|
-
"Use `range` to target staged, HEAD, or any revision range.
|
|
159
|
+
"Use `range` to target staged, HEAD, or any revision range.",
|
|
160
160
|
annotations: {
|
|
161
161
|
readOnlyHint: true,
|
|
162
162
|
},
|
|
@@ -192,17 +192,10 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
192
192
|
.describe("Glob patterns to exclude. Defaults to common noise: lock files, dist, vendor, etc."),
|
|
193
193
|
}),
|
|
194
194
|
execute: async (args) => {
|
|
195
|
-
|
|
196
|
-
const pre = requireGitAndRoots(server, args, undefined);
|
|
195
|
+
const pre = requireSingleRepo(server, args);
|
|
197
196
|
if (!pre.ok)
|
|
198
197
|
return jsonRespond(pre.error);
|
|
199
|
-
const
|
|
200
|
-
if (!rootInput)
|
|
201
|
-
return jsonRespond({ error: "no_workspace_root" });
|
|
202
|
-
const gitTop = gitTopLevel(rootInput);
|
|
203
|
-
if (!gitTop) {
|
|
204
|
-
return jsonRespond({ error: "not_a_git_repository", path: rootInput });
|
|
205
|
-
}
|
|
198
|
+
const gitTop = pre.gitTop;
|
|
206
199
|
// --- Build git diff args ---
|
|
207
200
|
const diffArgsResult = buildDiffArgs(args.range);
|
|
208
201
|
if (!diffArgsResult.ok) {
|
|
@@ -8,7 +8,10 @@ import { MAX_INVENTORY_ROOTS_DEFAULT, WorkspacePickSchema } from "./schemas.js";
|
|
|
8
8
|
export function registerGitInventoryTool(server) {
|
|
9
9
|
server.addTool({
|
|
10
10
|
name: "git_inventory",
|
|
11
|
-
description: "Read-only status + ahead/behind per root.
|
|
11
|
+
description: "Read-only status + ahead/behind per root.",
|
|
12
|
+
annotations: {
|
|
13
|
+
readOnlyHint: true,
|
|
14
|
+
},
|
|
12
15
|
parameters: WorkspacePickSchema.extend({
|
|
13
16
|
nestedRoots: z.array(z.string()).optional(),
|
|
14
17
|
preset: z.string().optional(),
|
|
@@ -14,12 +14,13 @@ const DEFAULT_SINCE = "7.days";
|
|
|
14
14
|
// The format string itself is safe ASCII; git emits the byte.
|
|
15
15
|
const FIELD_SEP_OUT = "\x01"; // what git outputs (SOH)
|
|
16
16
|
const RECORD_SEP_OUT = "\x02"; // what git outputs (STX) — used as record-START marker
|
|
17
|
-
// git log --pretty tformat:
|
|
17
|
+
// git log --pretty tformat: shaFull, subject, author, email, ISO date.
|
|
18
|
+
// sha7 and ageRelative dropped in v3 (shaFull.slice(0,7) used for display; date is ISO).
|
|
18
19
|
// %x02 is placed at the START of each record (tformat adds \n as terminator after each).
|
|
19
20
|
// Splitting stdout on \x02 then gives empty-first-chunk + one chunk per commit,
|
|
20
21
|
// each structured as: <fields>\x01\n\n <shortstat text>\n
|
|
21
22
|
// Fields are separated by %x01; the trailing \x01 before \n leaves one empty last field (ignored).
|
|
22
|
-
const PRETTY_FORMAT = "%x02%
|
|
23
|
+
const PRETTY_FORMAT = "%x02%H%x01%s%x01%aN%x01%aE%x01%aI%x01";
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
24
25
|
// Helpers
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
@@ -101,18 +102,16 @@ async function runGitLog(opts) {
|
|
|
101
102
|
const fieldsPart = newlineIdx >= 0 ? chunk.slice(0, newlineIdx) : chunk;
|
|
102
103
|
const statPart = newlineIdx >= 0 ? chunk.slice(newlineIdx + 1) : "";
|
|
103
104
|
const fields = fieldsPart.split(FIELD_SEP_OUT);
|
|
104
|
-
const [
|
|
105
|
-
if (!
|
|
105
|
+
const [shaFull, subject, authorName, email, date] = fields;
|
|
106
|
+
if (!shaFull)
|
|
106
107
|
continue;
|
|
107
108
|
const stat = parseShortstat(statPart);
|
|
108
109
|
const commit = {
|
|
109
|
-
|
|
110
|
-
shaFull: shaFull.trim(),
|
|
110
|
+
sha: shaFull.trim(),
|
|
111
111
|
subject: subject?.trim() ?? "",
|
|
112
112
|
author: authorName?.trim() ?? "",
|
|
113
|
-
email
|
|
113
|
+
...spreadDefined("email", email?.trim() || undefined),
|
|
114
114
|
date: date?.trim() ?? "",
|
|
115
|
-
ageRelative: ageRelative?.trim() ?? "",
|
|
116
115
|
...spreadDefined("filesChanged", stat?.filesChanged),
|
|
117
116
|
...spreadDefined("insertions", stat?.insertions),
|
|
118
117
|
...spreadDefined("deletions", stat?.deletions),
|
|
@@ -123,7 +122,7 @@ async function runGitLog(opts) {
|
|
|
123
122
|
const commits = truncated ? allCommits.slice(0, maxCommits) : allCommits;
|
|
124
123
|
const omittedCount = truncated ? allCommits.length - maxCommits : 0;
|
|
125
124
|
return {
|
|
126
|
-
|
|
125
|
+
workspaceRoot: top,
|
|
127
126
|
repo: basename(top),
|
|
128
127
|
branch: resolvedBranch,
|
|
129
128
|
commits,
|
|
@@ -137,14 +136,14 @@ async function runGitLog(opts) {
|
|
|
137
136
|
function renderLogMarkdown(group, filterSummary) {
|
|
138
137
|
const lines = [];
|
|
139
138
|
lines.push(`### ${group.repo} (${group.branch})${filterSummary ? ` — ${filterSummary}` : ""}`);
|
|
140
|
-
lines.push(`_root: ${group.
|
|
139
|
+
lines.push(`_root: ${group.workspaceRoot}_`);
|
|
141
140
|
lines.push("");
|
|
142
141
|
if (group.commits.length === 0) {
|
|
143
142
|
lines.push("_(no commits match)_");
|
|
144
143
|
}
|
|
145
144
|
else {
|
|
146
145
|
for (const c of group.commits) {
|
|
147
|
-
lines.push(`- \`${c.
|
|
146
|
+
lines.push(`- \`${c.sha.slice(0, 7)}\` ${c.date.slice(0, 10)} ${c.subject} — ${c.author}`);
|
|
148
147
|
}
|
|
149
148
|
}
|
|
150
149
|
if (group.truncated) {
|
|
@@ -160,8 +159,7 @@ export function registerGitLogTool(server) {
|
|
|
160
159
|
server.addTool({
|
|
161
160
|
name: "git_log",
|
|
162
161
|
description: "Path-filtered, time-windowed read-only `git log` across one or more workspace roots. " +
|
|
163
|
-
"Returns structured commit history with author, date, subject, and optional diff stats.
|
|
164
|
-
"See docs/mcp-tools.md.",
|
|
162
|
+
"Returns structured commit history with author, date, subject, and optional diff stats.",
|
|
165
163
|
annotations: {
|
|
166
164
|
readOnlyHint: true,
|
|
167
165
|
},
|
|
@@ -216,7 +214,7 @@ export function registerGitLogTool(server) {
|
|
|
216
214
|
const results = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
|
|
217
215
|
const top = gitTopLevel(rootInput);
|
|
218
216
|
if (!top) {
|
|
219
|
-
return { _error: true,
|
|
217
|
+
return { _error: true, workspaceRoot: rootInput, error: "not_a_git_repository" };
|
|
220
218
|
}
|
|
221
219
|
const r = await runGitLog({
|
|
222
220
|
top,
|
|
@@ -228,7 +226,7 @@ export function registerGitLogTool(server) {
|
|
|
228
226
|
branch: args.branch,
|
|
229
227
|
});
|
|
230
228
|
if ("error" in r) {
|
|
231
|
-
return { _error: true,
|
|
229
|
+
return { _error: true, workspaceRoot: rootInput, error: r.error };
|
|
232
230
|
}
|
|
233
231
|
return { _error: false, ...r };
|
|
234
232
|
});
|
|
@@ -245,8 +243,8 @@ export function registerGitLogTool(server) {
|
|
|
245
243
|
const groups = results.map((r) => {
|
|
246
244
|
if (r._error) {
|
|
247
245
|
return {
|
|
248
|
-
|
|
249
|
-
repo: basename(r.
|
|
246
|
+
workspaceRoot: r.workspaceRoot,
|
|
247
|
+
repo: basename(r.workspaceRoot ?? ""),
|
|
250
248
|
branch: "",
|
|
251
249
|
commits: [],
|
|
252
250
|
...spreadWhen(true, { error: r.error }),
|
|
@@ -264,7 +262,7 @@ export function registerGitLogTool(server) {
|
|
|
264
262
|
const mdChunks = ["# Git log"];
|
|
265
263
|
for (const r of results) {
|
|
266
264
|
if (r._error) {
|
|
267
|
-
mdChunks.push(`### ${r.
|
|
265
|
+
mdChunks.push(`### ${r.workspaceRoot}\n_error: ${r.error}_`);
|
|
268
266
|
continue;
|
|
269
267
|
}
|
|
270
268
|
const { _error: _e, ...group } = r;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
3
|
-
import { getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
2
|
+
import { spawnGitAsync } from "./git.js";
|
|
3
|
+
import { conflictPaths, getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
4
4
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
|
-
import {
|
|
5
|
+
import { requireSingleRepo } from "./roots.js";
|
|
6
6
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Schemas
|
|
@@ -16,17 +16,8 @@ const StrategySchema = z
|
|
|
16
16
|
"`rebase`: rebase source onto destination, then fast-forward (no merge-commit fallback). " +
|
|
17
17
|
"`merge`: always create a merge commit (no fast-forward).");
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
|
-
//
|
|
19
|
+
// Abort helpers
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
|
-
async function conflictPaths(gitTop) {
|
|
22
|
-
const r = await spawnGitAsync(gitTop, ["diff", "--name-only", "--diff-filter=U"]);
|
|
23
|
-
if (!r.ok)
|
|
24
|
-
return [];
|
|
25
|
-
return r.stdout
|
|
26
|
-
.split("\n")
|
|
27
|
-
.map((l) => l.trim())
|
|
28
|
-
.filter((l) => l.length > 0);
|
|
29
|
-
}
|
|
30
21
|
async function abortMerge(gitTop) {
|
|
31
22
|
await spawnGitAsync(gitTop, ["merge", "--abort"]);
|
|
32
23
|
}
|
|
@@ -220,7 +211,7 @@ export function registerGitMergeTool(server) {
|
|
|
220
211
|
"Refuses if the working tree is dirty. Stops on the first conflict and reports " +
|
|
221
212
|
"the affected paths. Optional flags auto-delete merged branches and worktrees, " +
|
|
222
213
|
"skipping protected names (main, master, dev, develop, stable, trunk, prod, " +
|
|
223
|
-
"production, release/*, hotfix/*).
|
|
214
|
+
"production, release/*, hotfix/*).",
|
|
224
215
|
annotations: {
|
|
225
216
|
readOnlyHint: false,
|
|
226
217
|
destructiveHint: false,
|
|
@@ -246,8 +237,7 @@ export function registerGitMergeTool(server) {
|
|
|
246
237
|
.optional()
|
|
247
238
|
.default(false)
|
|
248
239
|
.describe("After all sources merge cleanly, delete each source branch locally (`git branch -d`). " +
|
|
249
|
-
"Protected names
|
|
250
|
-
"release/*, hotfix/*) are always skipped. Never affects remote branches."),
|
|
240
|
+
"Protected names always skipped. Never affects remote branches."),
|
|
251
241
|
deleteMergedWorktrees: z
|
|
252
242
|
.boolean()
|
|
253
243
|
.optional()
|
|
@@ -256,15 +246,10 @@ export function registerGitMergeTool(server) {
|
|
|
256
246
|
"on a source branch (`git worktree remove`). Protected tails always skipped."),
|
|
257
247
|
}),
|
|
258
248
|
execute: async (args) => {
|
|
259
|
-
const pre =
|
|
249
|
+
const pre = requireSingleRepo(server, args);
|
|
260
250
|
if (!pre.ok)
|
|
261
251
|
return jsonRespond(pre.error);
|
|
262
|
-
const
|
|
263
|
-
if (!rootInput)
|
|
264
|
-
return jsonRespond({ error: "no_workspace_root" });
|
|
265
|
-
const gitTop = gitTopLevel(rootInput);
|
|
266
|
-
if (!gitTop)
|
|
267
|
-
return jsonRespond({ error: "not_a_git_repository", path: rootInput });
|
|
252
|
+
const gitTop = pre.gitTop;
|
|
268
253
|
// --- Validate ref tokens early ---
|
|
269
254
|
for (const s of args.sources) {
|
|
270
255
|
if (!isSafeGitRefToken(s)) {
|
|
@@ -8,7 +8,10 @@ import { WorkspacePickSchema } from "./schemas.js";
|
|
|
8
8
|
export function registerGitParityTool(server) {
|
|
9
9
|
server.addTool({
|
|
10
10
|
name: "git_parity",
|
|
11
|
-
description: "Read-only HEAD parity for path pairs.
|
|
11
|
+
description: "Read-only HEAD parity for path pairs.",
|
|
12
|
+
annotations: {
|
|
13
|
+
readOnlyHint: true,
|
|
14
|
+
},
|
|
12
15
|
parameters: WorkspacePickSchema.extend({
|
|
13
16
|
pairs: z
|
|
14
17
|
.array(z.object({
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
3
|
+
import { getCurrentBranch, inferRemoteFromUpstream, isSafeGitRefToken } 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 registerGitPushTool(server) {
|
|
8
|
+
server.addTool({
|
|
9
|
+
name: "git_push",
|
|
10
|
+
description: "Push the current branch to its configured upstream. " +
|
|
11
|
+
"Use `setUpstream: true` to set tracking (`-u`) when no upstream is configured yet. " +
|
|
12
|
+
"Refuses on detached HEAD. Does not force-push.",
|
|
13
|
+
annotations: {
|
|
14
|
+
readOnlyHint: false,
|
|
15
|
+
destructiveHint: false,
|
|
16
|
+
idempotentHint: false,
|
|
17
|
+
},
|
|
18
|
+
parameters: WorkspacePickSchema.extend({
|
|
19
|
+
remote: z
|
|
20
|
+
.string()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Remote to push to. Defaults to the remote inferred from the current upstream tracking " +
|
|
23
|
+
"ref, or `origin` when `setUpstream` is true."),
|
|
24
|
+
branch: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Branch to push. Defaults to the currently checked-out branch. " +
|
|
28
|
+
"Rejected when HEAD is detached."),
|
|
29
|
+
setUpstream: z
|
|
30
|
+
.boolean()
|
|
31
|
+
.optional()
|
|
32
|
+
.default(false)
|
|
33
|
+
.describe("Set the upstream tracking reference (`git push -u`). " +
|
|
34
|
+
"Use when the branch has not been pushed yet. Remote defaults to `origin`."),
|
|
35
|
+
}),
|
|
36
|
+
execute: async (args) => {
|
|
37
|
+
const pre = requireSingleRepo(server, args);
|
|
38
|
+
if (!pre.ok)
|
|
39
|
+
return jsonRespond(pre.error);
|
|
40
|
+
const { gitTop } = pre;
|
|
41
|
+
// --- Resolve branch ---
|
|
42
|
+
const currentBranch = await getCurrentBranch(gitTop);
|
|
43
|
+
const branch = args.branch?.trim() || currentBranch;
|
|
44
|
+
if (!branch) {
|
|
45
|
+
return jsonRespond({ error: "push_detached_head" });
|
|
46
|
+
}
|
|
47
|
+
if (!isSafeGitRefToken(branch)) {
|
|
48
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: branch });
|
|
49
|
+
}
|
|
50
|
+
// --- Resolve remote ---
|
|
51
|
+
let remote;
|
|
52
|
+
if (args.remote?.trim()) {
|
|
53
|
+
if (!isSafeGitUpstreamToken(args.remote.trim())) {
|
|
54
|
+
return jsonRespond({ error: "unsafe_remote_token", remote: args.remote.trim() });
|
|
55
|
+
}
|
|
56
|
+
remote = args.remote.trim();
|
|
57
|
+
}
|
|
58
|
+
else if (args.setUpstream) {
|
|
59
|
+
// No explicit remote and we're setting upstream — default to origin.
|
|
60
|
+
remote = "origin";
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// Infer remote from existing upstream tracking ref.
|
|
64
|
+
const t = await inferRemoteFromUpstream(gitTop);
|
|
65
|
+
if (!t.ok) {
|
|
66
|
+
return jsonRespond({
|
|
67
|
+
error: "push_no_upstream",
|
|
68
|
+
branch,
|
|
69
|
+
detail: t.detail,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
remote = t.remote;
|
|
73
|
+
}
|
|
74
|
+
// --- Push ---
|
|
75
|
+
const pushArgs = ["push"];
|
|
76
|
+
if (args.setUpstream)
|
|
77
|
+
pushArgs.push("-u");
|
|
78
|
+
pushArgs.push(remote, branch);
|
|
79
|
+
const pushResult = await spawnGitAsync(gitTop, pushArgs);
|
|
80
|
+
if (!pushResult.ok) {
|
|
81
|
+
return jsonRespond({
|
|
82
|
+
ok: false,
|
|
83
|
+
branch,
|
|
84
|
+
remote,
|
|
85
|
+
error: "push_failed",
|
|
86
|
+
detail: (pushResult.stderr || pushResult.stdout).trim(),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// Probe the upstream tracking ref now (may have been set by -u).
|
|
90
|
+
const upstreamProbe = await spawnGitAsync(gitTop, [
|
|
91
|
+
"rev-parse",
|
|
92
|
+
"--abbrev-ref",
|
|
93
|
+
"--symbolic-full-name",
|
|
94
|
+
"@{u}",
|
|
95
|
+
]);
|
|
96
|
+
const upstream = upstreamProbe.ok ? upstreamProbe.stdout.trim() : `${remote}/${branch}`;
|
|
97
|
+
if (args.format === "json") {
|
|
98
|
+
return jsonRespond({
|
|
99
|
+
ok: true,
|
|
100
|
+
branch,
|
|
101
|
+
remote,
|
|
102
|
+
upstream,
|
|
103
|
+
...spreadDefined("setUpstream", args.setUpstream || undefined),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return `# Push\n✓ ${branch} → ${upstream}`;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
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
|
@@ -25,9 +25,9 @@ Pass **`format: "json"`** on any tool for structured JSON instead of markdown (d
|
|
|
25
25
|
|
|
26
26
|
## JSON responses
|
|
27
27
|
|
|
28
|
-
Tool JSON bodies are minified and contain only the payload — no `rethunkGitMcp` envelope. Current `MCP_JSON_FORMAT_VERSION` is **`"
|
|
28
|
+
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
29
|
|
|
30
|
-
### v2 field omission (consumer contract)
|
|
30
|
+
### v2/v3 field omission (consumer contract)
|
|
31
31
|
|
|
32
32
|
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
33
|
|
|
@@ -68,17 +68,15 @@ To keep responses compact, **optional fields are omitted when they would be empt
|
|
|
68
68
|
```json
|
|
69
69
|
{
|
|
70
70
|
"groups": [{
|
|
71
|
-
"
|
|
71
|
+
"workspaceRoot": "/abs/path",
|
|
72
72
|
"repo": "my-repo",
|
|
73
73
|
"branch": "main",
|
|
74
74
|
"commits": [{
|
|
75
|
-
"
|
|
76
|
-
"shaFull": "a1bf184c3d...",
|
|
75
|
+
"sha": "a1bf184c3d…",
|
|
77
76
|
"subject": "feat(satcom): upgrade to PROTOCOL_VERSION 4",
|
|
78
77
|
"author": "Damon Blais",
|
|
79
78
|
"email": "damon@example.com",
|
|
80
79
|
"date": "2026-04-12T18:32:01-07:00",
|
|
81
|
-
"ageRelative": "42m ago",
|
|
82
80
|
"filesChanged": 4,
|
|
83
81
|
"insertions": 16,
|
|
84
82
|
"deletions": 5
|
|
@@ -89,14 +87,16 @@ To keep responses compact, **optional fields are omitted when they would be empt
|
|
|
89
87
|
}
|
|
90
88
|
```
|
|
91
89
|
|
|
92
|
-
|
|
90
|
+
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).
|
|
91
|
+
|
|
92
|
+
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
93
|
|
|
94
94
|
### `git_log` — error codes
|
|
95
95
|
|
|
96
96
|
| Code | Meaning |
|
|
97
97
|
|------|---------|
|
|
98
98
|
| `git_not_found` | `git` binary not on `PATH`. |
|
|
99
|
-
| `
|
|
99
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
100
100
|
| `invalid_since` | The `since` string contains shell metacharacters and was rejected. |
|
|
101
101
|
| `invalid_paths` | One of the `paths` entries contains shell metacharacters and was rejected. |
|
|
102
102
|
| `git_log_failed` | `git log` exited non-zero (e.g. unknown branch ref). |
|
|
@@ -339,6 +339,110 @@ Repo state is cleaned (`git cherry-pick --abort`) before returning — no partia
|
|
|
339
339
|
|
|
340
340
|
---
|
|
341
341
|
|
|
342
|
+
### `git_push` — parameters
|
|
343
|
+
|
|
344
|
+
| Parameter | Type | Notes |
|
|
345
|
+
|-----------|------|-------|
|
|
346
|
+
| `remote` | string | Remote to push to. Defaults to the remote inferred from the upstream tracking ref, or `origin` when `setUpstream` is true. |
|
|
347
|
+
| `branch` | string | Branch to push. Defaults to the currently checked-out branch. Rejected on detached HEAD. |
|
|
348
|
+
| `setUpstream` | boolean | Default `false`. Pass `-u` to set the upstream tracking ref; remote defaults to `origin`. |
|
|
349
|
+
| `workspaceRoot`, `rootIndex`, `format` | — | Standard workspace pick + output format. |
|
|
350
|
+
|
|
351
|
+
### `git_push` — JSON shape (`format: "json"`)
|
|
352
|
+
|
|
353
|
+
```json
|
|
354
|
+
{ "ok": true, "branch": "feature/x", "remote": "origin", "upstream": "origin/feature/x" }
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### `git_push` — error codes
|
|
358
|
+
|
|
359
|
+
| Code | Meaning |
|
|
360
|
+
|------|---------|
|
|
361
|
+
| `push_detached_head` | HEAD is detached; no branch name to push. |
|
|
362
|
+
| `push_no_upstream` | Branch has no configured upstream and `setUpstream` was not requested. |
|
|
363
|
+
| `push_failed` | `git push` exited non-zero. `detail` carries stderr. |
|
|
364
|
+
| `unsafe_ref_token` | `branch` value contains characters outside the safe token set. |
|
|
365
|
+
| `unsafe_remote_token` | `remote` value contains disallowed characters. |
|
|
366
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
### `git_reset_soft` — parameters
|
|
371
|
+
|
|
372
|
+
| Parameter | Type | Notes |
|
|
373
|
+
|-----------|------|-------|
|
|
374
|
+
| `ref` | string | Target commit: `HEAD~N`, branch name, or full/short SHA. |
|
|
375
|
+
| `workspaceRoot`, `rootIndex`, `format` | — | Standard workspace pick + output format. |
|
|
376
|
+
|
|
377
|
+
### `git_reset_soft` — JSON shape (`format: "json"`)
|
|
378
|
+
|
|
379
|
+
```json
|
|
380
|
+
{ "ok": true, "ref": "HEAD~2", "beforeSha": "a1b2c3d…", "afterSha": "f9e8d7c…", "stagedCount": 5 }
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### `git_reset_soft` — error codes
|
|
384
|
+
|
|
385
|
+
| Code | Meaning |
|
|
386
|
+
|------|---------|
|
|
387
|
+
| `unsafe_ref_token` | `ref` contains characters outside the ancestor-safe token set. |
|
|
388
|
+
| `working_tree_dirty` | Working tree has uncommitted/unstaged changes; clean up before resetting. |
|
|
389
|
+
| `status_failed` | `git status` failed unexpectedly. |
|
|
390
|
+
| `reset_failed` | `git reset --soft` failed (e.g. ref does not exist). |
|
|
391
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
### `git_worktree_list` — JSON shape (`format: "json"`)
|
|
396
|
+
|
|
397
|
+
```json
|
|
398
|
+
{ "worktrees": [{ "path": "/abs/path", "branch": "feature/x", "head": "a1b2c3d…" }] }
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
`branch` is `null` for detached-HEAD worktrees.
|
|
402
|
+
|
|
403
|
+
### `git_worktree_add` — parameters
|
|
404
|
+
|
|
405
|
+
| Parameter | Type | Notes |
|
|
406
|
+
|-----------|------|-------|
|
|
407
|
+
| `path` | string | Filesystem path for the new worktree. Relative paths are resolved from the git toplevel. |
|
|
408
|
+
| `branch` | string | Branch to check out. Created from `baseRef` if it does not already exist. |
|
|
409
|
+
| `baseRef` | string | Commit-ish for branch creation. Default: `HEAD`. Ignored when `branch` already exists. |
|
|
410
|
+
| `workspaceRoot`, `rootIndex`, `format` | — | Standard workspace pick + output format. |
|
|
411
|
+
|
|
412
|
+
### `git_worktree_add` — JSON shape (`format: "json"`)
|
|
413
|
+
|
|
414
|
+
```json
|
|
415
|
+
{ "ok": true, "path": "/abs/worktree", "branch": "feature/x", "created": true, "baseRef": "main" }
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### `git_worktree_add` — error codes
|
|
419
|
+
|
|
420
|
+
| Code | Meaning |
|
|
421
|
+
|------|---------|
|
|
422
|
+
| `unsafe_ref_token` | `branch` or `baseRef` contains disallowed characters. |
|
|
423
|
+
| `protected_branch` | `branch` is on the protected names list. |
|
|
424
|
+
| `worktree_add_failed` | `git worktree add` exited non-zero. `detail` carries stderr. |
|
|
425
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
426
|
+
|
|
427
|
+
### `git_worktree_remove` — parameters
|
|
428
|
+
|
|
429
|
+
| Parameter | Type | Notes |
|
|
430
|
+
|-----------|------|-------|
|
|
431
|
+
| `path` | string | Path of the worktree to remove. |
|
|
432
|
+
| `force` | boolean | Default `false`. Pass `--force` to allow removal with uncommitted changes. |
|
|
433
|
+
| `workspaceRoot`, `rootIndex`, `format` | — | Standard workspace pick + output format. |
|
|
434
|
+
|
|
435
|
+
### `git_worktree_remove` — error codes
|
|
436
|
+
|
|
437
|
+
| Code | Meaning |
|
|
438
|
+
|------|---------|
|
|
439
|
+
| `cannot_remove_main_worktree` | `path` resolves to the main (non-linked) worktree. |
|
|
440
|
+
| `worktree_not_found` | `path` is not registered as a worktree in this repo. |
|
|
441
|
+
| `worktree_remove_failed` | `git worktree remove` failed. Pass `force: true` if there are uncommitted changes. |
|
|
442
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
342
446
|
## Resource
|
|
343
447
|
|
|
344
448
|
| URI | Purpose |
|
package/package.json
CHANGED