@contentrain/mcp 1.11.0 → 2.0.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 (40) hide show
  1. package/dist/{apply-manager-tRqDkzBV.mjs → apply-manager-a5l93F4O.mjs} +5 -5
  2. package/dist/apply-manager-a5l93F4O.mjs.map +1 -0
  3. package/dist/content-manager-BgxF3dHu.d.mts.map +1 -1
  4. package/dist/{context-CrS-IvVm.mjs → context-DjglDPvj.mjs} +2 -2
  5. package/dist/{context-CrS-IvVm.mjs.map → context-DjglDPvj.mjs.map} +1 -1
  6. package/dist/core/apply-manager.mjs +4 -4
  7. package/dist/core/content-manager.mjs +1 -1
  8. package/dist/core/context.mjs +2 -2
  9. package/dist/core/doctor.mjs +2 -2
  10. package/dist/core/model-manager.d.mts +13 -4
  11. package/dist/core/model-manager.d.mts.map +1 -1
  12. package/dist/core/model-manager.mjs +1 -1
  13. package/dist/core/ops/index.mjs +1 -1
  14. package/dist/core/validator/index.d.mts.map +1 -1
  15. package/dist/core/validator/index.mjs +2 -2
  16. package/dist/{doctor-BRPXxKhx.mjs → doctor-pw8EWRFR.mjs} +2 -2
  17. package/dist/{doctor-BRPXxKhx.mjs.map → doctor-pw8EWRFR.mjs.map} +1 -1
  18. package/dist/git/transaction.mjs +3 -3
  19. package/dist/index.mjs +9 -9
  20. package/dist/{local-DqmDeNxI.mjs → local-EJTxd5MR.mjs} +4 -4
  21. package/dist/{local-DqmDeNxI.mjs.map → local-EJTxd5MR.mjs.map} +1 -1
  22. package/dist/{model-manager-SiesTJrS.mjs → model-manager-DP2CZiMT.mjs} +92 -9
  23. package/dist/model-manager-DP2CZiMT.mjs.map +1 -0
  24. package/dist/{ops-B422KP_S.mjs → ops-CGKIKwDc.mjs} +2 -1
  25. package/dist/ops-CGKIKwDc.mjs.map +1 -0
  26. package/dist/providers/local/index.mjs +5 -5
  27. package/dist/server/http/index.mjs +9 -9
  28. package/dist/{server-DSEl2wzT.mjs → server-DKbnhtvV.mjs} +62 -18
  29. package/dist/server-DKbnhtvV.mjs.map +1 -0
  30. package/dist/server.mjs +9 -9
  31. package/dist/{transaction-BHOsiEn1.mjs → transaction-DC_dNON9.mjs} +2 -2
  32. package/dist/{transaction-BHOsiEn1.mjs.map → transaction-DC_dNON9.mjs.map} +1 -1
  33. package/dist/{validator-Ck0puppQ.mjs → validator-D5VncJ8M.mjs} +100 -82
  34. package/dist/validator-D5VncJ8M.mjs.map +1 -0
  35. package/package.json +2 -2
  36. package/dist/apply-manager-tRqDkzBV.mjs.map +0 -1
  37. package/dist/model-manager-SiesTJrS.mjs.map +0 -1
  38. package/dist/ops-B422KP_S.mjs.map +0 -1
  39. package/dist/server-DSEl2wzT.mjs.map +0 -1
  40. package/dist/validator-Ck0puppQ.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"transaction-BHOsiEn1.mjs","names":["removeFile"],"sources":["../src/providers/local/migration.ts","../src/git/transaction.ts"],"sourcesContent":["import type { SimpleGit } from 'simple-git'\n\n/**\n * Migration: the first MCP release used `contentrain/*` feature branches.\n * Once we introduced the singleton `contentrain` branch (tracking the\n * committed content state), those old feature branches became a ref-\n * namespace conflict — git cannot hold both `contentrain` (a leaf ref)\n * and `contentrain/foo` (implying a directory) simultaneously.\n *\n * `migrateLegacyBranches` removes the old-prefix branches so the\n * singleton `contentrain` ref can be created. It is idempotent and\n * safe to call before every `ensureContentBranch` run.\n *\n * Strategy:\n * 1. Delete merged `contentrain/*` branches first (`-d`). Their commits\n * are already on the base branch via the old auto-merge flow.\n * 2. Force-delete whatever remains (`-D`). Any unmerged leftover is\n * from an abandoned or partially-committed legacy branch — content\n * on `main` always wins, and the singleton `contentrain` branch is\n * about to be created from `main`/`baseBranch` anyway.\n *\n * Returns the number of branches that were deleted. Callers may log it;\n * the git transaction layer does not need the count for correctness.\n */\nexport async function migrateLegacyBranches(\n git: SimpleGit,\n baseBranch: string,\n): Promise<number> {\n const branches = await git.branchLocal()\n const oldPrefixBranches = branches.all.filter(b => b.startsWith('contentrain/'))\n if (oldPrefixBranches.length === 0) return 0\n\n let deleted = 0\n\n // 1) Delete merged legacy branches first — the safe path.\n let mergedLegacy: string[] = []\n try {\n const mergedOutput = await git.raw(['branch', '--merged', baseBranch])\n mergedLegacy = mergedOutput.split('\\n')\n .map(b => b.trim().replace(/^\\*\\s*/, ''))\n .filter(b => b.startsWith('contentrain/'))\n } catch {\n // `branch --merged` fails before baseBranch exists — fall through.\n }\n\n for (const b of mergedLegacy) {\n try {\n await git.raw(['branch', '-d', b])\n deleted++\n } catch {\n // Branch may be protected or already gone — safe to skip.\n }\n }\n\n // 2) Force-delete any unmerged legacy branches still present.\n const remaining = (await git.branchLocal()).all.filter(b => b.startsWith('contentrain/'))\n for (const b of remaining) {\n try {\n await git.raw(['branch', '-D', b])\n deleted++\n } catch {\n // Skip — best-effort cleanup.\n }\n }\n\n return deleted\n}\n","import { simpleGit, type SimpleGit } from 'simple-git'\nimport { join } from 'node:path'\nimport { rm as removeFile } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { randomUUID } from 'node:crypto'\nimport { readConfig } from '../core/config.js'\nimport { writeContext } from '../core/context.js'\nimport { deleteRemoteBranch, type RemoteDeleteResult } from './branch-lifecycle.js'\nimport { authorConfig } from './identity.js'\nimport { branchTimestamp } from '../util/id.js'\nimport { migrateLegacyBranches } from '../providers/local/migration.js'\nimport type { SyncResult, WorkflowMode } from '@contentrain/types'\nimport { CONTENTRAIN_BRANCH } from '@contentrain/types'\n\nexport interface ContextUpdate {\n tool: string\n model: string\n locale?: string\n entries?: string[]\n}\n\nexport interface GitTransaction {\n worktree: string\n branch: string\n write(callback: (worktreePath: string) => Promise<void>): Promise<void>\n commit(message: string, contextUpdate?: ContextUpdate): Promise<string>\n complete(): Promise<{ action: 'auto-merged' | 'pending-review'; commit: string; sync?: SyncResult; warning?: string }>\n cleanup(): Promise<void>\n}\n\nexport async function ensureContentBranch(projectRoot: string): Promise<void> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n\n // Check if contentrain branch exists locally\n const branches = await git.branchLocal()\n if (branches.all.includes(CONTENTRAIN_BRANCH)) return\n\n // Detect base branch\n const baseBranch = config?.repository?.default_branch\n || (await git.raw(['branch', '--show-current'])).trim()\n || 'main'\n\n // Clean up legacy `contentrain/*` feature branches so the singleton\n // `contentrain` ref can be created. Idempotent — safe to call even\n // when no legacy branches exist.\n await migrateLegacyBranches(git, baseBranch)\n\n // Create contentrain branch from base\n await git.branch([CONTENTRAIN_BRANCH, baseBranch])\n\n // Push to remote if exists\n const remoteName = process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n try {\n const remotes = await git.getRemotes()\n if (remotes.some(r => r.name === remoteName)) {\n await git.push(['-u', remoteName, CONTENTRAIN_BRANCH])\n }\n } catch {\n // Remote push is best-effort\n }\n}\n\nasync function selectiveSync(\n projectRoot: string,\n _worktreePath: string,\n contentrainTip: string,\n _previousBaseRef?: string,\n dirtyFilesBeforeUpdate?: Set<string>,\n): Promise<SyncResult> {\n const git = simpleGit(projectRoot)\n const synced: string[] = []\n const skipped: string[] = []\n\n // Use git plumbing to find ALL files that differ between old and new commits.\n // diff-tree is fast and ignores working tree / index state entirely.\n // Not limited to .contentrain/ — some ops also modify .gitignore, etc.\n const compareRef = _previousBaseRef ?? contentrainTip\n let changedFiles: string[] = []\n try {\n const diffOutput = await git.raw([\n 'diff-tree', '--name-only', '-r', '--no-commit-id',\n compareRef, contentrainTip,\n ])\n changedFiles = diffOutput.split('\\n').filter(f => f.trim().length > 0)\n } catch {\n // Fallback: list .contentrain/ files from the contentrainTip commit\n try {\n const lsOutput = await git.raw(['ls-tree', '-r', '--name-only', contentrainTip, '.contentrain/'])\n changedFiles = lsOutput.split('\\n').filter(f => f.trim().length > 0)\n } catch {\n return { synced, skipped }\n }\n }\n\n if (changedFiles.length === 0) return { synced, skipped }\n\n // Use pre-captured dirty files (before update-ref) to avoid false positives.\n // After update-ref, files appear as \"modified\" in status even though the developer\n // didn't touch them. We use the pre-update state to know what was truly dirty.\n const dirtyFiles = dirtyFilesBeforeUpdate ?? new Set<string>()\n\n // Which changed files still exist in contentrainTip (HEAD after update-ref)?\n // ONE `ls-tree` over the changed paths lists exactly the survivors, instead\n // of a `cat-file -e` spawn per file. Falls back to per-file probing if\n // ls-tree fails so behavior is preserved on any edge.\n const filesInTip = new Set<string>()\n try {\n const lsOutput = await git.raw(['ls-tree', '-r', '--name-only', contentrainTip, '--', ...changedFiles])\n for (const f of lsOutput.split('\\n')) {\n const trimmed = f.trim()\n if (trimmed) filesInTip.add(trimmed)\n }\n } catch {\n for (const file of changedFiles) {\n try {\n await git.raw(['cat-file', '-e', `${contentrainTip}:${file}`])\n filesInTip.add(file)\n } catch {\n // File does not exist in tip (was deleted)\n }\n }\n }\n\n // Partition: dirty developer files are skipped; survivors get checked out\n // from HEAD; the rest were deleted in the new HEAD and are removed on disk.\n const toCheckout: string[] = []\n const toRemove: string[] = []\n for (const file of changedFiles) {\n if (dirtyFiles.has(file)) skipped.push(file)\n else if (filesInTip.has(file)) toCheckout.push(file)\n else toRemove.push(file)\n }\n\n // ONE `git checkout HEAD -- f1 f2 …` restores every clean survivor at once.\n // On failure, fall back to per-file so a single unresolvable path still\n // yields precise skip accounting (dirty files were already excluded).\n if (toCheckout.length > 0) {\n try {\n await git.checkout(['HEAD', '--', ...toCheckout])\n synced.push(...toCheckout)\n } catch {\n for (const file of toCheckout) {\n try {\n await git.checkout(['HEAD', '--', file])\n synced.push(file)\n } catch {\n skipped.push(file)\n }\n }\n }\n }\n\n // Deletions are working-tree fs removals — no git spawn, safe to parallelize.\n await Promise.all(toRemove.map(async (file) => {\n try {\n await removeFile(join(projectRoot, file), { force: true })\n synced.push(file)\n } catch {\n skipped.push(file)\n }\n }))\n\n const warning = skipped.length > 0\n ? `${skipped.length} file(s) skipped due to local changes: ${skipped.join(', ')}. Commit your changes, then run: git checkout HEAD -- ${skipped.join(' ')}`\n : undefined\n\n return { synced, skipped, warning }\n}\n\nexport async function createTransaction(\n projectRoot: string,\n branchName: string,\n options?: { workflowOverride?: WorkflowMode },\n): Promise<GitTransaction> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n const workflow = options?.workflowOverride ?? config?.workflow ?? 'auto-merge'\n\n const remoteName = process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n\n // Detect base branch + current branch + remote in ONE batch\n // (reduces subprocess spawns from 4 to 2)\n let baseBranch = process.env['CONTENTRAIN_BRANCH'] ?? config?.repository?.default_branch ?? ''\n let currentBranch = ''\n let hasRemote = false\n\n const [branchResult, remotes] = await Promise.all([\n git.raw(['branch', '--show-current']).catch(() => ''),\n git.getRemotes().catch(() => []),\n ])\n currentBranch = branchResult.trim()\n if (!baseBranch) baseBranch = currentBranch || 'main'\n hasRemote = (remotes as { name: string }[]).some(r => r.name === remoteName)\n\n // Check if developer is on contentrain branch\n if (currentBranch === CONTENTRAIN_BRANCH) {\n throw Object.assign(new Error(\n `The '${CONTENTRAIN_BRANCH}' branch is checked out in your working directory. `\n + `Contentrain manages this branch automatically. `\n + `Switch to your working branch and retry.`,\n ), {\n code: 'CONTENT_BRANCH_CHECKED_OUT',\n agent_hint: 'Ask the developer to switch to their working branch (e.g., main or a feature branch), then retry the operation.',\n developer_action: `git checkout ${baseBranch}`,\n })\n }\n\n // Ensure contentrain branch exists (with migration for old contentrain/* branches)\n await ensureContentBranch(projectRoot)\n\n // Fetch latest from remote (parallel fetch for both branches)\n if (hasRemote) {\n await Promise.all([\n git.fetch(remoteName, baseBranch).catch(() => {}),\n git.fetch(remoteName, CONTENTRAIN_BRANCH).catch(() => {}),\n ])\n }\n\n const worktreePath = join(tmpdir(), `cr-${randomUUID()}`)\n const branch = branchName\n\n // Create worktree on contentrain branch\n await git.raw(['worktree', 'add', worktreePath, CONTENTRAIN_BRANCH])\n\n // Commit identity comes from `-c user.*` config (see authorConfig) — passed\n // as args, never via `.env()`, so simple-git's block-unsafe guard is never\n // triggered by an inherited EDITOR/GIT_ASKPASS/etc.\n const wtGit = simpleGit(worktreePath, { config: authorConfig() })\n\n // Sync contentrain with base branch (bring main changes into contentrain)\n try {\n await wtGit.merge([baseBranch, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* not in merge state */ }\n if (hasRemote) {\n try {\n await wtGit.merge([`${remoteName}/${baseBranch}`, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* ignore */ }\n }\n }\n }\n\n // Sync with remote contentrain if exists\n if (hasRemote) {\n try {\n await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* ignore */ }\n }\n }\n\n // Create feature branch from contentrain\n await wtGit.checkout(['-b', branch])\n\n let commitHash = ''\n let pendingReview = false\n let savedContextUpdate: ContextUpdate | undefined\n\n return {\n worktree: worktreePath,\n branch,\n\n async write(callback) {\n await callback(worktreePath)\n },\n\n async commit(message, contextUpdate?) {\n // context.json is intentionally NOT committed on the feature branch — it\n // is regenerated on the contentrain branch after the merge (see\n // complete()). Committing it per-branch caused cross-branch merge\n // conflicts on a single mutable file. `--no-verify` keeps the repo's\n // commit-msg / pre-commit hooks (commitlint, lefthook, husky) from\n // rejecting these machine-generated infra commits.\n savedContextUpdate = contextUpdate\n await wtGit.add('.')\n const result = await wtGit.commit(message, { '--allow-empty': null, '--no-verify': null })\n commitHash = result.commit || ''\n return commitHash\n },\n\n async complete() {\n if (workflow === 'review') {\n if (hasRemote) {\n await git.push(remoteName, branch)\n }\n // Pending-review branches must survive for a later contentrain_merge.\n pendingReview = true\n return { action: 'pending-review', commit: commitHash }\n }\n\n // auto-merge: merge feature branch into contentrain, then advance base\n\n // Switch to contentrain branch in worktree\n await wtGit.checkout(CONTENTRAIN_BRANCH)\n\n // Merge feature branch into contentrain\n try {\n await wtGit.merge([branch, '--no-edit'])\n } catch {\n try {\n await wtGit.merge(['--abort'])\n } catch { /* not in merge state */ }\n throw Object.assign(new Error(\n `Merge conflict when merging branch \"${branch}\" into \"${CONTENTRAIN_BRANCH}\". `\n + `The branch still exists with your changes intact. `\n + `Resolve the conflict manually, or delete the branch and retry.`,\n ), {\n code: 'CONTENT_BRANCH_MERGE_CONFLICT',\n agent_hint: 'The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.',\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branch}`,\n })\n }\n\n // Regenerate context.json on the contentrain branch (post-merge,\n // single-threaded) and fold it into the tip before advancing the base.\n if (savedContextUpdate) {\n await regenerateContextOnContentrain(wtGit, worktreePath, savedContextUpdate)\n }\n\n // Get contentrain tip + old base ref + dirty files in parallel\n const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([\n wtGit.raw(['rev-parse', 'HEAD']).then(s => s.trim()),\n git.raw(['rev-parse', baseBranch]).then(s => s.trim()),\n git.status(),\n ])\n const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map(f => f.path))\n\n // Verify fast-forward: baseBranch must be an ancestor of contentrainTip\n // (guaranteed by the merge above, but verify for safety).\n // `rev-list --count` instead of `merge-base --is-ancestor`: the latter\n // signals via exit code with empty stderr, which simple-git reports as\n // success — the guard would silently pass on divergence.\n if (!(await isAncestor(git, previousBaseRef, contentrainTip))) {\n throw Object.assign(new Error(\n `Cannot fast-forward \"${baseBranch}\" to contentrain tip. `\n + `The base branch has diverged. Merge \"${baseBranch}\" into \"${CONTENTRAIN_BRANCH}\" first.`,\n ), {\n code: 'BASE_UPDATE_FAILED',\n agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`,\n })\n }\n\n // Advance base branch to contentrain tip via update-ref\n await git.raw(['update-ref', `refs/heads/${baseBranch}`, contentrainTip])\n\n // Refresh index to match new HEAD.\n // update-ref moves the branch pointer but leaves the index stale.\n // read-tree updates the index to match HEAD without touching the working tree.\n try {\n await git.raw(['read-tree', 'HEAD'])\n } catch {\n // fallback: try reset for older git versions\n try { await git.raw(['reset', 'HEAD']) } catch { /* ignore */ }\n }\n\n // Selective sync: copy .contentrain/ files to developer's working tree\n const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate)\n\n // Push to remote (best-effort with retry)\n if (hasRemote) {\n // Push contentrain branch\n try {\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n // Retry: fetch, merge, push\n try {\n await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH)\n await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, '--no-edit'])\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n // Push failed after retry — continue, local state is fine\n }\n }\n\n // Push base branch\n try {\n await git.push(remoteName, baseBranch)\n } catch {\n // push may fail, local merge succeeded\n }\n }\n\n return {\n action: 'auto-merged' as const,\n commit: commitHash,\n sync,\n ...(sync.warning ? { warning: sync.warning } : {}),\n }\n },\n\n async cleanup() {\n try {\n await git.raw(['worktree', 'remove', worktreePath, '--force'])\n } catch {\n // worktree may already be cleaned up\n }\n // Prune the feature branch unless it is a pending-review branch that must\n // survive for a later contentrain_merge. Auto-merged branches (already in\n // contentrain) and failed/empty branches are both safe to delete, so\n // failed saves and merged saves no longer leak dangling cr/* refs.\n if (!pendingReview) {\n await safeDeleteBranch(git, branch)\n }\n },\n }\n}\n\nexport async function mergeBranch(\n projectRoot: string,\n branchName: string,\n): Promise<{ action: 'merged'; commit: string; sync: SyncResult; remote?: RemoteDeleteResult }> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n const remoteName = process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n\n // Detect base branch\n const baseBranch = process.env['CONTENTRAIN_BRANCH']\n ?? config?.repository?.default_branch\n ?? ((await git.raw(['branch', '--show-current'])).trim() || 'main')\n\n // Ensure contentrain branch exists\n await ensureContentBranch(projectRoot)\n\n // Check remote\n let hasRemote = false\n try {\n const remotes = await git.getRemotes()\n hasRemote = remotes.some(r => r.name === remoteName)\n } catch {\n hasRemote = false\n }\n\n // Create temp worktree on contentrain branch\n const worktreePath = join(tmpdir(), `cr-merge-${randomUUID()}`)\n await git.raw(['worktree', 'add', worktreePath, CONTENTRAIN_BRANCH])\n\n // Commit identity from `-c user.*` config (see authorConfig) — guard-safe,\n // no `.env()` spread.\n const wtGit = simpleGit(worktreePath, { config: authorConfig() })\n\n try {\n // Merge the feature branch into contentrain\n try {\n await wtGit.merge([branchName, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* not in merge state */ }\n throw Object.assign(new Error(\n `Merge conflict when merging branch \"${branchName}\" into \"${CONTENTRAIN_BRANCH}\". `\n + `The branch still exists with your changes intact. `\n + `Resolve the conflict manually, or delete the branch and retry.`,\n ), {\n code: 'CONTENT_BRANCH_MERGE_CONFLICT',\n agent_hint: 'The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.',\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branchName}`,\n })\n }\n\n // Regenerate context.json on contentrain post-merge (deterministic,\n // single-threaded) so review-mode branches — which carry no context.json —\n // still produce up-to-date stats once landed.\n await regenerateContextOnContentrain(wtGit, worktreePath, { tool: 'contentrain_merge', model: '*' })\n\n // Get contentrain tip + old base ref + dirty files in parallel\n const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([\n wtGit.raw(['rev-parse', 'HEAD']).then(s => s.trim()),\n git.raw(['rev-parse', baseBranch]).then(s => s.trim()),\n git.status(),\n ])\n const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map(f => f.path))\n\n // Verify fast-forward: baseBranch must be an ancestor of contentrainTip.\n // (See complete() — merge-base --is-ancestor is unusable via simple-git.)\n if (!(await isAncestor(git, previousBaseRef, contentrainTip))) {\n throw Object.assign(new Error(\n `Cannot fast-forward \"${baseBranch}\" to contentrain tip. `\n + `The base branch has diverged. Merge \"${baseBranch}\" into \"${CONTENTRAIN_BRANCH}\" first.`,\n ), {\n code: 'BASE_UPDATE_FAILED',\n agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`,\n })\n }\n\n // Advance base branch to contentrain tip via update-ref\n await git.raw(['update-ref', `refs/heads/${baseBranch}`, contentrainTip])\n\n // Refresh index to match new HEAD\n try {\n await git.raw(['read-tree', 'HEAD'])\n } catch {\n try { await git.raw(['reset', 'HEAD']) } catch { /* ignore */ }\n }\n\n // Selective sync: copy .contentrain/ files to developer's working tree\n const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate)\n\n // Push to remote (best-effort)\n if (hasRemote) {\n try {\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n try {\n await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH)\n await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, '--no-edit'])\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n // Push failed after retry — continue, local state is fine\n }\n }\n\n try {\n await git.push(remoteName, baseBranch)\n } catch {\n // push may fail, local merge succeeded\n }\n }\n\n // Prune the now-merged feature branch so merged cr/* refs don't accumulate.\n await safeDeleteBranch(git, branchName)\n\n // Delete the remote copy too (review-mode branches were pushed on save).\n // Best-effort and config-gated inside the helper: a failure surfaces as\n // `remote.warning`, never as a failed merge.\n let remote: RemoteDeleteResult | undefined\n if (hasRemote) {\n remote = await deleteRemoteBranch(projectRoot, branchName, { config })\n }\n\n return {\n action: 'merged' as const,\n commit: contentrainTip,\n sync,\n ...(remote ? { remote } : {}),\n }\n } finally {\n // Cleanup worktree\n try {\n await git.raw(['worktree', 'remove', worktreePath, '--force'])\n } catch {\n // worktree may already be cleaned up\n }\n }\n}\n\nexport function buildBranchName(scope: string, target: string, locale?: string): string {\n const ts = branchTimestamp()\n const parts = ['cr', scope, target]\n if (locale) parts.push(locale)\n parts.push(ts)\n return parts.join('/')\n}\n\n/**\n * True when `ancestor` is an ancestor of (or equal to) `descendant`.\n * Implemented with `rev-list --count` because `merge-base --is-ancestor`\n * signals via exit code with empty stderr — simple-git reports that as\n * success, so it cannot express a negative verdict.\n */\nasync function isAncestor(git: SimpleGit, ancestor: string, descendant: string): Promise<boolean> {\n try {\n const count = Number((await git.raw(['rev-list', '--count', ancestor, `^${descendant}`])).trim())\n return count === 0\n } catch {\n return false\n }\n}\n\n/**\n * Force-delete a local branch, swallowing all errors. Never deletes the\n * singleton `contentrain` branch. Used to prune feature branches after they\n * are merged (auto-merge / contentrain_merge) or when a transaction fails\n * before completing — so failed/merged `cr/*` refs do not accumulate.\n */\nasync function safeDeleteBranch(git: SimpleGit, branch: string): Promise<void> {\n if (!branch || branch === CONTENTRAIN_BRANCH) return\n try {\n await git.raw(['branch', '-D', branch])\n } catch {\n // Branch may not exist, be checked out, or already be deleted — ignore.\n }\n}\n\n/**\n * Regenerate `.contentrain/context.json` deterministically inside a worktree\n * that is currently on the `contentrain` branch, then commit it (hooks\n * bypassed). Called AFTER a feature branch is merged so context.json is only\n * ever written on `contentrain`, single-threaded — eliminating the per-branch\n * merge conflicts that came from committing it on every feature branch.\n */\nasync function regenerateContextOnContentrain(\n wtGit: SimpleGit,\n worktreePath: string,\n contextUpdate: ContextUpdate,\n): Promise<void> {\n await writeContext(worktreePath, contextUpdate)\n await wtGit.add('.contentrain/context.json')\n try {\n await wtGit.commit('[contentrain] context: update', { '--no-verify': null })\n } catch {\n // Nothing staged (context.json unchanged) — fine.\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"transaction-DC_dNON9.mjs","names":["removeFile"],"sources":["../src/providers/local/migration.ts","../src/git/transaction.ts"],"sourcesContent":["import type { SimpleGit } from 'simple-git'\n\n/**\n * Migration: the first MCP release used `contentrain/*` feature branches.\n * Once we introduced the singleton `contentrain` branch (tracking the\n * committed content state), those old feature branches became a ref-\n * namespace conflict — git cannot hold both `contentrain` (a leaf ref)\n * and `contentrain/foo` (implying a directory) simultaneously.\n *\n * `migrateLegacyBranches` removes the old-prefix branches so the\n * singleton `contentrain` ref can be created. It is idempotent and\n * safe to call before every `ensureContentBranch` run.\n *\n * Strategy:\n * 1. Delete merged `contentrain/*` branches first (`-d`). Their commits\n * are already on the base branch via the old auto-merge flow.\n * 2. Force-delete whatever remains (`-D`). Any unmerged leftover is\n * from an abandoned or partially-committed legacy branch — content\n * on `main` always wins, and the singleton `contentrain` branch is\n * about to be created from `main`/`baseBranch` anyway.\n *\n * Returns the number of branches that were deleted. Callers may log it;\n * the git transaction layer does not need the count for correctness.\n */\nexport async function migrateLegacyBranches(\n git: SimpleGit,\n baseBranch: string,\n): Promise<number> {\n const branches = await git.branchLocal()\n const oldPrefixBranches = branches.all.filter(b => b.startsWith('contentrain/'))\n if (oldPrefixBranches.length === 0) return 0\n\n let deleted = 0\n\n // 1) Delete merged legacy branches first — the safe path.\n let mergedLegacy: string[] = []\n try {\n const mergedOutput = await git.raw(['branch', '--merged', baseBranch])\n mergedLegacy = mergedOutput.split('\\n')\n .map(b => b.trim().replace(/^\\*\\s*/, ''))\n .filter(b => b.startsWith('contentrain/'))\n } catch {\n // `branch --merged` fails before baseBranch exists — fall through.\n }\n\n for (const b of mergedLegacy) {\n try {\n await git.raw(['branch', '-d', b])\n deleted++\n } catch {\n // Branch may be protected or already gone — safe to skip.\n }\n }\n\n // 2) Force-delete any unmerged legacy branches still present.\n const remaining = (await git.branchLocal()).all.filter(b => b.startsWith('contentrain/'))\n for (const b of remaining) {\n try {\n await git.raw(['branch', '-D', b])\n deleted++\n } catch {\n // Skip — best-effort cleanup.\n }\n }\n\n return deleted\n}\n","import { simpleGit, type SimpleGit } from 'simple-git'\nimport { join } from 'node:path'\nimport { rm as removeFile } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { randomUUID } from 'node:crypto'\nimport { readConfig } from '../core/config.js'\nimport { writeContext } from '../core/context.js'\nimport { deleteRemoteBranch, type RemoteDeleteResult } from './branch-lifecycle.js'\nimport { authorConfig } from './identity.js'\nimport { branchTimestamp } from '../util/id.js'\nimport { migrateLegacyBranches } from '../providers/local/migration.js'\nimport type { SyncResult, WorkflowMode } from '@contentrain/types'\nimport { CONTENTRAIN_BRANCH } from '@contentrain/types'\n\nexport interface ContextUpdate {\n tool: string\n model: string\n locale?: string\n entries?: string[]\n}\n\nexport interface GitTransaction {\n worktree: string\n branch: string\n write(callback: (worktreePath: string) => Promise<void>): Promise<void>\n commit(message: string, contextUpdate?: ContextUpdate): Promise<string>\n complete(): Promise<{ action: 'auto-merged' | 'pending-review'; commit: string; sync?: SyncResult; warning?: string }>\n cleanup(): Promise<void>\n}\n\nexport async function ensureContentBranch(projectRoot: string): Promise<void> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n\n // Check if contentrain branch exists locally\n const branches = await git.branchLocal()\n if (branches.all.includes(CONTENTRAIN_BRANCH)) return\n\n // Detect base branch\n const baseBranch = config?.repository?.default_branch\n || (await git.raw(['branch', '--show-current'])).trim()\n || 'main'\n\n // Clean up legacy `contentrain/*` feature branches so the singleton\n // `contentrain` ref can be created. Idempotent — safe to call even\n // when no legacy branches exist.\n await migrateLegacyBranches(git, baseBranch)\n\n // Create contentrain branch from base\n await git.branch([CONTENTRAIN_BRANCH, baseBranch])\n\n // Push to remote if exists\n const remoteName = process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n try {\n const remotes = await git.getRemotes()\n if (remotes.some(r => r.name === remoteName)) {\n await git.push(['-u', remoteName, CONTENTRAIN_BRANCH])\n }\n } catch {\n // Remote push is best-effort\n }\n}\n\nasync function selectiveSync(\n projectRoot: string,\n _worktreePath: string,\n contentrainTip: string,\n _previousBaseRef?: string,\n dirtyFilesBeforeUpdate?: Set<string>,\n): Promise<SyncResult> {\n const git = simpleGit(projectRoot)\n const synced: string[] = []\n const skipped: string[] = []\n\n // Use git plumbing to find ALL files that differ between old and new commits.\n // diff-tree is fast and ignores working tree / index state entirely.\n // Not limited to .contentrain/ — some ops also modify .gitignore, etc.\n const compareRef = _previousBaseRef ?? contentrainTip\n let changedFiles: string[] = []\n try {\n const diffOutput = await git.raw([\n 'diff-tree', '--name-only', '-r', '--no-commit-id',\n compareRef, contentrainTip,\n ])\n changedFiles = diffOutput.split('\\n').filter(f => f.trim().length > 0)\n } catch {\n // Fallback: list .contentrain/ files from the contentrainTip commit\n try {\n const lsOutput = await git.raw(['ls-tree', '-r', '--name-only', contentrainTip, '.contentrain/'])\n changedFiles = lsOutput.split('\\n').filter(f => f.trim().length > 0)\n } catch {\n return { synced, skipped }\n }\n }\n\n if (changedFiles.length === 0) return { synced, skipped }\n\n // Use pre-captured dirty files (before update-ref) to avoid false positives.\n // After update-ref, files appear as \"modified\" in status even though the developer\n // didn't touch them. We use the pre-update state to know what was truly dirty.\n const dirtyFiles = dirtyFilesBeforeUpdate ?? new Set<string>()\n\n // Which changed files still exist in contentrainTip (HEAD after update-ref)?\n // ONE `ls-tree` over the changed paths lists exactly the survivors, instead\n // of a `cat-file -e` spawn per file. Falls back to per-file probing if\n // ls-tree fails so behavior is preserved on any edge.\n const filesInTip = new Set<string>()\n try {\n const lsOutput = await git.raw(['ls-tree', '-r', '--name-only', contentrainTip, '--', ...changedFiles])\n for (const f of lsOutput.split('\\n')) {\n const trimmed = f.trim()\n if (trimmed) filesInTip.add(trimmed)\n }\n } catch {\n for (const file of changedFiles) {\n try {\n await git.raw(['cat-file', '-e', `${contentrainTip}:${file}`])\n filesInTip.add(file)\n } catch {\n // File does not exist in tip (was deleted)\n }\n }\n }\n\n // Partition: dirty developer files are skipped; survivors get checked out\n // from HEAD; the rest were deleted in the new HEAD and are removed on disk.\n const toCheckout: string[] = []\n const toRemove: string[] = []\n for (const file of changedFiles) {\n if (dirtyFiles.has(file)) skipped.push(file)\n else if (filesInTip.has(file)) toCheckout.push(file)\n else toRemove.push(file)\n }\n\n // ONE `git checkout HEAD -- f1 f2 …` restores every clean survivor at once.\n // On failure, fall back to per-file so a single unresolvable path still\n // yields precise skip accounting (dirty files were already excluded).\n if (toCheckout.length > 0) {\n try {\n await git.checkout(['HEAD', '--', ...toCheckout])\n synced.push(...toCheckout)\n } catch {\n for (const file of toCheckout) {\n try {\n await git.checkout(['HEAD', '--', file])\n synced.push(file)\n } catch {\n skipped.push(file)\n }\n }\n }\n }\n\n // Deletions are working-tree fs removals — no git spawn, safe to parallelize.\n await Promise.all(toRemove.map(async (file) => {\n try {\n await removeFile(join(projectRoot, file), { force: true })\n synced.push(file)\n } catch {\n skipped.push(file)\n }\n }))\n\n const warning = skipped.length > 0\n ? `${skipped.length} file(s) skipped due to local changes: ${skipped.join(', ')}. Commit your changes, then run: git checkout HEAD -- ${skipped.join(' ')}`\n : undefined\n\n return { synced, skipped, warning }\n}\n\nexport async function createTransaction(\n projectRoot: string,\n branchName: string,\n options?: { workflowOverride?: WorkflowMode },\n): Promise<GitTransaction> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n const workflow = options?.workflowOverride ?? config?.workflow ?? 'auto-merge'\n\n const remoteName = process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n\n // Detect base branch + current branch + remote in ONE batch\n // (reduces subprocess spawns from 4 to 2)\n let baseBranch = process.env['CONTENTRAIN_BRANCH'] ?? config?.repository?.default_branch ?? ''\n let currentBranch = ''\n let hasRemote = false\n\n const [branchResult, remotes] = await Promise.all([\n git.raw(['branch', '--show-current']).catch(() => ''),\n git.getRemotes().catch(() => []),\n ])\n currentBranch = branchResult.trim()\n if (!baseBranch) baseBranch = currentBranch || 'main'\n hasRemote = (remotes as { name: string }[]).some(r => r.name === remoteName)\n\n // Check if developer is on contentrain branch\n if (currentBranch === CONTENTRAIN_BRANCH) {\n throw Object.assign(new Error(\n `The '${CONTENTRAIN_BRANCH}' branch is checked out in your working directory. `\n + `Contentrain manages this branch automatically. `\n + `Switch to your working branch and retry.`,\n ), {\n code: 'CONTENT_BRANCH_CHECKED_OUT',\n agent_hint: 'Ask the developer to switch to their working branch (e.g., main or a feature branch), then retry the operation.',\n developer_action: `git checkout ${baseBranch}`,\n })\n }\n\n // Ensure contentrain branch exists (with migration for old contentrain/* branches)\n await ensureContentBranch(projectRoot)\n\n // Fetch latest from remote (parallel fetch for both branches)\n if (hasRemote) {\n await Promise.all([\n git.fetch(remoteName, baseBranch).catch(() => {}),\n git.fetch(remoteName, CONTENTRAIN_BRANCH).catch(() => {}),\n ])\n }\n\n const worktreePath = join(tmpdir(), `cr-${randomUUID()}`)\n const branch = branchName\n\n // Create worktree on contentrain branch\n await git.raw(['worktree', 'add', worktreePath, CONTENTRAIN_BRANCH])\n\n // Commit identity comes from `-c user.*` config (see authorConfig) — passed\n // as args, never via `.env()`, so simple-git's block-unsafe guard is never\n // triggered by an inherited EDITOR/GIT_ASKPASS/etc.\n const wtGit = simpleGit(worktreePath, { config: authorConfig() })\n\n // Sync contentrain with base branch (bring main changes into contentrain)\n try {\n await wtGit.merge([baseBranch, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* not in merge state */ }\n if (hasRemote) {\n try {\n await wtGit.merge([`${remoteName}/${baseBranch}`, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* ignore */ }\n }\n }\n }\n\n // Sync with remote contentrain if exists\n if (hasRemote) {\n try {\n await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* ignore */ }\n }\n }\n\n // Create feature branch from contentrain\n await wtGit.checkout(['-b', branch])\n\n let commitHash = ''\n let pendingReview = false\n let savedContextUpdate: ContextUpdate | undefined\n\n return {\n worktree: worktreePath,\n branch,\n\n async write(callback) {\n await callback(worktreePath)\n },\n\n async commit(message, contextUpdate?) {\n // context.json is intentionally NOT committed on the feature branch — it\n // is regenerated on the contentrain branch after the merge (see\n // complete()). Committing it per-branch caused cross-branch merge\n // conflicts on a single mutable file. `--no-verify` keeps the repo's\n // commit-msg / pre-commit hooks (commitlint, lefthook, husky) from\n // rejecting these machine-generated infra commits.\n savedContextUpdate = contextUpdate\n await wtGit.add('.')\n const result = await wtGit.commit(message, { '--allow-empty': null, '--no-verify': null })\n commitHash = result.commit || ''\n return commitHash\n },\n\n async complete() {\n if (workflow === 'review') {\n if (hasRemote) {\n await git.push(remoteName, branch)\n }\n // Pending-review branches must survive for a later contentrain_merge.\n pendingReview = true\n return { action: 'pending-review', commit: commitHash }\n }\n\n // auto-merge: merge feature branch into contentrain, then advance base\n\n // Switch to contentrain branch in worktree\n await wtGit.checkout(CONTENTRAIN_BRANCH)\n\n // Merge feature branch into contentrain\n try {\n await wtGit.merge([branch, '--no-edit'])\n } catch {\n try {\n await wtGit.merge(['--abort'])\n } catch { /* not in merge state */ }\n throw Object.assign(new Error(\n `Merge conflict when merging branch \"${branch}\" into \"${CONTENTRAIN_BRANCH}\". `\n + `The branch still exists with your changes intact. `\n + `Resolve the conflict manually, or delete the branch and retry.`,\n ), {\n code: 'CONTENT_BRANCH_MERGE_CONFLICT',\n agent_hint: 'The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.',\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branch}`,\n })\n }\n\n // Regenerate context.json on the contentrain branch (post-merge,\n // single-threaded) and fold it into the tip before advancing the base.\n if (savedContextUpdate) {\n await regenerateContextOnContentrain(wtGit, worktreePath, savedContextUpdate)\n }\n\n // Get contentrain tip + old base ref + dirty files in parallel\n const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([\n wtGit.raw(['rev-parse', 'HEAD']).then(s => s.trim()),\n git.raw(['rev-parse', baseBranch]).then(s => s.trim()),\n git.status(),\n ])\n const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map(f => f.path))\n\n // Verify fast-forward: baseBranch must be an ancestor of contentrainTip\n // (guaranteed by the merge above, but verify for safety).\n // `rev-list --count` instead of `merge-base --is-ancestor`: the latter\n // signals via exit code with empty stderr, which simple-git reports as\n // success — the guard would silently pass on divergence.\n if (!(await isAncestor(git, previousBaseRef, contentrainTip))) {\n throw Object.assign(new Error(\n `Cannot fast-forward \"${baseBranch}\" to contentrain tip. `\n + `The base branch has diverged. Merge \"${baseBranch}\" into \"${CONTENTRAIN_BRANCH}\" first.`,\n ), {\n code: 'BASE_UPDATE_FAILED',\n agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`,\n })\n }\n\n // Advance base branch to contentrain tip via update-ref\n await git.raw(['update-ref', `refs/heads/${baseBranch}`, contentrainTip])\n\n // Refresh index to match new HEAD.\n // update-ref moves the branch pointer but leaves the index stale.\n // read-tree updates the index to match HEAD without touching the working tree.\n try {\n await git.raw(['read-tree', 'HEAD'])\n } catch {\n // fallback: try reset for older git versions\n try { await git.raw(['reset', 'HEAD']) } catch { /* ignore */ }\n }\n\n // Selective sync: copy .contentrain/ files to developer's working tree\n const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate)\n\n // Push to remote (best-effort with retry)\n if (hasRemote) {\n // Push contentrain branch\n try {\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n // Retry: fetch, merge, push\n try {\n await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH)\n await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, '--no-edit'])\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n // Push failed after retry — continue, local state is fine\n }\n }\n\n // Push base branch\n try {\n await git.push(remoteName, baseBranch)\n } catch {\n // push may fail, local merge succeeded\n }\n }\n\n return {\n action: 'auto-merged' as const,\n commit: commitHash,\n sync,\n ...(sync.warning ? { warning: sync.warning } : {}),\n }\n },\n\n async cleanup() {\n try {\n await git.raw(['worktree', 'remove', worktreePath, '--force'])\n } catch {\n // worktree may already be cleaned up\n }\n // Prune the feature branch unless it is a pending-review branch that must\n // survive for a later contentrain_merge. Auto-merged branches (already in\n // contentrain) and failed/empty branches are both safe to delete, so\n // failed saves and merged saves no longer leak dangling cr/* refs.\n if (!pendingReview) {\n await safeDeleteBranch(git, branch)\n }\n },\n }\n}\n\nexport async function mergeBranch(\n projectRoot: string,\n branchName: string,\n): Promise<{ action: 'merged'; commit: string; sync: SyncResult; remote?: RemoteDeleteResult }> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n const remoteName = process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n\n // Detect base branch\n const baseBranch = process.env['CONTENTRAIN_BRANCH']\n ?? config?.repository?.default_branch\n ?? ((await git.raw(['branch', '--show-current'])).trim() || 'main')\n\n // Ensure contentrain branch exists\n await ensureContentBranch(projectRoot)\n\n // Check remote\n let hasRemote = false\n try {\n const remotes = await git.getRemotes()\n hasRemote = remotes.some(r => r.name === remoteName)\n } catch {\n hasRemote = false\n }\n\n // Create temp worktree on contentrain branch\n const worktreePath = join(tmpdir(), `cr-merge-${randomUUID()}`)\n await git.raw(['worktree', 'add', worktreePath, CONTENTRAIN_BRANCH])\n\n // Commit identity from `-c user.*` config (see authorConfig) — guard-safe,\n // no `.env()` spread.\n const wtGit = simpleGit(worktreePath, { config: authorConfig() })\n\n try {\n // Merge the feature branch into contentrain\n try {\n await wtGit.merge([branchName, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* not in merge state */ }\n throw Object.assign(new Error(\n `Merge conflict when merging branch \"${branchName}\" into \"${CONTENTRAIN_BRANCH}\". `\n + `The branch still exists with your changes intact. `\n + `Resolve the conflict manually, or delete the branch and retry.`,\n ), {\n code: 'CONTENT_BRANCH_MERGE_CONFLICT',\n agent_hint: 'The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.',\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branchName}`,\n })\n }\n\n // Regenerate context.json on contentrain post-merge (deterministic,\n // single-threaded) so review-mode branches — which carry no context.json —\n // still produce up-to-date stats once landed.\n await regenerateContextOnContentrain(wtGit, worktreePath, { tool: 'contentrain_merge', model: '*' })\n\n // Get contentrain tip + old base ref + dirty files in parallel\n const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([\n wtGit.raw(['rev-parse', 'HEAD']).then(s => s.trim()),\n git.raw(['rev-parse', baseBranch]).then(s => s.trim()),\n git.status(),\n ])\n const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map(f => f.path))\n\n // Verify fast-forward: baseBranch must be an ancestor of contentrainTip.\n // (See complete() — merge-base --is-ancestor is unusable via simple-git.)\n if (!(await isAncestor(git, previousBaseRef, contentrainTip))) {\n throw Object.assign(new Error(\n `Cannot fast-forward \"${baseBranch}\" to contentrain tip. `\n + `The base branch has diverged. Merge \"${baseBranch}\" into \"${CONTENTRAIN_BRANCH}\" first.`,\n ), {\n code: 'BASE_UPDATE_FAILED',\n agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`,\n })\n }\n\n // Advance base branch to contentrain tip via update-ref\n await git.raw(['update-ref', `refs/heads/${baseBranch}`, contentrainTip])\n\n // Refresh index to match new HEAD\n try {\n await git.raw(['read-tree', 'HEAD'])\n } catch {\n try { await git.raw(['reset', 'HEAD']) } catch { /* ignore */ }\n }\n\n // Selective sync: copy .contentrain/ files to developer's working tree\n const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate)\n\n // Push to remote (best-effort)\n if (hasRemote) {\n try {\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n try {\n await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH)\n await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, '--no-edit'])\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n // Push failed after retry — continue, local state is fine\n }\n }\n\n try {\n await git.push(remoteName, baseBranch)\n } catch {\n // push may fail, local merge succeeded\n }\n }\n\n // Prune the now-merged feature branch so merged cr/* refs don't accumulate.\n await safeDeleteBranch(git, branchName)\n\n // Delete the remote copy too (review-mode branches were pushed on save).\n // Best-effort and config-gated inside the helper: a failure surfaces as\n // `remote.warning`, never as a failed merge.\n let remote: RemoteDeleteResult | undefined\n if (hasRemote) {\n remote = await deleteRemoteBranch(projectRoot, branchName, { config })\n }\n\n return {\n action: 'merged' as const,\n commit: contentrainTip,\n sync,\n ...(remote ? { remote } : {}),\n }\n } finally {\n // Cleanup worktree\n try {\n await git.raw(['worktree', 'remove', worktreePath, '--force'])\n } catch {\n // worktree may already be cleaned up\n }\n }\n}\n\nexport function buildBranchName(scope: string, target: string, locale?: string): string {\n const ts = branchTimestamp()\n const parts = ['cr', scope, target]\n if (locale) parts.push(locale)\n parts.push(ts)\n return parts.join('/')\n}\n\n/**\n * True when `ancestor` is an ancestor of (or equal to) `descendant`.\n * Implemented with `rev-list --count` because `merge-base --is-ancestor`\n * signals via exit code with empty stderr — simple-git reports that as\n * success, so it cannot express a negative verdict.\n */\nasync function isAncestor(git: SimpleGit, ancestor: string, descendant: string): Promise<boolean> {\n try {\n const count = Number((await git.raw(['rev-list', '--count', ancestor, `^${descendant}`])).trim())\n return count === 0\n } catch {\n return false\n }\n}\n\n/**\n * Force-delete a local branch, swallowing all errors. Never deletes the\n * singleton `contentrain` branch. Used to prune feature branches after they\n * are merged (auto-merge / contentrain_merge) or when a transaction fails\n * before completing — so failed/merged `cr/*` refs do not accumulate.\n */\nasync function safeDeleteBranch(git: SimpleGit, branch: string): Promise<void> {\n if (!branch || branch === CONTENTRAIN_BRANCH) return\n try {\n await git.raw(['branch', '-D', branch])\n } catch {\n // Branch may not exist, be checked out, or already be deleted — ignore.\n }\n}\n\n/**\n * Regenerate `.contentrain/context.json` deterministically inside a worktree\n * that is currently on the `contentrain` branch, then commit it (hooks\n * bypassed). Called AFTER a feature branch is merged so context.json is only\n * ever written on `contentrain`, single-threaded — eliminating the per-branch\n * merge conflicts that came from committing it on every feature branch.\n */\nasync function regenerateContextOnContentrain(\n wtGit: SimpleGit,\n worktreePath: string,\n contextUpdate: ContextUpdate,\n): Promise<void> {\n await writeContext(worktreePath, contextUpdate)\n await wtGit.add('.contentrain/context.json')\n try {\n await wtGit.commit('[contentrain] context: update', { '--no-verify': null })\n } catch {\n // Nothing staged (context.json unchanged) — fine.\n }\n}\n"],"mappings":"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"}
@@ -2,7 +2,7 @@ import { i as metaFilePath, n as contentFilePath, r as documentFilePath, t as co
2
2
  import { c as writeText, s as writeJson } from "./fs-DLbVB-Ek.mjs";
3
3
  import { i as writeMeta } from "./meta-manager-CJUiTgP2.mjs";
4
4
  import { t as readConfig } from "./config-oxxgznz7.mjs";
5
- import { C as LocalReader, f as parseFrontmatter, g as resolveLocaleStrategy, o as listModels, s as readModel } from "./model-manager-SiesTJrS.mjs";
5
+ import { C as LocalReader, f as parseFrontmatter, g as resolveLocaleStrategy, o as listModels, s as readModel } from "./model-manager-DP2CZiMT.mjs";
6
6
  import { join } from "node:path";
7
7
  import { detectSecrets, validateFieldValue } from "@contentrain/types";
8
8
  import { rm } from "node:fs/promises";
@@ -31,7 +31,9 @@ function validateContent(data, fields, modelId, locale, entryId, ctx) {
31
31
  errors
32
32
  };
33
33
  }
34
- function validateField(value, def, modelId, locale, entryId, fieldId, ctx) {
34
+ /** Bounds `items`-inside-`items` nesting; far above any real schema. */
35
+ const MAX_FIELD_DEPTH = 10;
36
+ function validateField(value, def, modelId, locale, entryId, fieldId, ctx, depth = 0) {
35
37
  const errors = [];
36
38
  const errCtx = {
37
39
  model: modelId,
@@ -39,6 +41,11 @@ function validateField(value, def, modelId, locale, entryId, fieldId, ctx) {
39
41
  entry: entryId,
40
42
  field: fieldId
41
43
  };
44
+ if (depth > MAX_FIELD_DEPTH) return [{
45
+ severity: "error",
46
+ ...errCtx,
47
+ message: `${fieldId} exceeds the maximum nesting depth of ${MAX_FIELD_DEPTH}`
48
+ }];
42
49
  if (value !== null && value !== void 0 && value !== "") {
43
50
  const secretErrors = detectSecrets(value);
44
51
  for (const e of secretErrors) errors.push({
@@ -79,16 +86,6 @@ function validateField(value, def, modelId, locale, entryId, fieldId, ctx) {
79
86
  }
80
87
  }
81
88
  }
82
- if (def.type === "email" && typeof value === "string" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) errors.push({
83
- severity: "warning",
84
- ...errCtx,
85
- message: `${fieldId} may not be a valid email`
86
- });
87
- if (def.type === "url" && typeof value === "string" && !/^https?:\/\/.+/.test(value) && !value.startsWith("/")) errors.push({
88
- severity: "warning",
89
- ...errCtx,
90
- message: `${fieldId} may not be a valid URL`
91
- });
92
89
  if (def.type === "relation" && def.model) {
93
90
  const targets = Array.isArray(def.model) ? def.model : [def.model];
94
91
  if (targets.length > 1) if (typeof value !== "object" || value === null || !("model" in value) || !("ref" in value)) errors.push({
@@ -154,65 +151,19 @@ function validateField(value, def, modelId, locale, entryId, fieldId, ctx) {
154
151
  });
155
152
  }
156
153
  }
157
- if (def.type === "array" && Array.isArray(value) && def.items && typeof def.items === "string") for (let i = 0; i < value.length; i++) errors.push(...validateArrayItemType(value[i], def.items, errCtx, `${fieldId}[${i}]`));
158
- if (def.type === "object" && def.fields && typeof value === "object" && value !== null && !Array.isArray(value)) {
159
- const nested = validateContent(value, def.fields, modelId, locale, entryId);
160
- errors.push(...nested.errors);
154
+ if (def.type === "array" && Array.isArray(value) && def.items) {
155
+ const itemDef = typeof def.items === "string" ? { type: def.items } : def.items;
156
+ for (let i = 0; i < value.length; i++) errors.push(...validateField(value[i], itemDef, modelId, locale, entryId, `${fieldId}[${i}]`, void 0, depth + 1));
161
157
  }
162
- if (def.type === "array" && Array.isArray(value) && def.items && typeof def.items === "object" && def.items.type === "object" && def.items.fields) {
163
- for (let i = 0; i < value.length; i++) if (typeof value[i] === "object" && value[i] !== null) {
164
- const nested = validateContent(value[i], def.items.fields, modelId, locale, entryId);
165
- for (const e of nested.errors) errors.push({
166
- ...e,
167
- field: `${fieldId}[${i}].${e.field}`
168
- });
169
- }
158
+ if (def.type === "object" && def.fields && typeof value === "object" && value !== null && !Array.isArray(value)) {
159
+ const nested = validateContent(value, def.fields, modelId, locale, entryId, ctx);
160
+ for (const e of nested.errors) errors.push({
161
+ ...e,
162
+ field: e.field ? `${fieldId}.${e.field}` : fieldId
163
+ });
170
164
  }
171
165
  return errors;
172
166
  }
173
- function validateArrayItemType(value, itemType, errCtx, fieldPath) {
174
- const ctx = {
175
- ...errCtx,
176
- field: fieldPath
177
- };
178
- switch (itemType) {
179
- case "string":
180
- case "email":
181
- case "url":
182
- case "slug":
183
- case "image":
184
- case "video":
185
- case "file":
186
- if (typeof value !== "string") return [{
187
- severity: "error",
188
- ...ctx,
189
- message: `${fieldPath} must be a string`
190
- }];
191
- break;
192
- case "number":
193
- case "integer":
194
- case "decimal":
195
- if (typeof value !== "number") return [{
196
- severity: "error",
197
- ...ctx,
198
- message: `${fieldPath} must be a number`
199
- }];
200
- if (itemType === "integer" && !Number.isInteger(value)) return [{
201
- severity: "error",
202
- ...ctx,
203
- message: `${fieldPath} must be an integer`
204
- }];
205
- break;
206
- case "boolean":
207
- if (typeof value !== "boolean") return [{
208
- severity: "error",
209
- ...ctx,
210
- message: `${fieldPath} must be a boolean`
211
- }];
212
- break;
213
- }
214
- return [];
215
- }
216
167
  //#endregion
217
168
  //#region src/core/validator/relation-integrity.ts
218
169
  /**
@@ -501,6 +452,8 @@ async function validateCollectionModel(reader, projectRoot, model, config, issue
501
452
  });
502
453
  }
503
454
  }
455
+ const strayResult = await checkStrayNonI18nMeta(reader, projectRoot, model, config, issues, fix);
456
+ fixed += strayResult.fixed;
504
457
  for (const locale of locales) {
505
458
  const metaRelPath = metaFilePath(model, locale, config.locales.default);
506
459
  const metaData = await readJsonViaReader(reader, metaRelPath);
@@ -536,7 +489,7 @@ async function validateCollectionModel(reader, projectRoot, model, config, issue
536
489
  entry: entryId,
537
490
  message: `Orphan content: entry "${entryId}" has no metadata`
538
491
  });
539
- if (fix && projectRoot) {
492
+ if (fix && projectRoot && !strayResult.unresolved) {
540
493
  await writeMeta(projectRoot, model, {
541
494
  locale,
542
495
  entryId,
@@ -561,38 +514,87 @@ async function validateCollectionModel(reader, projectRoot, model, config, issue
561
514
  });
562
515
  }
563
516
  }
564
- await checkStrayNonI18nMeta(reader, model, config, issues);
565
517
  return {
566
518
  entries: entriesChecked,
567
519
  fixed
568
520
  };
569
521
  }
570
522
  /**
571
- * Flag meta files a non-i18n model should not have.
523
+ * Flag — and, with `fix`, remediate — meta files a non-i18n model should not have.
572
524
  *
573
525
  * Such a model keeps all content in one `data.json` and therefore exactly one
574
526
  * meta record, at the default locale. Earlier writes derived the meta path from
575
527
  * the caller's locale, so saving under a non-default locale left a second meta
576
- * file — and readers disagreed about which was authoritative. Reported rather
577
- * than auto-removed: the stray may hold the only `published` status in the
578
- * project, so deleting it silently could unpublish content.
528
+ * file, and readers disagreed about which was authoritative.
529
+ *
530
+ * The `fix` remediation is deterministic and never decides a status:
531
+ * - default-locale meta present → the strays are redundant, so delete them
532
+ * (the default-locale record stays authoritative — no status is merged).
533
+ * - default-locale meta absent, exactly one stray → that stray holds the only
534
+ * record, so migrate it to the default path (move) rather than orphan the
535
+ * content.
536
+ * - default-locale meta absent, several strays → which is authoritative is
537
+ * ambiguous, so leave the warning for the agent to resolve by hand.
538
+ *
539
+ * Returns the count of files remediated and whether strays remain unresolved.
540
+ * `unresolved` gates the caller's orphan-content fabrication: while a non-i18n
541
+ * model's meta still lives in a stray, the content is not truly orphaned, so
542
+ * minting a draft default-locale record would both be wrong and set up a trap
543
+ * (a later fix pass would then treat the real stray as redundant and delete it).
579
544
  */
580
- async function checkStrayNonI18nMeta(reader, model, config, issues) {
581
- if (model.i18n) return;
545
+ async function checkStrayNonI18nMeta(reader, projectRoot, model, config, issues, fix) {
546
+ if (model.i18n) return {
547
+ fixed: 0,
548
+ unresolved: false
549
+ };
550
+ const metaDir = `.contentrain/meta/${model.id}`;
582
551
  const expected = `${config.locales.default}.json`;
583
552
  let files;
584
553
  try {
585
- files = await reader.listDirectory(`.contentrain/meta/${model.id}`);
554
+ files = await reader.listDirectory(metaDir);
586
555
  } catch {
587
- return;
556
+ return {
557
+ fixed: 0,
558
+ unresolved: false
559
+ };
588
560
  }
589
561
  const strays = files.filter((f) => f.endsWith(".json") && f !== expected);
590
- if (strays.length === 0) return;
562
+ if (strays.length === 0) return {
563
+ fixed: 0,
564
+ unresolved: false
565
+ };
591
566
  issues.push({
592
567
  severity: "warning",
593
568
  model: model.id,
594
- message: `Meta layout mismatch: "${model.id}" has i18n disabled, so its content lives in a single data.json and its meta belongs at ${expected} alone — but [${strays.join(", ")}] also exist. Readers may disagree about which file is authoritative. Merge any status you want to keep into ${expected}, then remove the extras.`
569
+ message: `Meta layout mismatch: "${model.id}" has i18n disabled, so its content lives in a single data.json and its meta belongs at ${expected} alone — but [${strays.join(", ")}] also exist. Readers may disagree about which file is authoritative. Run contentrain_validate fix:true to prune the extras (the default-locale meta stays authoritative).`
595
570
  });
571
+ if (!fix || !projectRoot) return {
572
+ fixed: 0,
573
+ unresolved: true
574
+ };
575
+ if (await readJsonViaReader(reader, `${metaDir}/${expected}`) === null) {
576
+ if (strays.length !== 1) return {
577
+ fixed: 0,
578
+ unresolved: true
579
+ };
580
+ const stray = strays[0];
581
+ const content = await readJsonViaReader(reader, `${metaDir}/${stray}`);
582
+ if (content === null) return {
583
+ fixed: 0,
584
+ unresolved: true
585
+ };
586
+ await writeJson(join(projectRoot, metaDir, expected), content);
587
+ await rm(join(projectRoot, metaDir, stray), { force: true });
588
+ return {
589
+ fixed: 1,
590
+ unresolved: false
591
+ };
592
+ }
593
+ await Promise.all(strays.map((stray) => rm(join(projectRoot, metaDir, stray), { force: true })));
594
+ return {
595
+ fixed: strays.length,
596
+ unresolved: false
597
+ };
596
598
  }
597
599
  async function validateSingletonModel(reader, projectRoot, model, config, issues, fix) {
598
600
  let entriesChecked = 0;
@@ -783,11 +785,24 @@ async function validateDocumentModel(reader, projectRoot, model, config, issues,
783
785
  };
784
786
  const slugs = await discoverDocumentSlugs(reader, cDir, model);
785
787
  const locales = model.i18n ? config.locales.supported : [config.locales.default];
788
+ const rawByKey = /* @__PURE__ */ new Map();
789
+ const frontmatterByLocale = {};
790
+ for (const slug of slugs) {
791
+ if (slug.startsWith(".")) continue;
792
+ for (const locale of locales) {
793
+ const raw = await readTextViaReader(reader, documentFilePath(model, locale, slug));
794
+ if (!raw) continue;
795
+ rawByKey.set(`${slug}\u0000${locale}`, raw);
796
+ const { frontmatter } = parseFrontmatter(raw);
797
+ frontmatterByLocale[locale] ??= {};
798
+ frontmatterByLocale[locale][slug] = frontmatter;
799
+ }
800
+ }
786
801
  for (const slug of slugs) {
787
802
  if (slug.startsWith(".")) continue;
788
803
  for (const locale of locales) {
789
804
  const filePath = documentFilePath(model, locale, slug);
790
- const raw = await readTextViaReader(reader, filePath);
805
+ const raw = rawByKey.get(`${slug}\u0000${locale}`) ?? null;
791
806
  if (!raw) {
792
807
  if (model.i18n) {
793
808
  issues.push({
@@ -825,7 +840,10 @@ async function validateDocumentModel(reader, projectRoot, model, config, issues,
825
840
  });
826
841
  if (model.fields) {
827
842
  const fieldsWithoutBody = Object.fromEntries(Object.entries(model.fields).filter(([name]) => name !== "body"));
828
- const entryResult = validateContent(frontmatter, fieldsWithoutBody, model.id, locale);
843
+ const entryResult = validateContent(frontmatter, fieldsWithoutBody, model.id, locale, void 0, {
844
+ allEntries: frontmatterByLocale[locale] ?? {},
845
+ currentEntryId: slug
846
+ });
829
847
  for (const err of entryResult.errors) issues.push({
830
848
  ...err,
831
849
  slug
@@ -972,4 +990,4 @@ async function validateProject(input, options) {
972
990
  //#endregion
973
991
  export { validateContent as i, validateScheduleFields as n, checkRelationIntegrity as r, validateProject as t };
974
992
 
975
- //# sourceMappingURL=validator-Ck0puppQ.mjs.map
993
+ //# sourceMappingURL=validator-D5VncJ8M.mjs.map