@contentrain/mcp 1.5.2 → 1.8.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.
Files changed (72) hide show
  1. package/README.md +8 -7
  2. package/dist/{apply-manager-BJ3nKEls.mjs → apply-manager-BRmB27H5.mjs} +5 -5
  3. package/dist/{apply-manager-BJ3nKEls.mjs.map → apply-manager-BRmB27H5.mjs.map} +1 -1
  4. package/dist/branch-lifecycle-7dQoF22r.d.mts +141 -0
  5. package/dist/branch-lifecycle-7dQoF22r.d.mts.map +1 -0
  6. package/dist/branch-lifecycle-D7tXOE4f.mjs +444 -0
  7. package/dist/branch-lifecycle-D7tXOE4f.mjs.map +1 -0
  8. package/dist/{config-Lw7vpK9n.mjs → config-DlFcTxFG.mjs} +3 -2
  9. package/dist/config-DlFcTxFG.mjs.map +1 -0
  10. package/dist/{context-DS40WOdw.mjs → context-CYN__o3Q.mjs} +24 -11
  11. package/dist/context-CYN__o3Q.mjs.map +1 -0
  12. package/dist/core/apply-manager.mjs +6 -6
  13. package/dist/core/config.d.mts.map +1 -1
  14. package/dist/core/config.mjs +1 -1
  15. package/dist/core/context.d.mts +20 -2
  16. package/dist/core/context.d.mts.map +1 -1
  17. package/dist/core/context.mjs +2 -2
  18. package/dist/core/doctor.mjs +3 -3
  19. package/dist/core/ops/index.d.mts +7 -0
  20. package/dist/core/ops/index.d.mts.map +1 -1
  21. package/dist/core/ops/index.mjs +1 -1
  22. package/dist/core/validator/index.mjs +2 -2
  23. package/dist/{detect-DL9mFmaS.mjs → detect-eWS6lvoH.mjs} +1 -1
  24. package/dist/{detect-DL9mFmaS.mjs.map → detect-eWS6lvoH.mjs.map} +1 -1
  25. package/dist/{doctor-Dxu_SzZy.mjs → doctor-qiUKCy9b.mjs} +34 -15
  26. package/dist/doctor-qiUKCy9b.mjs.map +1 -0
  27. package/dist/git/branch-lifecycle.d.mts +2 -57
  28. package/dist/git/branch-lifecycle.mjs +3 -3
  29. package/dist/git/transaction.d.mts +2 -0
  30. package/dist/git/transaction.d.mts.map +1 -1
  31. package/dist/git/transaction.mjs +5 -4
  32. package/dist/{id-CIO9s0x_.mjs → id-DV_T9Ic8.mjs} +1 -1
  33. package/dist/{id-CIO9s0x_.mjs.map → id-DV_T9Ic8.mjs.map} +1 -1
  34. package/dist/index.mjs +12 -12
  35. package/dist/{local-BJEAd4Ub.mjs → local-BZEUWBoC.mjs} +9 -12
  36. package/dist/local-BZEUWBoC.mjs.map +1 -0
  37. package/dist/{ops-DjEWi5Wa.mjs → ops-Dh2T3u53.mjs} +90 -5
  38. package/dist/ops-Dh2T3u53.mjs.map +1 -0
  39. package/dist/providers/github/index.d.mts +23 -2
  40. package/dist/providers/github/index.d.mts.map +1 -1
  41. package/dist/providers/github/index.mjs +112 -51
  42. package/dist/providers/github/index.mjs.map +1 -1
  43. package/dist/providers/gitlab/index.d.mts +3 -1
  44. package/dist/providers/gitlab/index.d.mts.map +1 -1
  45. package/dist/providers/gitlab/index.mjs +21 -5
  46. package/dist/providers/gitlab/index.mjs.map +1 -1
  47. package/dist/providers/local/index.mjs +7 -6
  48. package/dist/server/http/index.d.mts +1 -1
  49. package/dist/server/http/index.mjs +12 -12
  50. package/dist/{server-WqGx6ait.d.mts → server-DZBcZWhE.d.mts} +1 -1
  51. package/dist/{server-WqGx6ait.d.mts.map → server-DZBcZWhE.d.mts.map} +1 -1
  52. package/dist/{server-RwV1Ig5l.mjs → server-tTuinKHD.mjs} +60 -25
  53. package/dist/server-tTuinKHD.mjs.map +1 -0
  54. package/dist/server.d.mts +1 -1
  55. package/dist/server.mjs +12 -12
  56. package/dist/{transaction-LiTjan9Q.mjs → transaction-qliKvBRL.mjs} +104 -60
  57. package/dist/transaction-qliKvBRL.mjs.map +1 -0
  58. package/dist/util/detect.mjs +1 -1
  59. package/dist/util/id.mjs +1 -1
  60. package/dist/{validator-Bo06QPVB.mjs → validator-Z3DGNWQ6.mjs} +2 -2
  61. package/dist/{validator-Bo06QPVB.mjs.map → validator-Z3DGNWQ6.mjs.map} +1 -1
  62. package/package.json +2 -2
  63. package/dist/branch-lifecycle-DjX3MXNG.mjs +0 -149
  64. package/dist/branch-lifecycle-DjX3MXNG.mjs.map +0 -1
  65. package/dist/config-Lw7vpK9n.mjs.map +0 -1
  66. package/dist/context-DS40WOdw.mjs.map +0 -1
  67. package/dist/doctor-Dxu_SzZy.mjs.map +0 -1
  68. package/dist/git/branch-lifecycle.d.mts.map +0 -1
  69. package/dist/local-BJEAd4Ub.mjs.map +0 -1
  70. package/dist/ops-DjEWi5Wa.mjs.map +0 -1
  71. package/dist/server-RwV1Ig5l.mjs.map +0 -1
  72. package/dist/transaction-LiTjan9Q.mjs.map +0 -1
@@ -0,0 +1,444 @@
1
+ import { t as readConfig } from "./config-DlFcTxFG.mjs";
2
+ import { CONTENTRAIN_BRANCH } from "@contentrain/types";
3
+ import { simpleGit } from "simple-git";
4
+ //#region src/git/branch-lifecycle.ts
5
+ /**
6
+ * Lists all local contentrain/* branches, deletes those already merged
7
+ * into the base branch, and returns the count of remaining unmerged ones.
8
+ */
9
+ async function cleanupMergedBranches(projectRoot) {
10
+ const git = simpleGit(projectRoot);
11
+ const config = await readConfig(projectRoot);
12
+ const baseBranch = config?.repository?.default_branch ?? process.env["CONTENTRAIN_BRANCH"] ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
13
+ const contentrainBranches = (await git.branchLocal()).all.filter((b) => b.startsWith("cr/")).filter((b) => b !== CONTENTRAIN_BRANCH);
14
+ if (contentrainBranches.length === 0) return {
15
+ deleted: 0,
16
+ remaining: 0,
17
+ deletedBranches: []
18
+ };
19
+ let mergedSet;
20
+ try {
21
+ mergedSet = await classifyMergedBranches(projectRoot, contentrainBranches, CONTENTRAIN_BRANCH);
22
+ } catch {
23
+ try {
24
+ mergedSet = await classifyMergedBranches(projectRoot, contentrainBranches, baseBranch);
25
+ } catch {
26
+ return {
27
+ deleted: 0,
28
+ remaining: contentrainBranches.length,
29
+ deletedBranches: []
30
+ };
31
+ }
32
+ }
33
+ const mergedContentrain = contentrainBranches.filter((b) => mergedSet.has(b));
34
+ const deletedBranches = [];
35
+ const retentionMs = (config?.branchRetention ?? 30) * 24 * 60 * 60 * 1e3;
36
+ const now = Date.now();
37
+ for (const branch of mergedContentrain) try {
38
+ const timestampRaw = (await git.raw([
39
+ "log",
40
+ "-1",
41
+ "--format=%ct",
42
+ branch
43
+ ])).trim();
44
+ if (now - Number(timestampRaw) * 1e3 < retentionMs) continue;
45
+ await git.raw([
46
+ "branch",
47
+ "-D",
48
+ branch
49
+ ]);
50
+ deletedBranches.push(branch);
51
+ } catch {}
52
+ const remaining = contentrainBranches.length - deletedBranches.length;
53
+ return {
54
+ deleted: deletedBranches.length,
55
+ remaining,
56
+ deletedBranches
57
+ };
58
+ }
59
+ /**
60
+ * Check branch health: count contentrain/* branches and return warning/blocked status.
61
+ * - 50+ branches: warning
62
+ * - 80+ branches: blocked
63
+ */
64
+ async function checkBranchHealth(projectRoot) {
65
+ const git = simpleGit(projectRoot);
66
+ const config = await readConfig(projectRoot);
67
+ const baseBranch = config?.repository?.default_branch ?? process.env["CONTENTRAIN_BRANCH"] ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
68
+ const contentrainBranches = (await git.branchLocal()).all.filter((b) => b.startsWith("cr/")).filter((b) => b !== CONTENTRAIN_BRANCH);
69
+ const total = contentrainBranches.length;
70
+ const warnLimit = config?.branchWarnLimit ?? 50;
71
+ const blockLimit = config?.branchBlockLimit ?? 80;
72
+ let mergedCount = 0;
73
+ const classifyOpts = { fallbackThreshold: warnLimit };
74
+ try {
75
+ mergedCount = (await classifyMergedBranches(projectRoot, contentrainBranches, CONTENTRAIN_BRANCH, classifyOpts)).size;
76
+ } catch {
77
+ try {
78
+ mergedCount = (await classifyMergedBranches(projectRoot, contentrainBranches, baseBranch, classifyOpts)).size;
79
+ } catch {}
80
+ }
81
+ const unmerged = total - mergedCount;
82
+ const warning = unmerged >= warnLimit;
83
+ const blocked = unmerged >= blockLimit;
84
+ let message;
85
+ if (blocked) message = `BLOCKED: ${unmerged} active contentrain branches (limit: ${blockLimit}). Run cleanup or merge/delete old branches before creating new ones.`;
86
+ else if (warning) message = `WARNING: ${unmerged} active contentrain branches. Consider merging or deleting old branches (warning at ${warnLimit}, blocked at ${blockLimit}).`;
87
+ return {
88
+ total,
89
+ merged: mergedCount,
90
+ unmerged,
91
+ warning,
92
+ blocked,
93
+ message
94
+ };
95
+ }
96
+ /**
97
+ * Compute the diff between a feature branch and its base.
98
+ *
99
+ * Defaults `base` to `CONTENTRAIN_BRANCH` — the singleton content-
100
+ * tracking branch every feature branch forks from. Passing the repo's
101
+ * default branch (e.g. `main`) is almost always a bug: when
102
+ * `contentrain` is ahead of `main`, the diff picks up unrelated
103
+ * historical content changes that the feature branch did not produce.
104
+ *
105
+ * Used by `contentrain serve` (branch detail view), the `contentrain
106
+ * diff` CLI command, and any Studio-side driver that needs to preview
107
+ * a feature branch before approving it.
108
+ */
109
+ async function branchDiff(projectRoot, opts) {
110
+ const git = simpleGit(projectRoot);
111
+ const base = opts.base ?? CONTENTRAIN_BRANCH;
112
+ const range = `${base}...${opts.branch}`;
113
+ const [stat, patch, summary] = await Promise.all([
114
+ git.diff([range, "--stat"]),
115
+ git.diff([range]),
116
+ git.diffSummary([range])
117
+ ]);
118
+ return {
119
+ branch: opts.branch,
120
+ base,
121
+ stat,
122
+ patch,
123
+ filesChanged: summary.changed
124
+ };
125
+ }
126
+ /**
127
+ * Merged-verdict caches.
128
+ *
129
+ * - `pairVerdictCache` — keyed by `(tipSha, intoTipSha, cap)`: the
130
+ * relationship between two FIXED commits never changes, so entries are
131
+ * permanently valid.
132
+ * - `mergedTipCache` — keyed by `(tipSha, into NAME)`: once a tip is merged
133
+ * into a branch, it stays merged as that branch advances (merged-ness is
134
+ * monotonic), so positives survive `contentrain` moving forward. This is
135
+ * what keeps the per-write branch-health gate cheap in long-lived
136
+ * processes (MCP server, `contentrain serve`).
137
+ */
138
+ const pairVerdictCache = /* @__PURE__ */ new Map();
139
+ const mergedTipCache = /* @__PURE__ */ new Map();
140
+ const MERGED_CACHE_LIMIT = 1e4;
141
+ const DEFAULT_MAX_CHERRY_COMMITS = 200;
142
+ /** Concurrent `git cherry` subprocesses during classification. */
143
+ const CHERRY_CONCURRENCY = 8;
144
+ async function isTipMerged(git, tip, intoTip, maxCherryCommits, intoName) {
145
+ const tipKey = intoName ? `${tip}→${intoName}` : void 0;
146
+ if (tipKey && mergedTipCache.has(tipKey)) return true;
147
+ const pairKey = `${tip}:${intoTip}:${maxCherryCommits}`;
148
+ const cached = pairVerdictCache.get(pairKey);
149
+ if (cached !== void 0) return cached;
150
+ let merged = false;
151
+ try {
152
+ const lines = (await git.raw([
153
+ "cherry",
154
+ intoTip,
155
+ tip
156
+ ])).split("\n").map((line) => line.trim()).filter(Boolean);
157
+ if (lines.length === 0) merged = true;
158
+ else if (lines.length <= maxCherryCommits) merged = !lines.some((line) => line.startsWith("+"));
159
+ } catch {
160
+ merged = false;
161
+ }
162
+ if (pairVerdictCache.size >= MERGED_CACHE_LIMIT) pairVerdictCache.clear();
163
+ pairVerdictCache.set(pairKey, merged);
164
+ if (merged && tipKey) {
165
+ if (mergedTipCache.size >= MERGED_CACHE_LIMIT) mergedTipCache.clear();
166
+ mergedTipCache.set(tipKey, true);
167
+ }
168
+ return merged;
169
+ }
170
+ /**
171
+ * Robust merged check for a single ref: ancestry fast-path, then a bounded
172
+ * `git cherry` (patch-id) fallback that survives base-history rewrites.
173
+ * Returns false when either ref cannot be resolved.
174
+ */
175
+ async function isRefMerged(git, ref, into, opts) {
176
+ let tip;
177
+ let intoTip;
178
+ try {
179
+ const lines = (await git.raw([
180
+ "rev-parse",
181
+ ref,
182
+ into
183
+ ])).trim().split("\n");
184
+ tip = lines[0]?.trim();
185
+ intoTip = lines[1]?.trim();
186
+ } catch {
187
+ return false;
188
+ }
189
+ if (!tip || !intoTip) return false;
190
+ return isTipMerged(git, tip, intoTip, opts?.maxCherryCommits ?? DEFAULT_MAX_CHERRY_COMMITS, into);
191
+ }
192
+ /**
193
+ * Classify which of the given local branches are merged into `into`
194
+ * (default: the contentrain branch). One `git branch --merged` call covers
195
+ * the ancestry-merged majority; only the remainder pays the patch-id
196
+ * fallback (bounded concurrency, verdicts cached).
197
+ *
198
+ * `opts.fallbackThreshold` skips the patch-id fallback entirely when fewer
199
+ * than that many branches are ancestry-unmerged — the fallback can only
200
+ * LOWER the unmerged count, so callers that merely compare the count
201
+ * against a limit (the hot pre-write gate) pay nothing in the normal case.
202
+ *
203
+ * Throws when `into` does not resolve — callers use this to fall back to
204
+ * the base branch (mirrors the previous `branch --merged` semantics).
205
+ */
206
+ async function classifyMergedBranches(projectRoot, branches, into = CONTENTRAIN_BRANCH, opts) {
207
+ const git = simpleGit(projectRoot);
208
+ if (branches.length === 0) {
209
+ await git.raw([
210
+ "branch",
211
+ "--merged",
212
+ into
213
+ ]);
214
+ return /* @__PURE__ */ new Set();
215
+ }
216
+ const mergedRaw = await git.raw([
217
+ "branch",
218
+ "--merged",
219
+ into
220
+ ]);
221
+ const ancestryMerged = new Set(mergedRaw.split("\n").map((b) => b.replace(/^\*?\s+/, "").trim()).filter(Boolean));
222
+ const merged = /* @__PURE__ */ new Set();
223
+ const rest = [];
224
+ for (const branch of branches) if (ancestryMerged.has(branch)) merged.add(branch);
225
+ else rest.push(branch);
226
+ if (rest.length === 0 || rest.length < (opts?.fallbackThreshold ?? 0)) return merged;
227
+ let tips;
228
+ try {
229
+ tips = (await git.raw([
230
+ "rev-parse",
231
+ into,
232
+ ...rest
233
+ ])).trim().split("\n").map((s) => s.trim());
234
+ } catch {
235
+ return merged;
236
+ }
237
+ const intoTip = tips[0];
238
+ if (!intoTip) return merged;
239
+ for (let i = 0; i < rest.length; i += CHERRY_CONCURRENCY) {
240
+ const chunk = rest.slice(i, i + CHERRY_CONCURRENCY);
241
+ const verdicts = await Promise.all(chunk.map((branch, j) => {
242
+ const tip = tips[i + j + 1];
243
+ return tip ? isTipMerged(simpleGit(projectRoot), tip, intoTip, DEFAULT_MAX_CHERRY_COMMITS, into) : Promise.resolve(false);
244
+ }));
245
+ for (const [j, verdict] of verdicts.entries()) if (verdict) merged.add(chunk[j]);
246
+ }
247
+ return merged;
248
+ }
249
+ const REMOTE_PUSH_TIMEOUT_MS = 1e4;
250
+ const REMOTE_LIST_TIMEOUT_MS = 5e3;
251
+ function contentrainRemoteName() {
252
+ return process.env["CONTENTRAIN_REMOTE"] ?? "origin";
253
+ }
254
+ /**
255
+ * simple-git instance hardened for network operations: `timeout.block` kills
256
+ * the child after N ms without output (hung SSH passphrase prompts), and
257
+ * `GIT_TERMINAL_PROMPT=0` refuses interactive HTTPS credential prompts.
258
+ */
259
+ function networkGit(projectRoot, timeoutMs) {
260
+ return simpleGit({
261
+ baseDir: projectRoot,
262
+ timeout: { block: timeoutMs }
263
+ }).env({
264
+ ...process.env,
265
+ GIT_TERMINAL_PROMPT: "0"
266
+ });
267
+ }
268
+ async function resolveRemote(git) {
269
+ try {
270
+ const remotes = await git.getRemotes();
271
+ const name = contentrainRemoteName();
272
+ return remotes.some((r) => r.name === name) ? name : null;
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+ /**
278
+ * Best-effort delete of a cr/* branch on the configured remote. Never
279
+ * throws: expected conditions land in `skipped`, real failures in
280
+ * `warning`. Gated by `config.remoteBranchCleanup` (default: on).
281
+ *
282
+ * Pass `opts.config` when the caller already read it (avoids a re-read);
283
+ * `null` means "no config" and applies the default gate.
284
+ */
285
+ async function deleteRemoteBranch(projectRoot, branch, opts) {
286
+ if (!branch.startsWith("cr/") || branch === CONTENTRAIN_BRANCH) return {
287
+ deleted: false,
288
+ skipped: "protected"
289
+ };
290
+ if (!((opts?.config === void 0 ? await readConfig(projectRoot) : opts.config)?.remoteBranchCleanup ?? true)) return {
291
+ deleted: false,
292
+ skipped: "disabled"
293
+ };
294
+ const git = networkGit(projectRoot, opts?.timeoutMs ?? REMOTE_PUSH_TIMEOUT_MS);
295
+ const remote = await resolveRemote(git);
296
+ if (!remote) return {
297
+ deleted: false,
298
+ skipped: "no-remote"
299
+ };
300
+ try {
301
+ await git.push([
302
+ remote,
303
+ "--delete",
304
+ branch
305
+ ]);
306
+ return { deleted: true };
307
+ } catch (error) {
308
+ const message = error instanceof Error ? error.message : String(error);
309
+ if (/remote ref does not exist|couldn't find remote ref/i.test(message)) return {
310
+ deleted: false,
311
+ skipped: "not-found"
312
+ };
313
+ return {
314
+ deleted: false,
315
+ warning: `Could not delete "${branch}" on ${remote}: ${message}`
316
+ };
317
+ }
318
+ }
319
+ /**
320
+ * Authoritative list of cr/* branches on the configured remote via
321
+ * `ls-remote --heads` (no fetch, no stale remote-tracking refs). Returns
322
+ * null when no remote is configured. Never throws.
323
+ */
324
+ async function listRemoteCrBranches(projectRoot, opts) {
325
+ const git = networkGit(projectRoot, opts?.timeoutMs ?? REMOTE_LIST_TIMEOUT_MS);
326
+ const remote = await resolveRemote(git);
327
+ if (!remote) return null;
328
+ try {
329
+ return {
330
+ remote,
331
+ branches: (await git.raw([
332
+ "ls-remote",
333
+ "--heads",
334
+ remote,
335
+ "refs/heads/cr/*"
336
+ ])).split("\n").map((line) => line.trim()).filter(Boolean).map((line) => {
337
+ const [sha, ref] = line.split(" ");
338
+ return {
339
+ sha: sha?.trim() ?? "",
340
+ name: ref?.trim().replace(/^refs\/heads\//, "") ?? ""
341
+ };
342
+ }).filter((b) => b.name.startsWith("cr/") && b.name !== CONTENTRAIN_BRANCH)
343
+ };
344
+ } catch (error) {
345
+ return {
346
+ remote,
347
+ branches: [],
348
+ error: error instanceof Error ? error.message : String(error)
349
+ };
350
+ }
351
+ }
352
+ const PRUNE_PUSH_CHUNK = 50;
353
+ /**
354
+ * Delete already-merged cr/* branches on the remote in batches. Merged-state
355
+ * uses the same ancestry + patch-id classification as the local cleanup, so
356
+ * branches leaked before a base-history rewrite are still recognised.
357
+ * Ignores `branchRetention` — a merged remote copy only produces phantom
358
+ * reviews. Never throws; gated by `config.remoteBranchCleanup`.
359
+ */
360
+ async function pruneMergedRemoteBranches(projectRoot, opts) {
361
+ if (!((opts?.config === void 0 ? await readConfig(projectRoot) : opts.config)?.remoteBranchCleanup ?? true)) return {
362
+ deleted: [],
363
+ kept: [],
364
+ errors: [],
365
+ skipped: "disabled"
366
+ };
367
+ const listed = await listRemoteCrBranches(projectRoot, { timeoutMs: opts?.timeoutMs });
368
+ if (!listed) return {
369
+ deleted: [],
370
+ kept: [],
371
+ errors: [],
372
+ skipped: "no-remote"
373
+ };
374
+ if (listed.error) return {
375
+ deleted: [],
376
+ kept: [],
377
+ errors: [listed.error],
378
+ skipped: "offline"
379
+ };
380
+ if (listed.branches.length === 0) return {
381
+ deleted: [],
382
+ kept: [],
383
+ errors: []
384
+ };
385
+ const git = networkGit(projectRoot, opts?.timeoutMs ?? REMOTE_PUSH_TIMEOUT_MS);
386
+ if ((await Promise.all(listed.branches.map((b) => git.raw([
387
+ "cat-file",
388
+ "-t",
389
+ b.sha
390
+ ]).then(() => true).catch(() => false)))).some((present) => !present)) try {
391
+ await git.fetch(listed.remote, `+refs/heads/cr/*:refs/remotes/${listed.remote}/cr/*`);
392
+ } catch {}
393
+ const kept = [];
394
+ const candidates = [];
395
+ const verdicts = await Promise.all(listed.branches.map((b) => isRefMerged(git, b.sha, CONTENTRAIN_BRANCH)));
396
+ for (const [i, merged] of verdicts.entries()) {
397
+ const name = listed.branches[i].name;
398
+ if (merged) candidates.push(name);
399
+ else kept.push(name);
400
+ }
401
+ const limit = opts?.max ?? Number.POSITIVE_INFINITY;
402
+ const toDelete = candidates.slice(0, limit);
403
+ kept.push(...candidates.slice(toDelete.length));
404
+ if (opts?.dryRun) return {
405
+ deleted: toDelete,
406
+ kept,
407
+ errors: []
408
+ };
409
+ const deleted = [];
410
+ const errors = [];
411
+ for (let i = 0; i < toDelete.length; i += PRUNE_PUSH_CHUNK) {
412
+ const chunk = toDelete.slice(i, i + PRUNE_PUSH_CHUNK);
413
+ try {
414
+ await git.push([
415
+ listed.remote,
416
+ "--delete",
417
+ ...chunk
418
+ ]);
419
+ deleted.push(...chunk);
420
+ } catch {
421
+ for (const branch of chunk) try {
422
+ await git.push([
423
+ listed.remote,
424
+ "--delete",
425
+ branch
426
+ ]);
427
+ deleted.push(branch);
428
+ } catch (error) {
429
+ const message = error instanceof Error ? error.message : String(error);
430
+ if (/remote ref does not exist|couldn't find remote ref/i.test(message)) deleted.push(branch);
431
+ else errors.push(`${branch}: ${message}`);
432
+ }
433
+ }
434
+ }
435
+ return {
436
+ deleted,
437
+ kept,
438
+ errors
439
+ };
440
+ }
441
+ //#endregion
442
+ export { deleteRemoteBranch as a, pruneMergedRemoteBranches as c, cleanupMergedBranches as i, checkBranchHealth as n, isRefMerged as o, classifyMergedBranches as r, listRemoteCrBranches as s, branchDiff as t };
443
+
444
+ //# sourceMappingURL=branch-lifecycle-D7tXOE4f.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"branch-lifecycle-D7tXOE4f.mjs","names":[],"sources":["../src/git/branch-lifecycle.ts"],"sourcesContent":["import { simpleGit, type SimpleGit } from 'simple-git'\nimport { CONTENTRAIN_BRANCH, type ContentrainConfig } from '@contentrain/types'\nimport { readConfig } from '../core/config.js'\n\nexport interface CleanupResult {\n deleted: number\n remaining: number\n deletedBranches: string[]\n}\n\nexport interface BranchHealthCheck {\n total: number\n merged: number\n unmerged: number\n warning: boolean\n blocked: boolean\n message?: string\n}\n\n/**\n * Lists all local contentrain/* branches, deletes those already merged\n * into the base branch, and returns the count of remaining unmerged ones.\n */\nexport async function cleanupMergedBranches(projectRoot: string): Promise<CleanupResult> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n\n // Determine base branch\n const baseBranch = config?.repository?.default_branch\n ?? process.env['CONTENTRAIN_BRANCH']\n ?? ((await git.raw(['branch', '--show-current'])).trim() || 'main')\n\n // Get all local branches (exclude the dedicated contentrain branch itself)\n const branchSummary = await git.branchLocal()\n const contentrainBranches = branchSummary.all\n .filter(b => b.startsWith('cr/'))\n .filter(b => b !== CONTENTRAIN_BRANCH)\n\n if (contentrainBranches.length === 0) {\n return { deleted: 0, remaining: 0, deletedBranches: [] }\n }\n\n // Check merged into the dedicated contentrain branch, falling back to baseBranch\n let mergedSet: Set<string>\n try {\n mergedSet = await classifyMergedBranches(projectRoot, contentrainBranches, CONTENTRAIN_BRANCH)\n } catch {\n // Contentrain branch may not exist yet (pre-init); fall back to baseBranch\n try {\n mergedSet = await classifyMergedBranches(projectRoot, contentrainBranches, baseBranch)\n } catch {\n // Base branch may not exist either; nothing is merged\n return { deleted: 0, remaining: contentrainBranches.length, deletedBranches: [] }\n }\n }\n\n const mergedContentrain = contentrainBranches.filter(b => mergedSet.has(b))\n const deletedBranches: string[] = []\n\n // Determine retention period (days). Default: 30\n const retentionDays = config?.branchRetention ?? 30\n const retentionMs = retentionDays * 24 * 60 * 60 * 1000\n const now = Date.now()\n\n // Delete merged branches only if older than retention period\n for (const branch of mergedContentrain) {\n try {\n const timestampRaw = (await git.raw(['log', '-1', '--format=%ct', branch])).trim()\n const commitTimestamp = Number(timestampRaw) * 1000\n if (now - commitTimestamp < retentionMs) {\n continue // Branch is within retention period — keep it\n }\n // -D, not -d: classifyMergedBranches already proved merged-ness (including\n // patch-id equivalence after a base-history rewrite, which -d's\n // ancestry-only check would refuse).\n await git.raw(['branch', '-D', branch])\n deletedBranches.push(branch)\n } catch {\n // Branch may be checked out, locked, or log failed — skip\n }\n }\n\n const remaining = contentrainBranches.length - deletedBranches.length\n return { deleted: deletedBranches.length, remaining, deletedBranches }\n}\n\n/**\n * Check branch health: count contentrain/* branches and return warning/blocked status.\n * - 50+ branches: warning\n * - 80+ branches: blocked\n */\nexport async function checkBranchHealth(projectRoot: string): Promise<BranchHealthCheck> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n\n const baseBranch = config?.repository?.default_branch\n ?? process.env['CONTENTRAIN_BRANCH']\n ?? ((await git.raw(['branch', '--show-current'])).trim() || 'main')\n\n const branchSummary = await git.branchLocal()\n const contentrainBranches = branchSummary.all\n .filter(b => b.startsWith('cr/'))\n .filter(b => b !== CONTENTRAIN_BRANCH)\n const total = contentrainBranches.length\n\n const warnLimit = config?.branchWarnLimit ?? 50\n const blockLimit = config?.branchBlockLimit ?? 80\n\n // Count merged into contentrain branch, falling back to baseBranch.\n // This gate runs before EVERY write: the patch-id fallback (which rescues\n // rewrite-orphaned merged branches from being counted as unmerged) only\n // matters once the ancestry-unmerged count could trip the warning, so\n // below `warnLimit` the check costs exactly one `branch --merged`.\n let mergedCount = 0\n const classifyOpts = { fallbackThreshold: warnLimit }\n try {\n const mergedSet = await classifyMergedBranches(projectRoot, contentrainBranches, CONTENTRAIN_BRANCH, classifyOpts)\n mergedCount = mergedSet.size\n } catch {\n // Contentrain branch may not exist yet (pre-init); fall back to baseBranch\n try {\n const mergedSet = await classifyMergedBranches(projectRoot, contentrainBranches, baseBranch, classifyOpts)\n mergedCount = mergedSet.size\n } catch {\n // ignore — neither branch exists\n }\n }\n const unmerged = total - mergedCount\n const warning = unmerged >= warnLimit\n const blocked = unmerged >= blockLimit\n\n let message: string | undefined\n if (blocked) {\n message = `BLOCKED: ${unmerged} active contentrain branches (limit: ${blockLimit}). Run cleanup or merge/delete old branches before creating new ones.`\n } else if (warning) {\n message = `WARNING: ${unmerged} active contentrain branches. Consider merging or deleting old branches (warning at ${warnLimit}, blocked at ${blockLimit}).`\n }\n\n return { total, merged: mergedCount, unmerged, warning, blocked, message }\n}\n\nexport interface BranchDiffResult {\n /** The feature branch the diff was computed from. */\n branch: string\n /** The base ref the diff was computed against. Defaults to the `contentrain` branch. */\n base: string\n /** `git diff --stat` output — human-readable summary. */\n stat: string\n /** Raw unified diff. */\n patch: string\n /** Number of files touched in the diff. */\n filesChanged: number\n}\n\n/**\n * Compute the diff between a feature branch and its base.\n *\n * Defaults `base` to `CONTENTRAIN_BRANCH` — the singleton content-\n * tracking branch every feature branch forks from. Passing the repo's\n * default branch (e.g. `main`) is almost always a bug: when\n * `contentrain` is ahead of `main`, the diff picks up unrelated\n * historical content changes that the feature branch did not produce.\n *\n * Used by `contentrain serve` (branch detail view), the `contentrain\n * diff` CLI command, and any Studio-side driver that needs to preview\n * a feature branch before approving it.\n */\nexport async function branchDiff(\n projectRoot: string,\n opts: { branch: string, base?: string },\n): Promise<BranchDiffResult> {\n const git = simpleGit(projectRoot)\n const base = opts.base ?? CONTENTRAIN_BRANCH\n const range = `${base}...${opts.branch}`\n\n const [stat, patch, summary] = await Promise.all([\n git.diff([range, '--stat']),\n git.diff([range]),\n git.diffSummary([range]),\n ])\n\n return {\n branch: opts.branch,\n base,\n stat,\n patch,\n filesChanged: summary.changed,\n }\n}\n\n// ─── Merged-state classification (ancestry + patch-id fallback) ───\n\n/**\n * Merged-verdict caches.\n *\n * - `pairVerdictCache` — keyed by `(tipSha, intoTipSha, cap)`: the\n * relationship between two FIXED commits never changes, so entries are\n * permanently valid.\n * - `mergedTipCache` — keyed by `(tipSha, into NAME)`: once a tip is merged\n * into a branch, it stays merged as that branch advances (merged-ness is\n * monotonic), so positives survive `contentrain` moving forward. This is\n * what keeps the per-write branch-health gate cheap in long-lived\n * processes (MCP server, `contentrain serve`).\n */\nconst pairVerdictCache = new Map<string, boolean>()\nconst mergedTipCache = new Map<string, true>()\nconst MERGED_CACHE_LIMIT = 10_000\nconst DEFAULT_MAX_CHERRY_COMMITS = 200\n/** Concurrent `git cherry` subprocesses during classification. */\nconst CHERRY_CONCURRENCY = 8\n\nasync function isTipMerged(\n git: SimpleGit,\n tip: string,\n intoTip: string,\n maxCherryCommits: number,\n intoName?: string,\n): Promise<boolean> {\n const tipKey = intoName ? `${tip}→${intoName}` : undefined\n if (tipKey && mergedTipCache.has(tipKey)) return true\n const pairKey = `${tip}:${intoTip}:${maxCherryCommits}`\n const cached = pairVerdictCache.get(pairKey)\n if (cached !== undefined) return cached\n\n let merged = false\n try {\n // ONE subprocess answers both questions: `git cherry intoTip tip` lists\n // tip's commits missing from intoTip by ancestry — empty output means\n // ancestor (merged); otherwise a line without `+` is patch-id-equivalent\n // to a commit already in intoTip (survives base-history rewrites).\n // Deliberately NOT `merge-base --is-ancestor`: that plumbing signals via\n // exit code with EMPTY stderr, and simple-git reports exit-code-only\n // failures as success, silently inverting the check. Bounded by\n // maxCherryCommits so rewrite-orphaned deep histories cannot stall the\n // hot pre-write gate.\n const lines = (await git.raw(['cherry', intoTip, tip]))\n .split('\\n').map(line => line.trim()).filter(Boolean)\n if (lines.length === 0) {\n merged = true\n } else if (lines.length <= maxCherryCommits) {\n merged = !lines.some(line => line.startsWith('+'))\n }\n } catch {\n merged = false\n }\n\n if (pairVerdictCache.size >= MERGED_CACHE_LIMIT) pairVerdictCache.clear()\n pairVerdictCache.set(pairKey, merged)\n if (merged && tipKey) {\n if (mergedTipCache.size >= MERGED_CACHE_LIMIT) mergedTipCache.clear()\n mergedTipCache.set(tipKey, true)\n }\n return merged\n}\n\n/**\n * Robust merged check for a single ref: ancestry fast-path, then a bounded\n * `git cherry` (patch-id) fallback that survives base-history rewrites.\n * Returns false when either ref cannot be resolved.\n */\nexport async function isRefMerged(\n git: SimpleGit,\n ref: string,\n into: string,\n opts?: { maxCherryCommits?: number },\n): Promise<boolean> {\n let tip: string | undefined\n let intoTip: string | undefined\n try {\n const lines = (await git.raw(['rev-parse', ref, into])).trim().split('\\n')\n tip = lines[0]?.trim()\n intoTip = lines[1]?.trim()\n } catch {\n return false\n }\n if (!tip || !intoTip) return false\n return isTipMerged(git, tip, intoTip, opts?.maxCherryCommits ?? DEFAULT_MAX_CHERRY_COMMITS, into)\n}\n\n/**\n * Classify which of the given local branches are merged into `into`\n * (default: the contentrain branch). One `git branch --merged` call covers\n * the ancestry-merged majority; only the remainder pays the patch-id\n * fallback (bounded concurrency, verdicts cached).\n *\n * `opts.fallbackThreshold` skips the patch-id fallback entirely when fewer\n * than that many branches are ancestry-unmerged — the fallback can only\n * LOWER the unmerged count, so callers that merely compare the count\n * against a limit (the hot pre-write gate) pay nothing in the normal case.\n *\n * Throws when `into` does not resolve — callers use this to fall back to\n * the base branch (mirrors the previous `branch --merged` semantics).\n */\nexport async function classifyMergedBranches(\n projectRoot: string,\n branches: string[],\n into: string = CONTENTRAIN_BRANCH,\n opts?: { fallbackThreshold?: number },\n): Promise<Set<string>> {\n const git = simpleGit(projectRoot)\n if (branches.length === 0) {\n // Preserve the \"throws when into is missing\" contract even for empty input.\n await git.raw(['branch', '--merged', into])\n return new Set()\n }\n const mergedRaw = await git.raw(['branch', '--merged', into])\n const ancestryMerged = new Set(\n mergedRaw.split('\\n').map(b => b.replace(/^\\*?\\s+/, '').trim()).filter(Boolean),\n )\n\n const merged = new Set<string>()\n const rest: string[] = []\n for (const branch of branches) {\n if (ancestryMerged.has(branch)) merged.add(branch)\n else rest.push(branch)\n }\n if (rest.length === 0 || rest.length < (opts?.fallbackThreshold ?? 0)) return merged\n\n // Resolve all remaining tips + into in ONE subprocess, then run the\n // (mostly cached) patch-id fallback in bounded-concurrency chunks. Each\n // task gets its OWN simple-git instance — a shared instance serializes\n // its command queue, which turns 80 branches into 80 sequential spawns.\n let tips: string[]\n try {\n tips = (await git.raw(['rev-parse', into, ...rest])).trim().split('\\n').map(s => s.trim())\n } catch {\n return merged\n }\n const intoTip = tips[0]\n if (!intoTip) return merged\n for (let i = 0; i < rest.length; i += CHERRY_CONCURRENCY) {\n const chunk = rest.slice(i, i + CHERRY_CONCURRENCY)\n const verdicts = await Promise.all(chunk.map((branch, j) => {\n const tip = tips[i + j + 1]\n return tip\n ? isTipMerged(simpleGit(projectRoot), tip, intoTip, DEFAULT_MAX_CHERRY_COMMITS, into)\n : Promise.resolve(false)\n }))\n for (const [j, verdict] of verdicts.entries()) {\n if (verdict) merged.add(chunk[j]!)\n }\n }\n return merged\n}\n\n// ─── Remote cr/* branch lifecycle ───\n\nconst REMOTE_PUSH_TIMEOUT_MS = 10_000\nconst REMOTE_LIST_TIMEOUT_MS = 5_000\n\nfunction contentrainRemoteName(): string {\n return process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n}\n\n/**\n * simple-git instance hardened for network operations: `timeout.block` kills\n * the child after N ms without output (hung SSH passphrase prompts), and\n * `GIT_TERMINAL_PROMPT=0` refuses interactive HTTPS credential prompts.\n */\nfunction networkGit(projectRoot: string, timeoutMs: number): SimpleGit {\n return simpleGit({ baseDir: projectRoot, timeout: { block: timeoutMs } })\n .env({ ...process.env, GIT_TERMINAL_PROMPT: '0' })\n}\n\nasync function resolveRemote(git: SimpleGit): Promise<string | null> {\n try {\n const remotes = await git.getRemotes()\n const name = contentrainRemoteName()\n return remotes.some(r => r.name === name) ? name : null\n } catch {\n return null\n }\n}\n\nexport interface RemoteDeleteResult {\n deleted: boolean\n /** Why nothing was deleted, when that is expected (not a failure). */\n skipped?: 'disabled' | 'no-remote' | 'not-found' | 'protected'\n /** A real failure (offline, auth, protected ref) — surfaced, never thrown. */\n warning?: string\n}\n\n/**\n * Best-effort delete of a cr/* branch on the configured remote. Never\n * throws: expected conditions land in `skipped`, real failures in\n * `warning`. Gated by `config.remoteBranchCleanup` (default: on).\n *\n * Pass `opts.config` when the caller already read it (avoids a re-read);\n * `null` means \"no config\" and applies the default gate.\n */\nexport async function deleteRemoteBranch(\n projectRoot: string,\n branch: string,\n opts?: { config?: ContentrainConfig | null, timeoutMs?: number },\n): Promise<RemoteDeleteResult> {\n if (!branch.startsWith('cr/') || branch === CONTENTRAIN_BRANCH) {\n return { deleted: false, skipped: 'protected' }\n }\n const config = opts?.config === undefined ? await readConfig(projectRoot) : opts.config\n if (!(config?.remoteBranchCleanup ?? true)) {\n return { deleted: false, skipped: 'disabled' }\n }\n const git = networkGit(projectRoot, opts?.timeoutMs ?? REMOTE_PUSH_TIMEOUT_MS)\n const remote = await resolveRemote(git)\n if (!remote) return { deleted: false, skipped: 'no-remote' }\n try {\n await git.push([remote, '--delete', branch])\n return { deleted: true }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n if (/remote ref does not exist|couldn't find remote ref/i.test(message)) {\n return { deleted: false, skipped: 'not-found' }\n }\n return { deleted: false, warning: `Could not delete \"${branch}\" on ${remote}: ${message}` }\n }\n}\n\nexport interface RemoteBranchList {\n remote: string\n branches: { name: string, sha: string }[]\n /** ls-remote failed (offline/timeout) — branches is empty, not authoritative. */\n error?: string\n}\n\n/**\n * Authoritative list of cr/* branches on the configured remote via\n * `ls-remote --heads` (no fetch, no stale remote-tracking refs). Returns\n * null when no remote is configured. Never throws.\n */\nexport async function listRemoteCrBranches(\n projectRoot: string,\n opts?: { timeoutMs?: number },\n): Promise<RemoteBranchList | null> {\n const git = networkGit(projectRoot, opts?.timeoutMs ?? REMOTE_LIST_TIMEOUT_MS)\n const remote = await resolveRemote(git)\n if (!remote) return null\n try {\n const raw = await git.raw(['ls-remote', '--heads', remote, 'refs/heads/cr/*'])\n const branches = raw.split('\\n')\n .map(line => line.trim())\n .filter(Boolean)\n .map((line) => {\n const [sha, ref] = line.split('\\t')\n return { sha: sha?.trim() ?? '', name: ref?.trim().replace(/^refs\\/heads\\//, '') ?? '' }\n })\n .filter(b => b.name.startsWith('cr/') && b.name !== CONTENTRAIN_BRANCH)\n return { remote, branches }\n } catch (error) {\n return { remote, branches: [], error: error instanceof Error ? error.message : String(error) }\n }\n}\n\nexport interface RemotePruneResult {\n /** Branches removed from the remote (in dryRun mode: the candidates). */\n deleted: string[]\n kept: string[]\n errors: string[]\n skipped?: 'disabled' | 'no-remote' | 'offline'\n}\n\nconst PRUNE_PUSH_CHUNK = 50\n\n/**\n * Delete already-merged cr/* branches on the remote in batches. Merged-state\n * uses the same ancestry + patch-id classification as the local cleanup, so\n * branches leaked before a base-history rewrite are still recognised.\n * Ignores `branchRetention` — a merged remote copy only produces phantom\n * reviews. Never throws; gated by `config.remoteBranchCleanup`.\n */\nexport async function pruneMergedRemoteBranches(\n projectRoot: string,\n opts?: { config?: ContentrainConfig | null, max?: number, dryRun?: boolean, timeoutMs?: number },\n): Promise<RemotePruneResult> {\n const config = opts?.config === undefined ? await readConfig(projectRoot) : opts.config\n if (!(config?.remoteBranchCleanup ?? true)) {\n return { deleted: [], kept: [], errors: [], skipped: 'disabled' }\n }\n\n const listed = await listRemoteCrBranches(projectRoot, { timeoutMs: opts?.timeoutMs })\n if (!listed) return { deleted: [], kept: [], errors: [], skipped: 'no-remote' }\n if (listed.error) return { deleted: [], kept: [], errors: [listed.error], skipped: 'offline' }\n if (listed.branches.length === 0) return { deleted: [], kept: [], errors: [] }\n\n const git = networkGit(projectRoot, opts?.timeoutMs ?? REMOTE_PUSH_TIMEOUT_MS)\n\n // Remote tips may predate this clone (leaked long ago, and our fetches are\n // single-refspec) — one scoped fetch backfills any missing objects.\n // `cat-file -t` (not `-e`): -e signals via exit code with empty stderr,\n // which simple-git reports as success.\n const presence = await Promise.all(listed.branches.map(b =>\n git.raw(['cat-file', '-t', b.sha]).then(() => true).catch(() => false),\n ))\n if (presence.some(present => !present)) {\n try {\n await git.fetch(listed.remote, `+refs/heads/cr/*:refs/remotes/${listed.remote}/cr/*`)\n } catch {\n // Best-effort: branches whose objects stay unresolvable are kept below.\n }\n }\n\n const kept: string[] = []\n const candidates: string[] = []\n const verdicts = await Promise.all(listed.branches.map(b => isRefMerged(git, b.sha, CONTENTRAIN_BRANCH)))\n for (const [i, merged] of verdicts.entries()) {\n const name = listed.branches[i]!.name\n if (merged) candidates.push(name)\n else kept.push(name)\n }\n\n const limit = opts?.max ?? Number.POSITIVE_INFINITY\n const toDelete = candidates.slice(0, limit)\n kept.push(...candidates.slice(toDelete.length))\n\n if (opts?.dryRun) return { deleted: toDelete, kept, errors: [] }\n\n const deleted: string[] = []\n const errors: string[] = []\n for (let i = 0; i < toDelete.length; i += PRUNE_PUSH_CHUNK) {\n const chunk = toDelete.slice(i, i + PRUNE_PUSH_CHUNK)\n try {\n await git.push([listed.remote, '--delete', ...chunk])\n deleted.push(...chunk)\n } catch {\n // One missing ref fails the whole multi-refspec push — retry per branch.\n for (const branch of chunk) {\n try {\n await git.push([listed.remote, '--delete', branch])\n deleted.push(branch)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n if (/remote ref does not exist|couldn't find remote ref/i.test(message)) {\n deleted.push(branch) // already gone — net effect is pruned\n } else {\n errors.push(`${branch}: ${message}`)\n }\n }\n }\n }\n }\n return { deleted, kept, errors }\n}\n"],"mappings":"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"}
@@ -25,7 +25,8 @@ function normaliseConfig(raw) {
25
25
  assets_path: raw.assets_path,
26
26
  branchRetention: raw.branchRetention,
27
27
  branchWarnLimit: raw.branchWarnLimit,
28
- branchBlockLimit: raw.branchBlockLimit
28
+ branchBlockLimit: raw.branchBlockLimit,
29
+ remoteBranchCleanup: raw.remoteBranchCleanup
29
30
  };
30
31
  }
31
32
  async function readConfig(input) {
@@ -39,4 +40,4 @@ async function readVocabulary(input) {
39
40
  //#endregion
40
41
  export { readVocabulary as n, readConfig as t };
41
42
 
42
- //# sourceMappingURL=config-Lw7vpK9n.mjs.map
43
+ //# sourceMappingURL=config-DlFcTxFG.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config-DlFcTxFG.mjs","names":[],"sources":["../src/core/config.ts"],"sourcesContent":["import type { ContentrainConfig, Vocabulary } from '@contentrain/types'\nimport { join } from 'node:path'\nimport { contentrainDir, readJson } from '../util/fs.js'\nimport type { RepoReader } from './contracts/index.js'\n\nconst CONFIG_PATH = '.contentrain/config.json'\nconst VOCABULARY_PATH = '.contentrain/vocabulary.json'\n\nasync function readJsonViaReader<T>(reader: RepoReader, path: string): Promise<T | null> {\n try {\n return JSON.parse(await reader.readFile(path)) as T\n } catch {\n return null\n }\n}\n\nfunction normaliseConfig(raw: Partial<ContentrainConfig> | null): ContentrainConfig | null {\n if (!raw) return null\n return {\n version: raw.version ?? 1,\n stack: raw.stack ?? 'other',\n workflow: raw.workflow ?? 'auto-merge',\n locales: {\n default: raw.locales?.default ?? 'en',\n supported: raw.locales?.supported ?? [raw.locales?.default ?? 'en'],\n },\n domains: raw.domains ?? [],\n repository: raw.repository,\n assets_path: raw.assets_path,\n branchRetention: raw.branchRetention,\n branchWarnLimit: raw.branchWarnLimit,\n branchBlockLimit: raw.branchBlockLimit,\n remoteBranchCleanup: raw.remoteBranchCleanup,\n }\n}\n\n/**\n * Read `.contentrain/config.json` and normalise it with safe defaults.\n *\n * Accepts either a legacy `projectRoot` string (LocalReader is constructed\n * internally) or any `RepoReader` — tool handlers pass their provider so\n * the same logic resolves against local fs or the GitHub Git Data API.\n */\nexport function readConfig(projectRoot: string): Promise<ContentrainConfig | null>\nexport function readConfig(reader: RepoReader): Promise<ContentrainConfig | null>\nexport async function readConfig(input: string | RepoReader): Promise<ContentrainConfig | null> {\n if (typeof input === 'string') {\n const raw = await readJson<Partial<ContentrainConfig>>(join(contentrainDir(input), 'config.json'))\n return normaliseConfig(raw)\n }\n const raw = await readJsonViaReader<Partial<ContentrainConfig>>(input, CONFIG_PATH)\n return normaliseConfig(raw)\n}\n\n/**\n * Read `.contentrain/vocabulary.json`. Same dual signature as {@link readConfig}.\n */\nexport function readVocabulary(projectRoot: string): Promise<Vocabulary | null>\nexport function readVocabulary(reader: RepoReader): Promise<Vocabulary | null>\nexport async function readVocabulary(input: string | RepoReader): Promise<Vocabulary | null> {\n if (typeof input === 'string') {\n return readJson<Vocabulary>(join(contentrainDir(input), 'vocabulary.json'))\n }\n return readJsonViaReader<Vocabulary>(input, VOCABULARY_PATH)\n}\n"],"mappings":";;;AAKA,MAAM,cAAc;AACpB,MAAM,kBAAkB;AAExB,eAAe,kBAAqB,QAAoB,MAAiC;AACvF,KAAI;AACF,SAAO,KAAK,MAAM,MAAM,OAAO,SAAS,KAAK,CAAC;SACxC;AACN,SAAO;;;AAIX,SAAS,gBAAgB,KAAkE;AACzF,KAAI,CAAC,IAAK,QAAO;AACjB,QAAO;EACL,SAAS,IAAI,WAAW;EACxB,OAAO,IAAI,SAAS;EACpB,UAAU,IAAI,YAAY;EAC1B,SAAS;GACP,SAAS,IAAI,SAAS,WAAW;GACjC,WAAW,IAAI,SAAS,aAAa,CAAC,IAAI,SAAS,WAAW,KAAK;GACpE;EACD,SAAS,IAAI,WAAW,EAAE;EAC1B,YAAY,IAAI;EAChB,aAAa,IAAI;EACjB,iBAAiB,IAAI;EACrB,iBAAiB,IAAI;EACrB,kBAAkB,IAAI;EACtB,qBAAqB,IAAI;EAC1B;;AAYH,eAAsB,WAAW,OAA+D;AAC9F,KAAI,OAAO,UAAU,SAEnB,QAAO,gBADK,MAAM,SAAqC,KAAK,eAAe,MAAM,EAAE,cAAc,CAAC,CACvE;AAG7B,QAAO,gBADK,MAAM,kBAA8C,OAAO,YAAY,CACxD;;AAQ7B,eAAsB,eAAe,OAAwD;AAC3F,KAAI,OAAO,UAAU,SACnB,QAAO,SAAqB,KAAK,eAAe,MAAM,EAAE,kBAAkB,CAAC;AAE7E,QAAO,kBAA8B,OAAO,gBAAgB"}
@@ -1,6 +1,6 @@
1
1
  import { n as canonicalStringify } from "./serialization-B1CEzR4H.mjs";
2
2
  import { a as readJson, s as writeJson, t as contentrainDir } from "./fs-DLbVB-Ek.mjs";
3
- import { t as readConfig } from "./config-Lw7vpK9n.mjs";
3
+ import { t as readConfig } from "./config-DlFcTxFG.mjs";
4
4
  import { o as listModels, r as countEntries, s as readModel } from "./model-manager-BhLsUgaB.mjs";
5
5
  import { join } from "node:path";
6
6
  //#region src/core/context.ts
@@ -63,17 +63,30 @@ async function writeContext(projectRoot, operation) {
63
63
  * read surface. GitHubProvider pays an extra round trip per model; the
64
64
  * payoff is a context.json that matches what the local write path emits,
65
65
  * so cross-provider merges stay deterministic.
66
+ *
67
+ * A caller that already knows the model/entry counts (e.g. Studio deriving
68
+ * them from its own index) can pass `opts.stats` to skip that O(models·
69
+ * locales) scan entirely — only `readConfig` remains, for the locale list
70
+ * and the `lastOperation.locale` default. The emitted context.json is
71
+ * byte-identical to the scanned variant for the same logical state.
66
72
  */
67
- async function buildContextChange(reader, operation, source) {
73
+ async function buildContextChange(reader, operation, source, opts) {
68
74
  const config = await readConfig(reader);
69
- const models = await listModels(reader);
70
75
  const locales = config?.locales.supported ?? ["en"];
71
- let totalEntries = null;
72
- try {
73
- const fullModels = await Promise.all(models.map((m) => readModel(reader, m.id)));
74
- totalEntries = (await Promise.all(fullModels.filter((m) => m !== null).map((m) => countEntries(reader, m)))).reduce((acc, c) => acc + c.total, 0);
75
- } catch {
76
- totalEntries = null;
76
+ let modelCount;
77
+ let totalEntries;
78
+ if (opts?.stats) {
79
+ modelCount = opts.stats.models;
80
+ totalEntries = opts.stats.entries;
81
+ } else {
82
+ const models = await listModels(reader);
83
+ modelCount = models.length;
84
+ try {
85
+ const fullModels = await Promise.all(models.map((m) => readModel(reader, m.id)));
86
+ totalEntries = (await Promise.all(fullModels.filter((m) => m !== null).map((m) => countEntries(reader, m)))).reduce((acc, c) => acc + c.total, 0);
87
+ } catch {
88
+ totalEntries = null;
89
+ }
77
90
  }
78
91
  return {
79
92
  path: CONTEXT_PATH,
@@ -88,7 +101,7 @@ async function buildContextChange(reader, operation, source) {
88
101
  source: resolveSource(source)
89
102
  },
90
103
  stats: {
91
- models: models.length,
104
+ models: modelCount,
92
105
  entries: totalEntries,
93
106
  locales,
94
107
  lastSync: (/* @__PURE__ */ new Date()).toISOString()
@@ -99,4 +112,4 @@ async function buildContextChange(reader, operation, source) {
99
112
  //#endregion
100
113
  export { readContext as n, writeContext as r, buildContextChange as t };
101
114
 
102
- //# sourceMappingURL=context-DS40WOdw.mjs.map
115
+ //# sourceMappingURL=context-CYN__o3Q.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-CYN__o3Q.mjs","names":[],"sources":["../src/core/context.ts"],"sourcesContent":["import type { ContextJson, ContextSource } from '@contentrain/types'\nimport { join } from 'node:path'\nimport { contentrainDir, readJson, writeJson } from '../util/fs.js'\nimport type { FileChange, RepoReader } from './contracts/index.js'\nimport { canonicalStringify } from './serialization/index.js'\nimport { listModels, readModel, countEntries } from './model-manager.js'\nimport { readConfig } from './config.js'\n\nconst CONTEXT_PATH = '.contentrain/context.json'\n\n/**\n * Pre-computed stats a caller can pass to {@link buildContextChange} to skip\n * the model/entry scan. `entries` is nullable — a `null` is dropped from the\n * emitted context.json by {@link canonicalStringify}, exactly as a failed\n * scan is today.\n */\nexport interface ContextStats {\n models: number\n entries: number | null\n}\n\n/**\n * Read the committed `.contentrain/context.json` payload written by the\n * most recent content or model operation. Returns `null` when the file\n * does not exist (fresh project, or reader lookup failure).\n *\n * Dual signature — the local flow passes a `projectRoot` string, remote\n * flows (GitHubProvider, GitLabProvider, any custom `RepoReader`) pass\n * the reader directly so `contentrain_status` over HTTP can still\n * report the last operation + stats.\n */\nexport function readContext(projectRoot: string): Promise<ContextJson | null>\nexport function readContext(reader: RepoReader): Promise<ContextJson | null>\nexport async function readContext(input: string | RepoReader): Promise<ContextJson | null> {\n if (typeof input === 'string') {\n return readJson<ContextJson>(join(contentrainDir(input), 'context.json'))\n }\n try {\n const raw = await input.readFile(CONTEXT_PATH)\n return JSON.parse(raw) as ContextJson\n } catch {\n return null\n }\n}\n\nfunction resolveSource(explicit?: ContextSource): ContextSource {\n if (explicit) return explicit\n return process.env['CONTENTRAIN_SOURCE'] === 'mcp-studio' ? 'mcp-studio' : 'mcp-local'\n}\n\nasync function computeEntriesCount(projectRoot: string): Promise<number | null> {\n try {\n const models = await listModels(projectRoot)\n const fullModels = await Promise.all(models.map(m => readModel(projectRoot, m.id)))\n const counts = await Promise.all(\n fullModels\n .filter((m): m is NonNullable<typeof m> => m !== null)\n .map(m => countEntries(projectRoot, m)),\n )\n return counts.reduce((acc, c) => acc + c.total, 0)\n } catch {\n return null\n }\n}\n\nexport async function writeContext(\n projectRoot: string,\n operation: { tool: string, model: string, locale?: string, entries?: string[] },\n): Promise<void> {\n const models = await listModels(projectRoot)\n const config = await readConfig(projectRoot)\n const locales = config?.locales.supported ?? ['en']\n const totalEntries = await computeEntriesCount(projectRoot)\n const source = resolveSource()\n\n const context: ContextJson = {\n version: '1',\n lastOperation: {\n tool: operation.tool,\n model: operation.model,\n locale: operation.locale ?? config?.locales.default ?? 'en',\n entries: operation.entries,\n timestamp: new Date().toISOString(),\n source,\n },\n stats: {\n models: models.length,\n entries: totalEntries as number,\n locales,\n lastSync: new Date().toISOString(),\n },\n }\n\n await writeJson(join(contentrainDir(projectRoot), 'context.json'), context)\n}\n\n/**\n * Build a FileChange for `.contentrain/context.json` that remote providers\n * (GitHubProvider over HTTP, for example) can slot into their plan. The\n * local write path still uses {@link writeContext} directly because its\n * transaction layer writes into the post-apply worktree with real stats.\n *\n * Remote providers (Phase 5.5+) now also get accurate entry counts: the\n * reader-based {@link countEntries} walks each model over the provider's\n * read surface. GitHubProvider pays an extra round trip per model; the\n * payoff is a context.json that matches what the local write path emits,\n * so cross-provider merges stay deterministic.\n *\n * A caller that already knows the model/entry counts (e.g. Studio deriving\n * them from its own index) can pass `opts.stats` to skip that O(models·\n * locales) scan entirely — only `readConfig` remains, for the locale list\n * and the `lastOperation.locale` default. The emitted context.json is\n * byte-identical to the scanned variant for the same logical state.\n */\nexport async function buildContextChange(\n reader: RepoReader,\n operation: { tool: string, model: string, locale?: string, entries?: string[] },\n source?: ContextSource,\n opts?: { stats?: ContextStats },\n): Promise<FileChange> {\n const config = await readConfig(reader)\n const locales = config?.locales.supported ?? ['en']\n\n let modelCount: number\n let totalEntries: number | null\n if (opts?.stats) {\n modelCount = opts.stats.models\n totalEntries = opts.stats.entries\n } else {\n const models = await listModels(reader)\n modelCount = models.length\n try {\n const fullModels = await Promise.all(models.map(m => readModel(reader, m.id)))\n const counts = await Promise.all(\n fullModels\n .filter((m): m is NonNullable<typeof m> => m !== null)\n .map(m => countEntries(reader, m)),\n )\n totalEntries = counts.reduce((acc, c) => acc + c.total, 0)\n } catch {\n totalEntries = null\n }\n }\n\n const context: ContextJson = {\n version: '1',\n lastOperation: {\n tool: operation.tool,\n model: operation.model,\n locale: operation.locale ?? config?.locales.default ?? 'en',\n entries: operation.entries,\n timestamp: new Date().toISOString(),\n source: resolveSource(source),\n },\n stats: {\n models: modelCount,\n entries: totalEntries as number,\n locales,\n lastSync: new Date().toISOString(),\n },\n }\n\n return {\n path: CONTEXT_PATH,\n content: canonicalStringify(context),\n }\n}\n"],"mappings":";;;;;;AAQA,MAAM,eAAe;AAyBrB,eAAsB,YAAY,OAAyD;AACzF,KAAI,OAAO,UAAU,SACnB,QAAO,SAAsB,KAAK,eAAe,MAAM,EAAE,eAAe,CAAC;AAE3E,KAAI;EACF,MAAM,MAAM,MAAM,MAAM,SAAS,aAAa;AAC9C,SAAO,KAAK,MAAM,IAAI;SAChB;AACN,SAAO;;;AAIX,SAAS,cAAc,UAAyC;AAC9D,KAAI,SAAU,QAAO;AACrB,QAAO,QAAQ,IAAI,0BAA0B,eAAe,eAAe;;AAG7E,eAAe,oBAAoB,aAA6C;AAC9E,KAAI;EACF,MAAM,SAAS,MAAM,WAAW,YAAY;EAC5C,MAAM,aAAa,MAAM,QAAQ,IAAI,OAAO,KAAI,MAAK,UAAU,aAAa,EAAE,GAAG,CAAC,CAAC;AAMnF,UALe,MAAM,QAAQ,IAC3B,WACG,QAAQ,MAAkC,MAAM,KAAK,CACrD,KAAI,MAAK,aAAa,aAAa,EAAE,CAAC,CAC1C,EACa,QAAQ,KAAK,MAAM,MAAM,EAAE,OAAO,EAAE;SAC5C;AACN,SAAO;;;AAIX,eAAsB,aACpB,aACA,WACe;CACf,MAAM,SAAS,MAAM,WAAW,YAAY;CAC5C,MAAM,SAAS,MAAM,WAAW,YAAY;CAC5C,MAAM,UAAU,QAAQ,QAAQ,aAAa,CAAC,KAAK;CACnD,MAAM,eAAe,MAAM,oBAAoB,YAAY;CAC3D,MAAM,SAAS,eAAe;CAE9B,MAAM,UAAuB;EAC3B,SAAS;EACT,eAAe;GACb,MAAM,UAAU;GAChB,OAAO,UAAU;GACjB,QAAQ,UAAU,UAAU,QAAQ,QAAQ,WAAW;GACvD,SAAS,UAAU;GACnB,4BAAW,IAAI,MAAM,EAAC,aAAa;GACnC;GACD;EACD,OAAO;GACL,QAAQ,OAAO;GACf,SAAS;GACT;GACA,2BAAU,IAAI,MAAM,EAAC,aAAa;GACnC;EACF;AAED,OAAM,UAAU,KAAK,eAAe,YAAY,EAAE,eAAe,EAAE,QAAQ;;;;;;;;;;;;;;;;;;;;AAqB7E,eAAsB,mBACpB,QACA,WACA,QACA,MACqB;CACrB,MAAM,SAAS,MAAM,WAAW,OAAO;CACvC,MAAM,UAAU,QAAQ,QAAQ,aAAa,CAAC,KAAK;CAEnD,IAAI;CACJ,IAAI;AACJ,KAAI,MAAM,OAAO;AACf,eAAa,KAAK,MAAM;AACxB,iBAAe,KAAK,MAAM;QACrB;EACL,MAAM,SAAS,MAAM,WAAW,OAAO;AACvC,eAAa,OAAO;AACpB,MAAI;GACF,MAAM,aAAa,MAAM,QAAQ,IAAI,OAAO,KAAI,MAAK,UAAU,QAAQ,EAAE,GAAG,CAAC,CAAC;AAM9E,mBALe,MAAM,QAAQ,IAC3B,WACG,QAAQ,MAAkC,MAAM,KAAK,CACrD,KAAI,MAAK,aAAa,QAAQ,EAAE,CAAC,CACrC,EACqB,QAAQ,KAAK,MAAM,MAAM,EAAE,OAAO,EAAE;UACpD;AACN,kBAAe;;;AAsBnB,QAAO;EACL,MAAM;EACN,SAAS,mBApBkB;GAC3B,SAAS;GACT,eAAe;IACb,MAAM,UAAU;IAChB,OAAO,UAAU;IACjB,QAAQ,UAAU,UAAU,QAAQ,QAAQ,WAAW;IACvD,SAAS,UAAU;IACnB,4BAAW,IAAI,MAAM,EAAC,aAAa;IACnC,QAAQ,cAAc,OAAO;IAC9B;GACD,OAAO;IACL,QAAQ;IACR,SAAS;IACT;IACA,2BAAU,IAAI,MAAM,EAAC,aAAa;IACnC;GACF,CAIqC;EACrC"}
@@ -1,11 +1,11 @@
1
1
  import "../serialization-B1CEzR4H.mjs";
2
2
  import "../fs-DLbVB-Ek.mjs";
3
- import "../config-Lw7vpK9n.mjs";
3
+ import "../config-DlFcTxFG.mjs";
4
4
  import "../model-manager-BhLsUgaB.mjs";
5
5
  import "../meta-manager-C900rEyG.mjs";
6
- import "../context-DS40WOdw.mjs";
7
- import "../id-CIO9s0x_.mjs";
8
- import "../transaction-LiTjan9Q.mjs";
9
- import "../branch-lifecycle-DjX3MXNG.mjs";
10
- import { a as detectFileFramework, c as validatePatchPath, i as checkSyntax, n as applyExtract, o as replaceInLine, r as applyReuse, s as validateFrameworkExpression, t as PATCHABLE_EXTENSIONS } from "../apply-manager-BJ3nKEls.mjs";
6
+ import "../context-CYN__o3Q.mjs";
7
+ import "../branch-lifecycle-D7tXOE4f.mjs";
8
+ import "../id-DV_T9Ic8.mjs";
9
+ import "../transaction-qliKvBRL.mjs";
10
+ import { a as detectFileFramework, c as validatePatchPath, i as checkSyntax, n as applyExtract, o as replaceInLine, r as applyReuse, s as validateFrameworkExpression, t as PATCHABLE_EXTENSIONS } from "../apply-manager-BRmB27H5.mjs";
11
11
  export { PATCHABLE_EXTENSIONS, applyExtract, applyReuse, checkSyntax, detectFileFramework, replaceInLine, validateFrameworkExpression, validatePatchPath };
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.mts","names":[],"sources":["../../src/core/config.ts"],"mappings":";;;;;;AA0CA;;;;;iBAAgB,UAAA,CAAW,WAAA,WAAsB,OAAA,CAAQ,iBAAA;AAAA,iBACzC,UAAA,CAAW,MAAA,EAAQ,UAAA,GAAa,OAAA,CAAQ,iBAAA;;;AAAxD;iBAagB,cAAA,CAAe,WAAA,WAAsB,OAAA,CAAQ,UAAA;AAAA,iBAC7C,cAAA,CAAe,MAAA,EAAQ,UAAA,GAAa,OAAA,CAAQ,UAAA"}
1
+ {"version":3,"file":"config.d.mts","names":[],"sources":["../../src/core/config.ts"],"mappings":";;;;;;AA2CA;;;;;iBAAgB,UAAA,CAAW,WAAA,WAAsB,OAAA,CAAQ,iBAAA;AAAA,iBACzC,UAAA,CAAW,MAAA,EAAQ,UAAA,GAAa,OAAA,CAAQ,iBAAA;;;AAAxD;iBAagB,cAAA,CAAe,WAAA,WAAsB,OAAA,CAAQ,UAAA;AAAA,iBAC7C,cAAA,CAAe,MAAA,EAAQ,UAAA,GAAa,OAAA,CAAQ,UAAA"}
@@ -1,3 +1,3 @@
1
1
  import "../fs-DLbVB-Ek.mjs";
2
- import { n as readVocabulary, t as readConfig } from "../config-Lw7vpK9n.mjs";
2
+ import { n as readVocabulary, t as readConfig } from "../config-DlFcTxFG.mjs";
3
3
  export { readConfig, readVocabulary };