@rethunk/mcp-multi-root-git 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md 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` — sole public export; session root resolution |
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/batch-commit-tool.ts`](src/server/batch-commit-tool.ts) | `batch_commit` sequential multi-commit; mutating |
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 `"2"`, 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.
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,62 @@
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.1] — 2026-04-21
6
+
7
+ Documentation-only patch following the 2.3.0 release.
8
+
9
+ ### Documentation
10
+
11
+ - **`README.md`** — one-liner description updated to include `git_log`, `git_push`, `git_worktree_*`, and `git_reset_soft`.
12
+ - **`HUMANS.md`** — opening line corrected from "Read-only MCP git tools" to "MCP git tools" (mutating operations have been present since v2.2.0).
13
+ - **`docs/mcp-tools.md`** — overview table expanded from 9 to 14 rows, adding all tools introduced in v2.3.0; **Read-only** / **Mutating** annotations added to every row consistently.
14
+
15
+ ## [2.3.0] — 2026-04-21
16
+
17
+ 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%.
18
+
19
+ ### Added
20
+
21
+ - **`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.
22
+ - **`git_worktree_list`** — list all git worktrees (`git worktree list --porcelain`); annotated `readOnlyHint: true`.
23
+ - **`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*`, …).
24
+ - **`git_worktree_remove`** — remove a registered worktree; refuses to remove the main worktree. Optional `force: true` for dirty trees.
25
+ - **`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.
26
+
27
+ ### Changed — breaking (`git_log` JSON, `MCP_JSON_FORMAT_VERSION` → `"3"`)
28
+
29
+ Consumers using `format: "json"` with `git_log` must update field names. All other tools are unaffected.
30
+
31
+ - `sha7` removed — was always derivable as `sha.slice(0, 7)`.
32
+ - `ageRelative` removed — was human-readable noise in machine output.
33
+ - `workspace_root` renamed to `workspaceRoot` — consistent camelCase with all other fields.
34
+ - `email` is now omitted when empty rather than always present.
35
+ - Error code `not_a_git_repo` corrected to `not_a_git_repository` — consistent with all other tools.
36
+
37
+ ### Changed — non-breaking
38
+
39
+ - **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.
40
+ - **`WorkspacePickSchema`** — `rootIndex` and `allWorkspaceRoots` parameters carry inline descriptions so LLMs can pick them without consulting external docs.
41
+ - **`git_merge`** — protected-branch list in the description collapsed to a single canonical reference (was duplicated inline).
42
+
43
+ ### Internal
44
+
45
+ - `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_*`.
46
+ - `conflictPaths` extracted from `git-merge-tool.ts` to `git-refs.ts`; shared by `git_merge` and `git_cherry_pick`.
47
+ - `inferRemoteFromUpstream` extracted to `git-refs.ts`; shared by `runPushAfter` (`batch_commit`) and `git_push`.
48
+ - `isWorkingTreeClean` used consistently everywhere (was inlined in `git_reset_soft`).
49
+
50
+ ### Tests
51
+
52
+ - Coverage: **88.6% lines / 92.8% functions** (up from 69.9% / 71.4%).
53
+ - 262 tests across 15 files (up from 134 across 7 files).
54
+ - 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.
55
+
56
+ ### Documentation
57
+
58
+ - `docs/mcp-tools.md` — all new tools documented with parameter tables, JSON shapes, and error-code tables.
59
+ - `AGENTS.md` — implementation map updated with all new and refactored modules.
60
+
5
61
  ## [2.2.0] — 2026-04-17
6
62
 
7
63
  Mutating git operations: merge, cherry-pick, and optional push-after for `batch_commit`.
@@ -41,6 +97,8 @@ Mutating git operations: merge, cherry-pick, and optional push-after for `batch_
41
97
 
42
98
  - Initial release: `git_status`, `git_inventory`, `git_parity`, `list_presets`.
43
99
 
100
+ [2.3.1]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.1
101
+ [2.3.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.0
44
102
  [2.2.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.2.0
45
103
  [2.1.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.1.0
46
104
  [2.0.1]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.0.1
package/HUMANS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @rethunk/mcp-multi-root-git — User guide
2
2
 
3
- Read-only MCP git tools for any workspace. **How the server is installed and wired to clients:** **[docs/install.md](docs/install.md)** only (do not restate that material here).
3
+ MCP git tools for any workspace. **How the server is installed and wired to clients:** **[docs/install.md](docs/install.md)** only (do not restate that material here).
4
4
 
5
5
  ## Badges
6
6
 
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm version](https://img.shields.io/npm/v/%40rethunk%2Fmcp-multi-root-git.svg)](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
- **git** tools over MCP: read-only status, multi-root inventory, `HEAD` parity, presets, structured diff viewer, batch commit, merge, and cherry-pick. **Install and MCP client wiring:** **[docs/install.md](docs/install.md)** only — do not duplicate those steps elsewhere.
7
+ **git** tools over MCP: read-only status, multi-root inventory, `HEAD` parity, presets, structured diff viewer, log, push, worktrees, soft-reset, batch commit, merge, and cherry-pick. **Install and MCP client wiring:** **[docs/install.md](docs/install.md)** only — do not duplicate those steps elsewhere.
8
8
 
9
9
  **Repository:** [github.com/Rethunk-AI/mcp-multi-root-git](https://github.com/Rethunk-AI/mcp-multi-root-git) · **npmjs (manual releases):** [`@rethunk/mcp-multi-root-git`](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git) · **GitHub Packages (CI on each tag):** [`@rethunk-ai/mcp-multi-root-git`](https://github.com/Rethunk-AI/mcp-multi-root-git/packages) — see [docs/install.md](docs/install.md) and [HUMANS.md](HUMANS.md) Publishing.
10
10
 
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
3
- import { gitTopLevel, spawnGitAsync } from "./git.js";
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 { requireGitAndRoots } from "./roots.js";
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
- // Verify upstream exists before attempting push (avoids side effects of auto-set-upstream).
29
- const upstreamProbe = await spawnGitAsync(gitTop, [
30
- "rev-parse",
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 upstream = upstreamProbe.stdout.trim();
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. See docs/mcp-tools.md.",
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 = requireGitAndRoots(server, args, undefined);
65
+ const pre = requireSingleRepo(server, args);
81
66
  if (!pre.ok)
82
67
  return jsonRespond(pre.error);
83
- const rootInput = pre.roots[0];
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 { gitTopLevel, spawnGitAsync } from "./git.js";
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 { requireGitAndRoots } from "./roots.js";
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. See docs/mcp-tools.md.",
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 = requireGitAndRoots(server, args, undefined);
132
+ const pre = requireSingleRepo(server, args);
142
133
  if (!pre.ok)
143
134
  return jsonRespond(pre.error);
144
- const rootInput = pre.roots[0];
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 { gitTopLevel, isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
3
+ import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
4
4
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
- import { requireGitAndRoots } from "./roots.js";
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. See docs/mcp-tools.md.",
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
- // --- Standard prelude ---
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 rootInput = pre.roots[0];
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. See docs/mcp-tools.md.",
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: sha7, shaFull, subject, author, email, ISO date, relative date.
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%h%x01%H%x01%s%x01%aN%x01%aE%x01%aI%x01%ar%x01";
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 [sha7, shaFull, subject, authorName, email, date, ageRelative] = fields;
105
- if (!sha7 || !shaFull)
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
- sha7: sha7.trim(),
110
- shaFull: shaFull.trim(),
110
+ sha: shaFull.trim(),
111
111
  subject: subject?.trim() ?? "",
112
112
  author: authorName?.trim() ?? "",
113
- email: email?.trim() ?? "",
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
- workspace_root: top,
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.workspace_root}_`);
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.sha7}\` ${c.ageRelative} ${c.subject} — ${c.author}`);
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, workspace_root: rootInput, error: "not_a_git_repo" };
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, workspace_root: rootInput, error: r.error };
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
- workspace_root: r.workspace_root,
249
- repo: basename(r.workspace_root),
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.workspace_root}\n_error: ${r.error}_`);
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 { gitTopLevel, spawnGitAsync } from "./git.js";
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 { requireGitAndRoots } from "./roots.js";
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
- // Conflict helpers
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/*). See docs/mcp-tools.md.",
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 (main, master, dev, develop, stable, trunk, prod, production, " +
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 = requireGitAndRoots(server, args, undefined);
249
+ const pre = requireSingleRepo(server, args);
260
250
  if (!pre.ok)
261
251
  return jsonRespond(pre.error);
262
- const rootInput = pre.roots[0];
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. See docs/mcp-tools.md.",
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
+ }