repolith 0.2.0 → 0.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.
Files changed (3) hide show
  1. package/README.md +8 -1
  2. package/dist/cli.js +125 -46
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Make a set of independent git repos feel like one monorepo — without touching git internals, GitHub, or CI.
4
4
 
5
- **Status: v0.2 — CLI + MCP server.** All CLI commands (`sync`, `checkout`, `status`, `grep`, `log`, `diff`, `exec`, `init`, `bisect`) are implemented and tested, plus an **MCP server** (`repolith mcp`) so AI agents can query and restore workspace state, and a VS Code extension. APIs may still change pre-1.0.
5
+ **Status: v0.3 — CLI + MCP server.** All CLI commands (`sync`, `checkout`, `status`, `grep`, `log`, `diff`, `exec`, `init`, `bisect`, `state`, `freeze`, `open`) are implemented and tested — with `--json` on the read commands — plus an **MCP server** (`repolith mcp`) so AI agents can query and restore workspace state, and a VS Code extension. APIs may still change pre-1.0.
6
6
 
7
7
  ## What it is
8
8
 
@@ -24,6 +24,8 @@ npm i -g repolith
24
24
  bun add -g repolith
25
25
  ```
26
26
 
27
+ **VS Code extension:** search "repolith" in the Extensions view, or install [`stanicky.repolith-vscode`](https://marketplace.visualstudio.com/items?itemName=stanicky.repolith-vscode).
28
+
27
29
  ## Quick start
28
30
 
29
31
  ```bash
@@ -34,8 +36,13 @@ repolith grep "TODO" # search across all repos at once
34
36
  repolith exec "npm test" # run a command in every repo
35
37
  repolith checkout # restore every repo to the locked commit (deterministic)
36
38
  repolith bisect --good good.lock.json --test "npm test" # find the repo+commit that broke the system
39
+ repolith state --json # print the atomic hash + per-repo commits (scriptable)
40
+ repolith freeze snap.json # write a shareable snapshot of the current state
41
+ repolith open snap.json # reconstruct the exact system from a shared snapshot
37
42
  ```
38
43
 
44
+ `status`, `grep`, `log`, `diff`, and `state` all accept `--json` for scripting and agent/CI use.
45
+
39
46
  ## For AI agents (MCP)
40
47
 
41
48
  `repolith` ships an [MCP](https://modelcontextprotocol.io) server so coding agents can query and reconstruct multi-repo state deterministically — *git pins a repo; repolith pins a system.*
package/dist/cli.js CHANGED
@@ -17058,24 +17058,21 @@ Sync completed with errors — lockfile NOT updated.`);
17058
17058
  import { readFile as readFile3 } from "node:fs/promises";
17059
17059
  import { existsSync as existsSync2 } from "node:fs";
17060
17060
  import { resolve as resolve2, join as join3 } from "node:path";
17061
- async function restoreToLock(manifestPath) {
17061
+ async function restoreState(manifestPath, state) {
17062
17062
  const manifestDir = resolve2(manifestPath, "..");
17063
17063
  const manifest = parseManifest(await readFile3(manifestPath, "utf8"));
17064
- const lock = await readLockfile(manifestDir);
17065
- if (!lock)
17066
- throw new Error("no repolith.lock.json — run `repolith sync` first");
17067
17064
  const results = await runAll(manifest.repos, async (repo) => {
17068
- const locked = lock.repos[repo.name];
17069
- if (!locked)
17070
- throw new Error(`repo "${repo.name}" is not in the lockfile`);
17065
+ const pinned = state.repos[repo.name];
17066
+ if (!pinned)
17067
+ throw new Error(`state has no commit for "${repo.name}"`);
17071
17068
  const dest = join3(manifestDir, repo.path);
17072
17069
  if (!existsSync2(dest))
17073
17070
  await gitClone(repo.url, dest, repo.ref);
17074
17071
  await gitFetch(dest);
17075
- await gitCheckoutCommit(dest, locked.commit);
17072
+ await gitCheckoutCommit(dest, pinned.commit);
17076
17073
  const head = (await gitRun(dest, ["rev-parse", "HEAD"])).stdout.trim();
17077
- if (head !== locked.commit) {
17078
- throw new Error(`HEAD ${head.slice(0, 8)} != locked ${locked.commit.slice(0, 8)}`);
17074
+ if (head !== pinned.commit) {
17075
+ throw new Error(`HEAD ${head.slice(0, 8)} != pinned ${pinned.commit.slice(0, 8)}`);
17079
17076
  }
17080
17077
  return head;
17081
17078
  });
@@ -17088,11 +17085,17 @@ async function restoreToLock(manifestPath) {
17088
17085
  repos[r.repo.name] = { url: r.repo.url, ref: r.repo.ref, commit: r.value };
17089
17086
  }
17090
17087
  const hash = computeHash(repos);
17091
- if (errors.length === 0 && hash !== lock.hash) {
17092
- throw new Error(`restored hash ${hash.slice(0, 12)} != lockfile hash ${lock.hash.slice(0, 12)} — refusing to claim reproducibility`);
17088
+ if (errors.length === 0 && hash !== state.hash) {
17089
+ throw new Error(`restored hash ${hash.slice(0, 12)} != expected ${state.hash.slice(0, 12)} — refusing to claim reproducibility`);
17093
17090
  }
17094
17091
  return { workspace: manifest.name, hash, repos, errors };
17095
17092
  }
17093
+ async function restoreToLock(manifestPath) {
17094
+ const lock = await readLockfile(resolve2(manifestPath, ".."));
17095
+ if (!lock)
17096
+ throw new Error("no repolith.lock.json — run `repolith sync` first");
17097
+ return restoreState(manifestPath, lock);
17098
+ }
17096
17099
  async function checkoutCommand(manifestPath) {
17097
17100
  console.log("Restoring workspace to locked state…");
17098
17101
  const { workspace, hash, errors } = await restoreToLock(manifestPath);
@@ -17125,11 +17128,16 @@ async function repoStatus(dest) {
17125
17128
  } catch {}
17126
17129
  return { branch: branch.trim(), dirty: porcelain.trim().length > 0, ahead, behind };
17127
17130
  }
17128
- async function statusCommand(manifestPath) {
17131
+ async function statusCommand(manifestPath, json = false) {
17129
17132
  const manifestDir = resolve3(manifestPath, "..");
17130
17133
  const toml = await readFile4(manifestPath, "utf8");
17131
17134
  const manifest = parseManifest(toml);
17132
17135
  const results = await runAll(manifest.repos, (repo) => repoStatus(join4(manifestDir, repo.path)));
17136
+ if (json) {
17137
+ const data = results.map((r) => r.ok ? { repo: r.repo.name, ...r.value } : { repo: r.repo.name, error: r.error.message });
17138
+ console.log(JSON.stringify(data, null, 2));
17139
+ return;
17140
+ }
17133
17141
  const nameWidth = Math.max(4, ...manifest.repos.map((r) => r.name.length));
17134
17142
  for (const r of results) {
17135
17143
  const name = r.repo.name.padEnd(nameWidth);
@@ -17183,34 +17191,37 @@ async function execCommand(command, manifestPath) {
17183
17191
  // src/commands/grep.ts
17184
17192
  import { readFile as readFile6 } from "node:fs/promises";
17185
17193
  import { resolve as resolve5, join as join6 } from "node:path";
17186
- async function grepCommand(pattern, extraArgs, manifestPath) {
17194
+ async function grepCommand(pattern, extraArgs, manifestPath, json = false) {
17187
17195
  const manifestDir = resolve5(manifestPath, "..");
17188
17196
  const toml = await readFile6(manifestPath, "utf8");
17189
17197
  const manifest = parseManifest(toml);
17190
17198
  const results = await runAll(manifest.repos, async (repo) => {
17191
17199
  const dest = join6(manifestDir, repo.path);
17192
- const { stdout } = await gitRun(dest, ["grep", "--color=never", "-n", pattern, ...extraArgs]).catch(() => ({ stdout: "", stderr: "" }));
17200
+ const { stdout } = await gitRun(dest, ["grep", "--color=never", "-n", ...extraArgs, pattern]).catch(() => ({ stdout: "", stderr: "" }));
17193
17201
  return stdout;
17194
17202
  });
17195
- let anyMatch = false;
17203
+ const matches = [];
17196
17204
  for (const r of results) {
17197
- if (!r.ok || !r.value.trim())
17198
- continue;
17199
- const lines = r.value.trim().split(`
17200
- `);
17201
- for (const line of lines) {
17202
- console.log(`[${r.repo.name}] ${line}`);
17203
- anyMatch = true;
17205
+ if (r.ok && r.value.trim())
17206
+ matches.push({ repo: r.repo.name, hits: r.value.trim().split(`
17207
+ `) });
17208
+ }
17209
+ if (json) {
17210
+ console.log(JSON.stringify(matches, null, 2));
17211
+ } else {
17212
+ for (const m of matches) {
17213
+ for (const line of m.hits)
17214
+ console.log(`[${m.repo}] ${line}`);
17204
17215
  }
17205
17216
  }
17206
- if (!anyMatch)
17217
+ if (matches.length === 0)
17207
17218
  process.exit(1);
17208
17219
  }
17209
17220
 
17210
17221
  // src/commands/log.ts
17211
17222
  import { readFile as readFile7 } from "node:fs/promises";
17212
17223
  import { resolve as resolve6, join as join7 } from "node:path";
17213
- async function logCommand2(extraArgs, manifestPath) {
17224
+ async function logCommand2(extraArgs, manifestPath, json = false) {
17214
17225
  const manifestDir = resolve6(manifestPath, "..");
17215
17226
  const toml = await readFile7(manifestPath, "utf8");
17216
17227
  const manifest = parseManifest(toml);
@@ -17219,6 +17230,11 @@ async function logCommand2(extraArgs, manifestPath) {
17219
17230
  const { stdout } = await gitRun(dest, ["log", "--oneline", ...extraArgs]);
17220
17231
  return stdout.trim();
17221
17232
  });
17233
+ if (json) {
17234
+ const data = results.map((r) => r.ok ? { repo: r.repo.name, log: r.value } : { repo: r.repo.name, error: r.error.message });
17235
+ console.log(JSON.stringify(data, null, 2));
17236
+ return;
17237
+ }
17222
17238
  for (const r of results) {
17223
17239
  console.log(`
17224
17240
  === [${r.repo.name}] ===`);
@@ -17235,7 +17251,7 @@ async function logCommand2(extraArgs, manifestPath) {
17235
17251
  // src/commands/diff.ts
17236
17252
  import { readFile as readFile8 } from "node:fs/promises";
17237
17253
  import { resolve as resolve7, join as join8 } from "node:path";
17238
- async function diffCommand(extraArgs, manifestPath) {
17254
+ async function diffCommand(extraArgs, manifestPath, json = false) {
17239
17255
  const manifestDir = resolve7(manifestPath, "..");
17240
17256
  const toml = await readFile8(manifestPath, "utf8");
17241
17257
  const manifest = parseManifest(toml);
@@ -17244,17 +17260,21 @@ async function diffCommand(extraArgs, manifestPath) {
17244
17260
  const { stdout } = await gitRun(dest, ["diff", ...extraArgs]);
17245
17261
  return stdout;
17246
17262
  });
17247
- let anyDiff = false;
17248
- for (const r of results) {
17249
- if (!r.ok) {
17250
- console.error(`=== [${r.repo.name}] ERROR: ${r.error.message}`);
17251
- continue;
17252
- }
17253
- if (r.value.trim()) {
17254
- console.log(`
17263
+ const anyDiff = results.some((r) => r.ok && r.value.trim().length > 0);
17264
+ if (json) {
17265
+ const data = results.map((r) => r.ok ? { repo: r.repo.name, diff: r.value } : { repo: r.repo.name, error: r.error.message });
17266
+ console.log(JSON.stringify(data, null, 2));
17267
+ } else {
17268
+ for (const r of results) {
17269
+ if (!r.ok) {
17270
+ console.error(`=== [${r.repo.name}] ERROR: ${r.error.message}`);
17271
+ continue;
17272
+ }
17273
+ if (r.value.trim()) {
17274
+ console.log(`
17255
17275
  === [${r.repo.name}] ===`);
17256
- process.stdout.write(r.value);
17257
- anyDiff = true;
17276
+ process.stdout.write(r.value);
17277
+ }
17258
17278
  }
17259
17279
  }
17260
17280
  if (anyDiff)
@@ -39950,7 +39970,7 @@ var jsonResult = (data) => ({
39950
39970
  });
39951
39971
  function buildMcpServer(manifestPath, opts) {
39952
39972
  const manifestDir = resolve8(manifestPath, "..");
39953
- const server = new McpServer({ name: "repolith", version: "0.2.0" });
39973
+ const server = new McpServer({ name: "repolith", version: "0.3.1" });
39954
39974
  const loadManifest = async () => parseManifest(await readFile9(manifestPath, "utf8"));
39955
39975
  server.registerTool("repolith_state", {
39956
39976
  title: "Workspace state",
@@ -40114,26 +40134,85 @@ async function bisect(opts) {
40114
40134
  }
40115
40135
  }
40116
40136
 
40137
+ // src/commands/freeze.ts
40138
+ import { readFile as readFile11, writeFile as writeFile3 } from "node:fs/promises";
40139
+ import { resolve as resolve10, join as join12 } from "node:path";
40140
+ async function buildState(manifestPath) {
40141
+ const manifestDir = resolve10(manifestPath, "..");
40142
+ const manifest = parseManifest(await readFile11(manifestPath, "utf8"));
40143
+ const results = await runAll(manifest.repos, (repo) => gitCurrentCommit(join12(manifestDir, repo.path)));
40144
+ const repos = {};
40145
+ for (const r of results) {
40146
+ if (!r.ok)
40147
+ throw new Error(`${r.repo.name}: ${r.error.message}`);
40148
+ repos[r.repo.name] = { url: r.repo.url, ref: r.repo.ref, commit: r.value };
40149
+ }
40150
+ return { version: 1, repos, hash: computeHash(repos) };
40151
+ }
40152
+ async function freezeCommand(manifestPath, outPath) {
40153
+ const state = await buildState(manifestPath);
40154
+ await writeFile3(outPath, JSON.stringify(state, null, 2) + `
40155
+ `, "utf8");
40156
+ console.log(`Froze ${Object.keys(state.repos).length} repos → ${outPath}`);
40157
+ console.log(`Workspace hash: ${state.hash}`);
40158
+ }
40159
+ async function stateCommand(manifestPath, json2 = false) {
40160
+ const state = await buildState(manifestPath);
40161
+ if (json2) {
40162
+ console.log(JSON.stringify(state, null, 2));
40163
+ return;
40164
+ }
40165
+ console.log(`hash: ${state.hash}`);
40166
+ for (const [name, r] of Object.entries(state.repos)) {
40167
+ console.log(` ${name} ${r.commit.slice(0, 12)} (${r.ref})`);
40168
+ }
40169
+ }
40170
+
40171
+ // src/commands/open.ts
40172
+ import { readFile as readFile12 } from "node:fs/promises";
40173
+ async function openCommand(manifestPath, stateFilePath) {
40174
+ const state = JSON.parse(await readFile12(stateFilePath, "utf8"));
40175
+ console.log(`Opening shared state from ${stateFilePath}…`);
40176
+ const { workspace, hash: hash2, errors: errors3 } = await restoreState(manifestPath, state);
40177
+ for (const e of errors3)
40178
+ console.error(` ERROR ${e.repo}: ${e.message}`);
40179
+ if (errors3.length) {
40180
+ console.error(`
40181
+ Open completed with errors.`);
40182
+ process.exit(1);
40183
+ }
40184
+ console.log(`"${workspace}" restored to ${hash2.slice(0, 12)}… from shared state`);
40185
+ }
40186
+
40117
40187
  // src/cli.ts
40118
40188
  function fail(e) {
40119
40189
  console.error(e.message);
40120
40190
  process.exit(1);
40121
40191
  }
40122
40192
  var program2 = new Command;
40123
- program2.name("repolith").description("Make a set of independent git repos feel like one monorepo").version("0.2.0");
40193
+ program2.name("repolith").description("Make a set of independent git repos feel like one monorepo").version("0.3.1");
40124
40194
  program2.command("sync").description("Clone missing repos, update all to their tracked refs, and write the lockfile").argument("[manifest]", "Path to repolith.toml", "repolith.toml").action(async (manifest) => {
40125
40195
  await syncCommand(manifest).catch(fail);
40126
40196
  });
40127
40197
  program2.command("checkout").description("Restore every repo to the commit pinned in repolith.lock.json (deterministic system restore)").option("--manifest <path>", "Path to repolith.toml", "repolith.toml").action(async (opts) => {
40128
40198
  await checkoutCommand(opts.manifest).catch(fail);
40129
40199
  });
40130
- program2.command("status").description("Show branch + dirty/clean + ahead/behind for every repo").option("--manifest <path>", "Path to repolith.toml", "repolith.toml").action(async (opts) => {
40131
- await statusCommand(opts.manifest).catch(fail);
40200
+ program2.command("state").description("Print the atomic workspace hash + each repo's current commit").option("--manifest <path>", "Path to repolith.toml", "repolith.toml").option("--json", "Output structured JSON", false).action(async (opts) => {
40201
+ await stateCommand(opts.manifest, opts.json ?? false).catch(fail);
40202
+ });
40203
+ program2.command("freeze").description("Write a shareable snapshot of the current state to a file").argument("[outfile]", "Output path", "repolith.state.json").option("--manifest <path>", "Path to repolith.toml", "repolith.toml").action(async (outfile, opts) => {
40204
+ await freezeCommand(opts.manifest, outfile).catch(fail);
40205
+ });
40206
+ program2.command("open").description("Reconstruct the workspace from a shared state file (from `repolith freeze`)").argument("<statefile>", "Path to a repolith.state.json").option("--manifest <path>", "Path to repolith.toml", "repolith.toml").action(async (statefile, opts) => {
40207
+ await openCommand(opts.manifest, statefile).catch(fail);
40208
+ });
40209
+ program2.command("status").description("Show branch + dirty/clean + ahead/behind for every repo").option("--manifest <path>", "Path to repolith.toml", "repolith.toml").option("--json", "Output structured JSON", false).action(async (opts) => {
40210
+ await statusCommand(opts.manifest, opts.json ?? false).catch(fail);
40132
40211
  });
40133
40212
  program2.command("exec").description("Run a shell command in every repo (quote the command)").argument("<command>", 'Command to run, e.g. "npm test"').option("--manifest <path>", "Path to repolith.toml", "repolith.toml").action(async (command, opts) => {
40134
40213
  await execCommand(command, opts.manifest).catch(fail);
40135
40214
  });
40136
- program2.command("grep").description("Search across all repos (uses git grep)").argument("<pattern>", "Search pattern").option("-i, --ignore-case", "Case-insensitive match").option("-l, --files-with-matches", "Print only filenames").option("-w, --word-regexp", "Match whole words only").option("--manifest <path>", "Path to repolith.toml", "repolith.toml").allowUnknownOption().action(async (pattern, opts) => {
40215
+ program2.command("grep").description("Search across all repos (uses git grep)").argument("<pattern>", "Search pattern").option("-i, --ignore-case", "Case-insensitive match").option("-l, --files-with-matches", "Print only filenames").option("-w, --word-regexp", "Match whole words only").option("--manifest <path>", "Path to repolith.toml", "repolith.toml").option("--json", "Output structured JSON", false).allowUnknownOption().action(async (pattern, opts) => {
40137
40216
  const extra = [];
40138
40217
  if (opts.ignoreCase)
40139
40218
  extra.push("-i");
@@ -40141,19 +40220,19 @@ program2.command("grep").description("Search across all repos (uses git grep)").
40141
40220
  extra.push("-l");
40142
40221
  if (opts.wordRegexp)
40143
40222
  extra.push("-w");
40144
- await grepCommand(pattern, extra, opts.manifest).catch(fail);
40223
+ await grepCommand(pattern, extra, opts.manifest, opts.json ?? false).catch(fail);
40145
40224
  });
40146
- program2.command("log").description("Show git log for all repos").option("-n, --max-count <n>", "Limit number of commits", "10").option("--since <date>", "Show commits more recent than date").option("--manifest <path>", "Path to repolith.toml", "repolith.toml").action(async (opts) => {
40225
+ program2.command("log").description("Show git log for all repos").option("-n, --max-count <n>", "Limit number of commits", "10").option("--since <date>", "Show commits more recent than date").option("--manifest <path>", "Path to repolith.toml", "repolith.toml").option("--json", "Output structured JSON", false).action(async (opts) => {
40147
40226
  const extra = ["-n", opts.maxCount];
40148
40227
  if (opts.since)
40149
40228
  extra.push(`--since=${opts.since}`);
40150
- await logCommand2(extra, opts.manifest).catch(fail);
40229
+ await logCommand2(extra, opts.manifest, opts.json ?? false).catch(fail);
40151
40230
  });
40152
- program2.command("diff").description("Show git diff across all repos").option("--staged", "Show staged changes").option("--manifest <path>", "Path to repolith.toml", "repolith.toml").allowUnknownOption().action(async (opts) => {
40231
+ program2.command("diff").description("Show git diff across all repos").option("--staged", "Show staged changes").option("--manifest <path>", "Path to repolith.toml", "repolith.toml").option("--json", "Output structured JSON", false).allowUnknownOption().action(async (opts) => {
40153
40232
  const extra = [];
40154
40233
  if (opts.staged)
40155
40234
  extra.push("--staged");
40156
- await diffCommand(extra, opts.manifest).catch(fail);
40235
+ await diffCommand(extra, opts.manifest, opts.json ?? false).catch(fail);
40157
40236
  });
40158
40237
  program2.command("init").description("Interactively create a repolith.toml").option("-d, --dir <path>", "Directory to create repolith.toml in", ".").option("-f, --force", "Overwrite an existing repolith.toml").action(async (opts) => {
40159
40238
  await initCommand(opts.dir, opts.force ?? false).catch(fail);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repolith",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Make a set of independent git repos feel like one monorepo — TOML manifest, atomic lockfile hash, parallel git dispatch, a VS Code extension, and an MCP server for AI agents.",
5
5
  "keywords": [
6
6
  "git",