@rethunk/mcp-multi-root-git 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,53 @@
1
+ # AGENTS.md — LLM and developer onboarding
2
+
3
+ **Scope:** [`@rethunk/mcp-multi-root-git`](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git) is an MCP **stdio** server: entry [`src/server.ts`](src/server.ts) (FastMCP + `registerRethunkGitTools`), supporting modules under [`src/server/`](src/server/), build output [`dist/server.js`](dist/server.js) (see `package.json` `bin` / `exports`; publish ships the full `dist/` tree).
4
+
5
+ **Operators and integrators:** **[docs/install.md](docs/install.md)** is the **only** place for prerequisites, how to launch the server, and per-client MCP configuration — do not duplicate that in README, HUMANS, rules, or here. Preset file, dev workflow, CI, and publishing: **[HUMANS.md](HUMANS.md)**.
6
+
7
+ **Tool ids, client naming, `format` / JSON envelopes, resource URI, workspace root order:** **[docs/mcp-tools.md](docs/mcp-tools.md)** — canonical; do not duplicate those tables in this file or HUMANS.
8
+
9
+ ## Implementation map
10
+
11
+ | File | Symbols / notes |
12
+ |------|-------------------|
13
+ | [`src/server.ts`](src/server.ts) | `FastMCP` + `roots: { enabled: true }`; `readMcpServerVersion()`; `registerRethunkGitTools` |
14
+ | [`src/server/json.ts`](src/server/json.ts) | `MCP_JSON_FORMAT_VERSION`, `jsonRespond()`, `spreadWhen`, `spreadDefined` — every tool JSON body ends with `rethunkGitMcp` |
15
+ | [`src/server/git.ts`](src/server/git.ts) | `gateGit()` — lazy `git --version`; `spawnGitAsync`, `asyncPool`, `GIT_SUBPROCESS_PARALLELISM`; `gitTopLevel`, `gitRevParseGitDir`, `gitRevParseHead`, `parseGitSubmodulePaths`, `hasGitMetadata`; `gitStatusSnapshotAsync`, `gitStatusShortBranchAsync`, `fetchAheadBehind`, `isSafeGitUpstreamToken` |
16
+ | [`src/server/roots.ts`](src/server/roots.ts) | `uriToPath`, `listFileRoots`, `pathMatchesWorkspaceRootHint`, `resolveWorkspaceRoots`, `resolveRootsForPreset`, `requireGitAndRoots` — session roots only (client wiring: [docs/install.md](docs/install.md)) |
17
+ | [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `splitPresetFileRaw`, `loadPresetsFromGitTop`, `getPresetEntry`, `presetLoadErrorPayload`, `applyPresetNestedRoots`, `applyPresetParityPairs`; Zod `PresetEntrySchema` / `PresetFileSchema` must match [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json) |
18
+ | [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT` |
19
+ | [`src/server/inventory.ts`](src/server/inventory.ts) | `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` (uses repo-paths + git) |
20
+ | [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — calls per-surface `register*` below |
21
+ | [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `registerGitStatusTool` — `git_status` |
22
+ | [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `registerGitInventoryTool` — `git_inventory` |
23
+ | [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `registerGitParityTool` — `git_parity` |
24
+ | [`src/server/list-presets-tool.ts`](src/server/list-presets-tool.ts) | `registerListPresetsTool` — `list_presets` |
25
+ | [`src/server/presets-resource.ts`](src/server/presets-resource.ts) | `registerPresetsResource` — `rethunk-git://presets` resource |
26
+ | [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop`, `realPathOrSelf` |
27
+
28
+ ## Changing contracts
29
+
30
+ - **Documentation layout:** do not add top-of-file **banner** paragraphs (bold blocks such as “Canonical doc for… / link here only”) to `docs/install.md` or other shipped docs. Use normal titles, TOC, and cross-links from README / this file / HUMANS.
31
+ - **`rethunkGitMcp.jsonFormatVersion`:** bump **`MCP_JSON_FORMAT_VERSION`** and document the migration in this file and in [docs/mcp-tools.md](docs/mcp-tools.md) when JSON field names or nesting change incompatibly.
32
+ - **Preset file:** keep **`splitPresetFileRaw`** + Zod parsing aligned with **`git-mcp-presets.schema.json`**; update the schema when adding keys or shapes.
33
+ - **Public tool surface:** if you add/rename tools, update [docs/mcp-tools.md](docs/mcp-tools.md) and [README.md](README.md) if the landing page still mentions tools; update [docs/install.md](docs/install.md) if install or client-specific wiring changes — **never** copy install steps into other docs; update [.cursor/rules/rethunk-git-mcp.mdc](.cursor/rules/rethunk-git-mcp.mdc) only if *when-to-use MCP vs shell* wording must change (that rule links `docs/install.md`, [HUMANS.md](HUMANS.md), and `docs/mcp-tools.md` without duplicating them).
34
+
35
+ ## Validation and CI
36
+
37
+ Local: `bun run build` (`rimraf dist && tsc`), `bun run check` (Biome), `bun run test` (`bun test` for [`src/repo-paths.test.ts`](src/repo-paths.test.ts)). GitHub Actions runs the same after `bun install --frozen-lockfile` on PRs and `main` ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)), then uploads a **prerelease `npm pack` artifact**. Pushes of tag **`v*.*.*`** matching `package.json` `version` run [`.github/workflows/release.yml`](.github/workflows/release.yml) (**GitHub Packages** npm publish under **`@rethunk-ai/mcp-multi-root-git`** + **GitHub Release** with tarball); **npmjs** is manual only — see [HUMANS.md](HUMANS.md) Publishing.
38
+
39
+ Optional [`.githooks/`](.githooks): run **`bun run setup-hooks`** once per clone (`core.hooksPath` → `.githooks`). **pre-commit** = `check`; **pre-push** = frozen install + build + check. See [HUMANS.md](HUMANS.md) Development.
40
+
41
+ Path confinement helpers live in [`src/repo-paths.ts`](src/repo-paths.ts); extend tests when changing that logic.
42
+
43
+ ## Repository MCP entry (contributors)
44
+
45
+ Dogfooding from a clone: **[docs/install.md](docs/install.md)** — *From source (this repository)* only.
46
+
47
+ This repo may ship **`.cursor/`** config (example MCP entry + **alwaysApply** rule [`.cursor/rules/rethunk-git-mcp.mdc`](.cursor/rules/rethunk-git-mcp.mdc)). The rule covers *when* to call these tools vs shell git and links **`docs/install.md`**, **[HUMANS.md](HUMANS.md)**, and **`docs/mcp-tools.md`** without duplicating their bodies; it does not re-link this file when it is already injected as project context.
48
+
49
+ User-level skills may still mention the GitHub **README** for discovery. Canonical references: **tools / JSON** — **[docs/mcp-tools.md](docs/mcp-tools.md)**; **install / client wiring** — **[docs/install.md](docs/install.md)** (link it; do not paste per-client JSON into skills); **preset file** — **[HUMANS.md](HUMANS.md)**.
50
+
51
+ ## Commits
52
+
53
+ Use the team’s **conventional commits + batching** skill (or equivalent): small themed commits, why-focused messages, `git add` + `git commit` in one shell invocation per batch.
package/HUMANS.md ADDED
@@ -0,0 +1,115 @@
1
+ # @rethunk/mcp-multi-root-git — User guide
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).
4
+
5
+ **Implementation map (modules under `src/server/`, entry `src/server.ts`), symbols, and contract bumps** live in **`AGENTS.md`** at the repository root. This guide does not repeat those sections.
6
+
7
+ **Registered tool ids, client naming (`rethunk-git_*`), `format` / JSON envelopes, resource URI, workspace root resolution:** **[docs/mcp-tools.md](docs/mcp-tools.md)** — canonical; not duplicated here.
8
+
9
+ ## Workspace preset file
10
+
11
+ Commit **`.rethunk/git-mcp-presets.json`** at the **git repository root** (next to `.git`).
12
+
13
+ Example (legacy-style map):
14
+
15
+ ```json
16
+ {
17
+ "push-prep": {
18
+ "nestedRoots": ["path/to/package-a", "path/to/package-b"],
19
+ "parityPairs": [
20
+ { "left": "core/packages/shared", "right": "edge/packages/shared", "label": "shared" }
21
+ ],
22
+ "workspaceRootHint": "my-repo"
23
+ }
24
+ }
25
+ ```
26
+
27
+ Call tools with `"preset": "push-prep"` instead of passing paths inline. Use **`presetMerge`: true** on `git_inventory` or `git_parity` to merge preset paths/pairs with inline `nestedRoots` / `pairs`.
28
+
29
+ With **multiple MCP file roots**, the server picks a root whose git toplevel defines that preset. If the preset entry has **`workspaceRootHint`**, only MCP roots whose basename or path suffix match are considered.
30
+
31
+ **JSON Schema** ships with the package as **`git-mcp-presets.schema.json`** for editor validation. Example wrapped layout:
32
+
33
+ ```json
34
+ {
35
+ "$schema": "./node_modules/@rethunk/mcp-multi-root-git/git-mcp-presets.schema.json",
36
+ "schemaVersion": "1",
37
+ "presets": {
38
+ "push-prep": {
39
+ "nestedRoots": ["packages/a"],
40
+ "parityPairs": []
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ If you installed from **GitHub Packages**, use **`./node_modules/@rethunk-ai/mcp-multi-root-git/git-mcp-presets.schema.json`** in **`$schema`** instead (see [docs/install.md](docs/install.md#github-packages)).
47
+
48
+ **Layouts:**
49
+
50
+ 1. **Wrapped (recommended):** `{ "schemaVersion": "1", "presets": { "<name>": { ... } } }`.
51
+ 2. **Legacy map:** `{ "<preset-name>": { ... } }` with optional top-level `"schemaVersion"` and `"$schema"`.
52
+
53
+ Invalid JSON or schema errors return **`preset_file_invalid`** (not a silent empty result).
54
+
55
+ ### Preset entry (quick reference)
56
+
57
+ ```jsonc
58
+ {
59
+ "<preset-name>": {
60
+ "nestedRoots": ["<relative-path>", ...],
61
+ "parityPairs": [{ "left": "<rel>", "right": "<rel>", "label": "<display>" }],
62
+ "workspaceRootHint": "<basename-or-suffix>"
63
+ }
64
+ }
65
+ ```
66
+
67
+ Relative preset paths must stay inside the git toplevel; escapes are rejected.
68
+
69
+ ## `git_not_found`
70
+
71
+ If **`git`** is missing or not runnable, tools and the presets resource respond with **`git_not_found`**. Prerequisites and `PATH`: **[docs/install.md](docs/install.md)**.
72
+
73
+ ## Installation
74
+
75
+ **Package install and MCP clients:** **[docs/install.md](docs/install.md)**.
76
+
77
+ ## Development
78
+
79
+ Requires **Bun ≥ 1.3.11** to build this repository (`packageManager` in `package.json`). **Published runtime** (Node/Bun/Git and how to launch the server): **[docs/install.md](docs/install.md)** — *Prerequisites*.
80
+
81
+ ```bash
82
+ bun install
83
+ bun run build # rimraf dist + tsc → dist/server.js, dist/server/*.js, dist/repo-paths.js
84
+ bun run check # Biome
85
+ bun run check:fix # Biome --write
86
+ bun run setup-hooks # once per clone: use .githooks (pre-commit: check; pre-push: CI parity)
87
+ ```
88
+
89
+ **Git hooks:** after `setup-hooks`, **pre-commit** runs `bun run check`; **pre-push** runs `bun install --frozen-lockfile`, `bun run build`, `bun run check`, and `bun run test` (same order as CI). Set **`SKIP_GIT_HOOKS=1`** or use **`--no-verify`** to bypass.
90
+
91
+ **CI:** [`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs on pull requests and pushes to `main`: **`actions/setup-node` with Node 24** (minimum 22 asserted), then `bun install --frozen-lockfile`, `bun run build`, `bun run check`, and `bun run test`. A follow-up job **`prerelease-pack`** builds the same tree and runs **`npm pack`**, then uploads a **prerelease `.tgz` artifact** (named with the commit SHA) you can download from the workflow run’s **Artifacts** section (retention 90 days). Match the check job locally before opening a PR.
92
+
93
+ ## Publishing
94
+
95
+ ### GitHub (automated) — version tags only
96
+
97
+ Tag pushes run [`.github/workflows/release.yml`](.github/workflows/release.yml): build, check, tests, then:
98
+
99
+ 1. **`npm pack`** using the committed **`package.json`** name [`@rethunk/mcp-multi-root-git`](https://github.com/Rethunk-AI/mcp-multi-root-git) — tarball attached to a **GitHub Release** for that tag.
100
+ 2. **GitHub Packages** (npm registry): the workflow temporarily rewrites the package **name** to **`@rethunk-ai/mcp-multi-root-git`** (required scope for org `Rethunk-AI` on GitHub) and runs **`npm publish`** to **`https://npm.pkg.github.com`** with **`GITHUB_TOKEN`** (`packages: write`). No npmjs token is used in CI.
101
+
102
+ Prerequisite: push a **semver git tag** `vX.Y.Z` that **exactly matches** `version` in `package.json` (e.g. `v1.2.3` and `"version": "1.2.3"`).
103
+
104
+ ### npmjs (manual) — maintainers only
105
+
106
+ npmjs no longer fits an unattended CI publish flow for this org; **do not** rely on automation to [npmjs](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git). To publish the **same** package name consumers already use (**`@rethunk/mcp-multi-root-git`**):
107
+
108
+ 1. On a clean checkout at the release commit (usually **`main`** after bumping version), run **`bun run prepublishOnly`** (or `bun run build && bun run check && bun run test`).
109
+ 2. Log in to the public registry once per machine: **`npm login`** (or `npm adduser`) so **`npm whoami`** shows the account that owns **`@rethunk`** on npmjs.
110
+ 3. Ensure **`package.json`** still has **`"name": "@rethunk/mcp-multi-root-git"`** and **`publishConfig.access`** is **`"public"`** (no **`publishConfig.registry`** pointing at GitHub — leave default registry for npmjs).
111
+ 4. Publish: **`npm publish --access public`** (runs **`prepublishOnly`** again unless you pass **`--ignore-scripts`** after you already verified locally).
112
+
113
+ **`package.json` `files`** must keep the whole **`dist/`** directory so every emitted chunk the entry imports is packed; if you add new `src/server/*.ts` modules, `tsc` emits matching **`dist/server/*.js`** files — do not narrow **`files`** back to a single **`server.js`** or installs break.
114
+
115
+ **Preset `$schema`:** after **`npm install`**, the schema path is under **`node_modules/@rethunk/mcp-multi-root-git/`** for npmjs, or **`node_modules/@rethunk-ai/mcp-multi-root-git/`** when installing from GitHub Packages — adjust **`$schema`** accordingly (see [docs/install.md](docs/install.md)).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rethunk-AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @rethunk/mcp-multi-root-git
2
+
3
+ [![CI](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/ci.yml/badge.svg)](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/ci.yml)
4
+ [![Release](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/release.yml/badge.svg)](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/release.yml)
5
+ [![GitHub release](https://img.shields.io/github/v/release/Rethunk-AI/mcp-multi-root-git?logo=github&label=release)](https://github.com/Rethunk-AI/mcp-multi-root-git/releases/latest)
6
+ [![npm version](https://img.shields.io/npm/v/%40rethunk%2Fmcp-multi-root-git.svg)](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
7
+ [![npm downloads](https://img.shields.io/npm/dm/%40rethunk%2Fmcp-multi-root-git.svg?label=npm%20downloads)](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
8
+ [![GitHub Packages](https://img.shields.io/badge/github%20packages-%40rethunk--ai%2Fmcp--multi--root--git-24292f?logo=github)](https://github.com/Rethunk-AI/mcp-multi-root-git/pkgs/npm/mcp-multi-root-git)
9
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
10
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D22-339933.svg)](https://github.com/Rethunk-AI/mcp-multi-root-git/blob/main/package.json)
11
+
12
+ Read-only **git** tools over MCP (status, multi-root inventory, `HEAD` parity, presets). **Install and MCP client wiring:** **[docs/install.md](docs/install.md)** only — do not duplicate those steps elsewhere.
13
+
14
+ **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/pkgs/npm/mcp-multi-root-git) — see [docs/install.md](docs/install.md) and [HUMANS.md](HUMANS.md) Publishing.
15
+
16
+ ## Documentation
17
+
18
+ | Doc | Audience |
19
+ |-----|----------|
20
+ | **[docs/install.md](docs/install.md)** | Single source for prerequisites, running the package, and every supported MCP client (plus from-source and troubleshooting) |
21
+ | **[docs/mcp-tools.md](docs/mcp-tools.md)** | Tool ids, client naming, `format` / JSON, resource URI, workspace root resolution (canonical reference) |
22
+ | **[HUMANS.md](HUMANS.md)** | Preset file, dev commands, CI, publishing |
23
+ | **[AGENTS.md](AGENTS.md)** | Contributors: implementation map ([`src/server/`](src/server/) + entry [`src/server.ts`](src/server.ts)), contract bumps, CI (IDEs that inject this file as project context should not re-link it from rules) |
24
+
25
+ **Tools at a glance:** see the table in **[docs/mcp-tools.md](docs/mcp-tools.md)**.
@@ -0,0 +1,26 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ export function realPathOrSelf(p) {
4
+ try {
5
+ return realpathSync(p);
6
+ }
7
+ catch {
8
+ return p;
9
+ }
10
+ }
11
+ export function isStrictlyUnderGitTop(absPath, gitTop) {
12
+ const absR = realPathOrSelf(resolve(absPath));
13
+ const topR = realPathOrSelf(resolve(gitTop));
14
+ const rel = relative(topR, absR);
15
+ if (rel === "")
16
+ return true;
17
+ return !rel.startsWith("..") && !isAbsolute(rel);
18
+ }
19
+ export function resolvePathForRepo(p, gitTop) {
20
+ const t = p.trim();
21
+ return isAbsolute(t) ? resolve(t) : resolve(gitTop, t);
22
+ }
23
+ /** Resolved path must lie inside git toplevel (relative or absolute user input). */
24
+ export function assertRelativePathUnderTop(_relPath, absResolved, gitTop) {
25
+ return isStrictlyUnderGitTop(absResolved, gitTop);
26
+ }
@@ -0,0 +1,191 @@
1
+ import { z } from "zod";
2
+ import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitRevParseGitDir, gitTopLevel, isSafeGitUpstreamToken, } from "./git.js";
3
+ import { buildInventorySectionMarkdown, collectInventoryEntry, makeSkipEntry, validateRepoPath, } from "./inventory.js";
4
+ import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
+ import { applyPresetNestedRoots } from "./presets.js";
6
+ import { requireGitAndRoots } from "./roots.js";
7
+ import { MAX_INVENTORY_ROOTS_DEFAULT, WorkspacePickSchema } from "./schemas.js";
8
+ export function registerGitInventoryTool(server) {
9
+ server.addTool({
10
+ name: "git_inventory",
11
+ description: "Read-only push-prep inventory: status + ahead/behind per root. " +
12
+ "Uses each repo's configured upstream (`@{u}`) unless both `remote` and `branch` are set. " +
13
+ "Presets from `.rethunk/git-mcp-presets.json`; use `presetMerge` to combine with inline paths.",
14
+ parameters: WorkspacePickSchema.extend({
15
+ nestedRoots: z
16
+ .array(z.string())
17
+ .optional()
18
+ .describe("Paths relative to git toplevel. If empty/omitted, only the repo root is listed. With `presetMerge`, merged with preset paths."),
19
+ preset: z
20
+ .string()
21
+ .optional()
22
+ .describe("Named preset from .rethunk/git-mcp-presets.json (at git toplevel)."),
23
+ presetMerge: z
24
+ .boolean()
25
+ .optional()
26
+ .default(false)
27
+ .describe("When true, merge `nestedRoots` with preset nestedRoots instead of replacing."),
28
+ remote: z
29
+ .string()
30
+ .optional()
31
+ .describe("Fixed upstream remote; must be set together with `branch` to override auto upstream."),
32
+ branch: z
33
+ .string()
34
+ .optional()
35
+ .describe("Fixed upstream branch name; must be set together with `remote`."),
36
+ maxRoots: z
37
+ .number()
38
+ .int()
39
+ .min(1)
40
+ .max(256)
41
+ .optional()
42
+ .default(MAX_INVENTORY_ROOTS_DEFAULT)
43
+ .describe("Max nested roots to process (cap)."),
44
+ }),
45
+ execute: async (args) => {
46
+ const pre = requireGitAndRoots(server, args, args.preset);
47
+ if (!pre.ok) {
48
+ return jsonRespond(pre.error);
49
+ }
50
+ const fixedRemote = args.remote;
51
+ const fixedBranch = args.branch;
52
+ const hasRemote = fixedRemote !== undefined && fixedRemote.trim() !== "";
53
+ const hasBranch = fixedBranch !== undefined && fixedBranch.trim() !== "";
54
+ if (hasRemote !== hasBranch) {
55
+ return jsonRespond({
56
+ error: "remote_branch_mismatch",
57
+ message: "Set both `remote` and `branch` for fixed upstream, or omit both for auto `@{u}`.",
58
+ });
59
+ }
60
+ const useFixed = hasRemote && hasBranch;
61
+ if (useFixed) {
62
+ const r = String(fixedRemote).trim();
63
+ const b = String(fixedBranch).trim();
64
+ if (!isSafeGitUpstreamToken(r) || !isSafeGitUpstreamToken(b)) {
65
+ return jsonRespond({
66
+ error: "invalid_remote_or_branch",
67
+ message: "remote and branch must be plain tokens: no whitespace, control characters, `@`, `..`, leading `-`, or git rev metacharacters like `^ : ? * [ ] { } ~ \\`.",
68
+ });
69
+ }
70
+ }
71
+ const allJson = [];
72
+ const mdChunks = [];
73
+ for (const workspaceRoot of pre.roots) {
74
+ const top = gitTopLevel(workspaceRoot);
75
+ if (!top) {
76
+ const err = { error: "not_a_git_repository", path: workspaceRoot };
77
+ if (args.format === "json") {
78
+ allJson.push({
79
+ workspace_root: workspaceRoot,
80
+ upstream: {
81
+ mode: useFixed ? "fixed" : "auto",
82
+ remote: fixedRemote,
83
+ branch: fixedBranch,
84
+ },
85
+ entries: [
86
+ {
87
+ label: workspaceRoot,
88
+ path: workspaceRoot,
89
+ branchStatus: "",
90
+ shortStatus: "",
91
+ detached: false,
92
+ headAbbrev: "",
93
+ upstreamMode: useFixed ? "fixed" : "auto",
94
+ upstreamRef: null,
95
+ ahead: null,
96
+ behind: null,
97
+ upstreamNote: "",
98
+ skipReason: JSON.stringify(err),
99
+ },
100
+ ],
101
+ });
102
+ }
103
+ else {
104
+ mdChunks.push(`# Git inventory`, "", jsonRespond(err), "");
105
+ }
106
+ continue;
107
+ }
108
+ let nestedRoots = args.nestedRoots;
109
+ let presetSchemaVersion;
110
+ if (args.preset) {
111
+ const applied = applyPresetNestedRoots(top, args.preset, args.presetMerge, nestedRoots);
112
+ if (!applied.ok) {
113
+ return jsonRespond(applied.error);
114
+ }
115
+ nestedRoots = applied.nestedRoots;
116
+ presetSchemaVersion = applied.presetSchemaVersion;
117
+ }
118
+ const maxRoots = args.maxRoots ?? MAX_INVENTORY_ROOTS_DEFAULT;
119
+ let nestedRootsTruncated = false;
120
+ let nestedRootsOmittedCount = 0;
121
+ if (nestedRoots && nestedRoots.length > maxRoots) {
122
+ nestedRootsOmittedCount = nestedRoots.length - maxRoots;
123
+ nestedRoots = nestedRoots.slice(0, maxRoots);
124
+ nestedRootsTruncated = true;
125
+ }
126
+ const headerNote = useFixed
127
+ ? `remote/branch (fixed): ${fixedRemote}/${fixedBranch}`
128
+ : "upstream: per-repo @{u} (configured upstream)";
129
+ const entries = [];
130
+ if (nestedRoots?.length) {
131
+ const jobs = [];
132
+ for (const rel of nestedRoots) {
133
+ const { abs, underTop } = validateRepoPath(rel, top);
134
+ if (!underTop) {
135
+ entries.push(makeSkipEntry(rel, abs, useFixed ? "fixed" : "auto", "(path escapes git toplevel — rejected)"));
136
+ continue;
137
+ }
138
+ if (!gitRevParseGitDir(abs)) {
139
+ entries.push(makeSkipEntry(rel, abs, useFixed ? "fixed" : "auto", "(not a git work tree — skip)"));
140
+ continue;
141
+ }
142
+ jobs.push({ label: rel, abs });
143
+ }
144
+ const computed = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, (j) => collectInventoryEntry(j.label, j.abs, useFixed ? fixedRemote : undefined, useFixed ? fixedBranch : undefined));
145
+ entries.push(...computed);
146
+ }
147
+ else if (!gitRevParseGitDir(top)) {
148
+ entries.push(makeSkipEntry(".", top, useFixed ? "fixed" : "auto", "(not a git work tree — unexpected)"));
149
+ }
150
+ else {
151
+ const one = await collectInventoryEntry(".", top, useFixed ? fixedRemote : undefined, useFixed ? fixedBranch : undefined);
152
+ entries.push(one);
153
+ }
154
+ if (args.format === "json") {
155
+ allJson.push({
156
+ workspace_root: top,
157
+ ...spreadDefined("presetSchemaVersion", presetSchemaVersion),
158
+ ...spreadWhen(nestedRootsTruncated, {
159
+ nestedRootsTruncated: true,
160
+ nestedRootsOmittedCount,
161
+ }),
162
+ upstream: useFixed
163
+ ? { mode: "fixed", remote: fixedRemote, branch: fixedBranch }
164
+ : { mode: "auto" },
165
+ entries,
166
+ });
167
+ }
168
+ else {
169
+ const sections = [
170
+ "# Git inventory",
171
+ "",
172
+ `workspace_root: ${top}`,
173
+ headerNote,
174
+ "",
175
+ ];
176
+ if (nestedRootsTruncated) {
177
+ sections.push(`nested_roots_truncated: ${nestedRootsOmittedCount} path(s) not listed (maxRoots=${maxRoots})`, "");
178
+ }
179
+ for (const e of entries) {
180
+ sections.push(...buildInventorySectionMarkdown(e));
181
+ }
182
+ mdChunks.push(sections.join("\n"));
183
+ }
184
+ }
185
+ if (args.format === "json") {
186
+ return jsonRespond({ inventories: allJson });
187
+ }
188
+ return mdChunks.join("\n\n---\n\n");
189
+ },
190
+ });
191
+ }
@@ -0,0 +1,149 @@
1
+ import { z } from "zod";
2
+ import { gitRevParseHead, gitTopLevel } from "./git.js";
3
+ import { validateRepoPath } from "./inventory.js";
4
+ import { jsonRespond, spreadDefined } from "./json.js";
5
+ import { applyPresetParityPairs } from "./presets.js";
6
+ import { requireGitAndRoots } from "./roots.js";
7
+ import { WorkspacePickSchema } from "./schemas.js";
8
+ export function registerGitParityTool(server) {
9
+ server.addTool({
10
+ name: "git_parity",
11
+ description: "Read-only: compare `git rev-parse HEAD` for path pairs. Presets or inline `pairs`; `presetMerge` merges with inline pairs.",
12
+ parameters: WorkspacePickSchema.extend({
13
+ pairs: z
14
+ .array(z.object({
15
+ left: z.string(),
16
+ right: z.string(),
17
+ label: z.string().optional(),
18
+ }))
19
+ .optional(),
20
+ preset: z.string().optional(),
21
+ presetMerge: z.boolean().optional().default(false),
22
+ }),
23
+ execute: async (args) => {
24
+ const pre = requireGitAndRoots(server, args, args.preset);
25
+ if (!pre.ok) {
26
+ return jsonRespond(pre.error);
27
+ }
28
+ const results = [];
29
+ const mdParts = [];
30
+ for (const workspaceRoot of pre.roots) {
31
+ const top = gitTopLevel(workspaceRoot);
32
+ if (!top) {
33
+ const errPayload = { error: "not_a_git_repository", path: workspaceRoot };
34
+ const err = jsonRespond(errPayload);
35
+ if (args.format === "json") {
36
+ results.push({
37
+ workspace_root: workspaceRoot,
38
+ status: "MISMATCH",
39
+ pairs: [{ label: "—", leftPath: "", rightPath: "", match: false, error: err }],
40
+ });
41
+ }
42
+ else {
43
+ mdParts.push(err);
44
+ }
45
+ continue;
46
+ }
47
+ let pairs = args.pairs;
48
+ let parityPresetSchemaVersion;
49
+ if (args.preset) {
50
+ const applied = applyPresetParityPairs(top, args.preset, args.presetMerge, pairs);
51
+ if (!applied.ok) {
52
+ return jsonRespond(applied.error);
53
+ }
54
+ pairs = applied.pairs;
55
+ parityPresetSchemaVersion = applied.presetSchemaVersion;
56
+ }
57
+ if (!pairs?.length) {
58
+ return jsonRespond({
59
+ error: "no_pairs",
60
+ message: "Pass `pairs` directly or a `preset` with parityPairs (or presetMerge).",
61
+ });
62
+ }
63
+ let allOk = true;
64
+ const pairResults = [];
65
+ for (const pair of pairs) {
66
+ const pa = validateRepoPath(pair.left, top);
67
+ const pb = validateRepoPath(pair.right, top);
68
+ const label = pair.label ?? `${pair.left} / ${pair.right}`;
69
+ if (!pa.underTop || !pb.underTop) {
70
+ allOk = false;
71
+ pairResults.push({
72
+ label,
73
+ leftPath: pa.abs,
74
+ rightPath: pb.abs,
75
+ match: false,
76
+ error: "path escapes git toplevel — rejected",
77
+ });
78
+ continue;
79
+ }
80
+ const ha = gitRevParseHead(pa.abs);
81
+ const hb = gitRevParseHead(pb.abs);
82
+ if (!ha.ok || !hb.ok) {
83
+ allOk = false;
84
+ pairResults.push({
85
+ label,
86
+ leftPath: pa.abs,
87
+ rightPath: pb.abs,
88
+ match: false,
89
+ error: [!ha.ok ? `left: ${ha.text}` : "", !hb.ok ? `right: ${hb.text}` : ""]
90
+ .filter(Boolean)
91
+ .join("\n"),
92
+ });
93
+ continue;
94
+ }
95
+ if (ha.sha !== hb.sha) {
96
+ allOk = false;
97
+ pairResults.push({
98
+ label,
99
+ leftPath: pa.abs,
100
+ rightPath: pb.abs,
101
+ match: false,
102
+ leftSha: ha.sha,
103
+ rightSha: hb.sha,
104
+ });
105
+ }
106
+ else {
107
+ pairResults.push({
108
+ label,
109
+ leftPath: pa.abs,
110
+ rightPath: pb.abs,
111
+ match: true,
112
+ sha: ha.sha,
113
+ });
114
+ }
115
+ }
116
+ results.push({
117
+ workspace_root: top,
118
+ ...spreadDefined("presetSchemaVersion", parityPresetSchemaVersion),
119
+ status: allOk ? "OK" : "MISMATCH",
120
+ pairs: pairResults,
121
+ });
122
+ if (args.format !== "json") {
123
+ const lines = [
124
+ "# Git HEAD parity",
125
+ "",
126
+ `status: ${allOk ? "OK" : "MISMATCH"}`,
127
+ "",
128
+ ];
129
+ for (const pr of pairResults) {
130
+ if (pr.error) {
131
+ lines.push(`## ${pr.label} — error`, "```text", pr.error, "```", "");
132
+ }
133
+ else if (pr.match) {
134
+ lines.push(`## ${pr.label} — OK`, "```text", `SHA: ${pr.sha}`, "```", "");
135
+ }
136
+ else {
137
+ lines.push(`## ${pr.label} — MISMATCH`, "```text", `left: ${pr.leftSha}`, `right: ${pr.rightSha}`, "```", "");
138
+ }
139
+ }
140
+ mdParts.push(lines.join("\n"));
141
+ }
142
+ }
143
+ if (args.format === "json") {
144
+ return jsonRespond({ parity: results });
145
+ }
146
+ return mdParts.join("\n\n---\n\n");
147
+ },
148
+ });
149
+ }