Sign In

@contentrain/mcp

Package Overview
Dependencies
Maintainers
1
Versions
41
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@contentrain/mcp - npm Package Compare versions

Comparing version
1.10.0
to
1.10.1
+756
dist/apply-manager-UXUTYFZn.mjs
import { c as writeText, o as readText, r as pathExists } from "./fs-DLbVB-Ek.mjs";
import { t as readConfig } from "./config-DlFcTxFG.mjs";
import { S as writeContent, _ as resolveMdFilePath, c as validateModelDefinition, h as resolveJsonFilePath, l as writeModel, m as resolveContentDir, o as listModels, s as readModel } from "./model-manager-BhLsUgaB.mjs";
import { r as writeContext } from "./context-CYN__o3Q.mjs";
import { n as checkBranchHealth } from "./branch-lifecycle-Dd84lx37.mjs";
import { n as createTransaction, t as buildBranchName } from "./transaction-DhVoGigz.mjs";
import { extname, join } from "node:path";
//#region src/core/apply-manager.ts
const MAX_PATCHES = 100;
/** File extensions allowed for patching — scannable source files only */
const PATCHABLE_EXTENSIONS = new Set([
".vue",
".tsx",
".jsx",
".ts",
".js",
".mjs",
".astro",
".svelte"
]);
/** Directories that must never be patched */
const FORBIDDEN_PATH_SEGMENTS = new Set([
".contentrain",
"node_modules",
".git",
"dist",
"build",
".next",
".nuxt"
]);
function detectFileFramework(filePath) {
switch (extname(filePath).toLowerCase()) {
case ".vue": return "vue";
case ".svelte": return "svelte";
case ".tsx":
case ".jsx": return "jsx";
case ".astro": return "astro";
case ".ts":
case ".js":
case ".mjs": return "script";
default: return "script";
}
}
/**
* Validate that a replacement expression uses the correct template syntax
* for the target file's framework. Returns a warning string or null.
*/
function validateFrameworkExpression(filePath, newExpression, context) {
if (context !== "tag_text") return null;
switch (detectFileFramework(filePath)) {
case "vue":
if (!newExpression.includes("{{")) return `Vue file "${filePath}": tag text expression "${newExpression}" does not contain "{{" — expected Vue template syntax like {{ $t('key') }}`;
break;
case "jsx":
if (!newExpression.includes("{")) return `JSX file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected JSX syntax like {t('key')}`;
break;
case "svelte":
if (!newExpression.includes("{")) return `Svelte file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected Svelte syntax like {$t('key')}`;
break;
case "astro":
if (!newExpression.includes("{")) return `Astro file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected Astro syntax like {t('key')}`;
break;
case "script": return `Script file "${filePath}": tag text replacement not applicable for .ts/.js files`;
}
return null;
}
/**
* Validate that a patch file path is safe and within allowed scope.
* Returns an error message or null if valid.
*/
function validatePatchPath(filePath) {
const normalizedPath = filePath.replace(/\\/g, "/");
if (normalizedPath.includes("..")) return `Path traversal detected: "${filePath}"`;
if (normalizedPath.startsWith("/")) return `Absolute path not allowed: "${filePath}"`;
const segments = normalizedPath.split("/");
for (const seg of segments) if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return `Patching files inside "${seg}/" is not allowed: "${filePath}"`;
const ext = extname(filePath).toLowerCase();
if (!PATCHABLE_EXTENSIONS.has(ext)) return `File extension "${ext}" is not patchable. Allowed: ${[...PATCHABLE_EXTENSIONS].join(", ")}. Path: "${filePath}"`;
return null;
}
/**
* Perform a basic syntax check on a patched file.
* Returns an error message or null if syntax appears valid.
*/
function checkSyntax(filePath, content) {
switch (extname(filePath).toLowerCase()) {
case ".ts":
case ".tsx":
case ".js":
case ".jsx":
case ".mjs": return checkJsSyntax(content);
case ".vue": return checkVueSyntax(content);
case ".svelte":
case ".astro": return checkTagBalance(content);
default: return null;
}
}
/**
* Basic JS/TS syntax check: bracket/paren/brace balance + string literal closure.
* This is intentionally conservative — it catches obvious breakage without
* requiring a full parser.
*/
function checkJsSyntax(content) {
const stack = [];
const pairs = {
")": "(",
"]": "[",
"}": "{"
};
let inString = null;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
for (let i = 0; i < content.length; i++) {
const ch = content[i];
const next = content[i + 1];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\" && inString !== null) {
escaped = true;
continue;
}
if (!inString && !inBlockComment && ch === "/" && next === "/") {
inLineComment = true;
continue;
}
if (inLineComment) {
if (ch === "\n") inLineComment = false;
continue;
}
if (!inString && !inBlockComment && ch === "/" && next === "*") {
inBlockComment = true;
i++;
continue;
}
if (inBlockComment) {
if (ch === "*" && next === "/") {
inBlockComment = false;
i++;
}
continue;
}
if (inString !== null) {
if (ch === inString) inString = null;
else if (inString !== "`" && ch === "\n") return `Unterminated string literal near offset ${i}`;
continue;
}
if (ch === "\"" || ch === "'" || ch === "`") {
inString = ch;
continue;
}
if (ch === "(" || ch === "[" || ch === "{") stack.push(ch);
else if (ch === ")" || ch === "]" || ch === "}") {
const expected = pairs[ch];
if (stack.length === 0) return `Unmatched closing "${ch}" near offset ${i}`;
const top = stack.pop();
if (top !== expected) return `Mismatched bracket: expected closing for "${top}" but found "${ch}" near offset ${i}`;
}
}
if (inString !== null) return `Unterminated string literal (opened with ${inString})`;
if (stack.length > 0) return `Unclosed bracket "${stack[stack.length - 1]}" — ${stack.length} unclosed bracket(s)`;
return null;
}
/**
* Vue SFC syntax check: ensure <template>, <script>, <style> tags are balanced.
*/
function checkVueSyntax(content) {
const tagBalance = checkTagBalance(content);
if (tagBalance) return tagBalance;
for (const tag of ["template", "script"]) {
const openRe = new RegExp(`<${tag}[\\s>]`, "g");
const closeRe = new RegExp(`</${tag}>`, "g");
const opens = content.match(openRe)?.length ?? 0;
const closes = content.match(closeRe)?.length ?? 0;
if (opens !== closes) return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`;
}
return null;
}
/**
* Basic tag balance check for HTML-like files.
* Checks that self-closing tags are handled and major structural tags are balanced.
*/
function checkTagBalance(content) {
for (const tag of [
"div",
"section",
"main",
"header",
"footer",
"nav",
"article",
"aside",
"ul",
"ol",
"table"
]) {
const openRe = new RegExp(`<${tag}[\\s>]`, "g");
const closeRe = new RegExp(`</${tag}>`, "g");
const opens = content.match(openRe)?.length ?? 0;
const closes = content.match(closeRe)?.length ?? 0;
if (opens !== closes) return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`;
}
return null;
}
async function applyExtract(projectRoot, input) {
const config = await readConfig(projectRoot);
if (!config) throw new Error("Project not initialized. Run contentrain_init first.");
const { extractions, dry_run } = input;
const existingModels = await listModels(projectRoot);
const existingIds = new Set(existingModels.map((m) => m.id));
const modelsToCreate = [];
const modelsToUpdate = [];
const contentFiles = [];
let totalEntries = 0;
const validationErrors = [];
for (const ext of extractions) {
const modelErrors = validateModelDefinition({
id: ext.model,
kind: ext.kind,
fields: ext.fields
});
if (modelErrors.length > 0) validationErrors.push(...modelErrors.map((e) => `[${ext.model}] ${e}`));
for (const entry of ext.entries) if (ext.kind === "dictionary") {
if (entry.data["id"] !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Dictionary entries should not have id or slug`);
for (const [key, val] of Object.entries(entry.data)) if (typeof val !== "string") validationErrors.push(`[${ext.model}] Dictionary entry value for key "${key}" must be a string, got ${typeof val}`);
} else if (ext.kind === "document") {
if (!entry.slug && !entry.data["slug"]) validationErrors.push(`[${ext.model}] Document entries must have a slug`);
} else if (ext.kind === "collection") {
if (entry.slug !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Collection entries should not have slug`);
} else if (ext.kind === "singleton") {
if (entry.data["id"] !== void 0 || entry.slug !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Singleton entries should not have id or slug`);
}
if (existingIds.has(ext.model)) modelsToUpdate.push(ext.model);
else modelsToCreate.push(ext.model);
totalEntries += ext.entries.length;
let previewModel;
if (existingIds.has(ext.model)) {
const real = await readModel(projectRoot, ext.model);
if (real) previewModel = real;
else previewModel = {
id: ext.model,
kind: ext.kind,
domain: ext.domain,
i18n: ext.i18n ?? true
};
} else previewModel = {
id: ext.model,
kind: ext.kind,
domain: ext.domain,
i18n: ext.i18n ?? true
};
const cDir = resolveContentDir(projectRoot, previewModel);
for (const entry of ext.entries) {
const locale = entry.locale ?? config.locales.default;
if (ext.kind === "document" && entry.slug) contentFiles.push(resolveMdFilePath(cDir, previewModel, locale, entry.slug));
else contentFiles.push(resolveJsonFilePath(cDir, previewModel, locale));
}
}
const preview = {
models_to_create: modelsToCreate,
models_to_update: modelsToUpdate,
total_entries: totalEntries,
content_files: [...new Set(contentFiles)]
};
if (dry_run !== false) return {
dry_run: true,
preview,
...validationErrors.length > 0 ? { validation_errors: validationErrors } : {},
next_steps: [
...validationErrors.length > 0 ? [`WARNING: ${validationErrors.length} validation error(s) found — fix before executing`] : [],
"Review the preview above",
"Call contentrain_apply with mode:extract and dry_run:false to execute"
]
};
if (validationErrors.length > 0) return {
dry_run: false,
error: "Model validation failed — cannot execute extract with invalid model definitions",
validation_errors: validationErrors,
next_steps: ["Fix the validation errors and retry"]
};
const health = await checkBranchHealth(projectRoot);
if (health.blocked) return {
error: health.message,
action: "blocked",
hint: "Merge or delete old contentrain/* branches before executing normalize."
};
const branchName = buildBranchName("normalize", "extract");
const tx = await createTransaction(projectRoot, branchName, { workflowOverride: "review" });
const sourceMap = [];
const modelsCreated = [];
const modelsUpdated = [];
let entriesWritten = 0;
try {
await tx.write(async (wt) => {
for (const ext of extractions) {
const existing = await readModel(wt, ext.model);
if (existing) {
if (ext.fields) {
const merged = {
...existing.fields,
...ext.fields
};
if (Object.keys(ext.fields).filter((k) => !(k in (existing.fields ?? {}))).length > 0) {
existing.fields = merged;
await writeModel(wt, existing);
modelsUpdated.push(ext.model);
}
}
} else {
await writeModel(wt, {
id: ext.model,
name: ext.model.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "),
kind: ext.kind,
domain: ext.domain,
i18n: ext.i18n ?? true,
fields: ext.fields
});
modelsCreated.push(ext.model);
}
const model = await readModel(wt, ext.model);
const entries = ext.entries.map((e) => ({
locale: e.locale,
slug: e.slug,
data: e.data
}));
await writeContent(wt, model, entries, await readConfig(wt) ?? config);
entriesWritten += entries.length;
for (const entry of ext.entries) if (ext.kind === "dictionary" && entry.sources) for (const s of entry.sources) sourceMap.push({
model: ext.model,
locale: entry.locale ?? config.locales.default,
value: s.value,
file: s.file,
line: s.line
});
else if (entry.source) sourceMap.push({
model: ext.model,
locale: entry.locale ?? config.locales.default,
value: entry.source.value,
file: entry.source.file,
line: entry.source.line
});
}
if (sourceMap.length > 0) {
const sourcesByModel = {};
for (const s of sourceMap) {
if (!sourcesByModel[s.model]) sourcesByModel[s.model] = {
source_files: [],
entry_count: 0
};
const modelEntry = sourcesByModel[s.model];
if (!modelEntry.source_files.includes(s.file)) modelEntry.source_files.push(s.file);
modelEntry.entry_count++;
}
const sourcesJson = JSON.stringify({
version: 1,
created_at: (/* @__PURE__ */ new Date()).toISOString(),
models: sourcesByModel
}, null, 2) + "\n";
await writeText(join(wt, ".contentrain", "normalize-sources.json"), sourcesJson);
}
await writeContext(wt, {
tool: "contentrain_apply",
model: extractions.map((e) => e.model).join(","),
locale: config.locales.default,
entries: extractions.flatMap((e) => e.entries.map((en) => en.slug ?? "entry"))
});
});
const commitMsg = `[contentrain] normalize: extract ${entriesWritten} entries to ${extractions.length} models`;
await tx.commit(commitMsg);
const gitResult = {
branch: branchName,
action: "pending-review",
commit: ""
};
try {
const completed = await tx.complete();
gitResult.action = completed.action;
gitResult.commit = completed.commit;
} catch {
gitResult.action = "pending-review";
} finally {
await tx.cleanup();
}
return {
dry_run: false,
results: {
models_created: modelsCreated,
models_updated: modelsUpdated,
entries_written: entriesWritten,
source_map: sourceMap
},
git: gitResult,
context_updated: true,
next_steps: [
"Run contentrain_validate to check the extracted content",
"Run contentrain_submit to push the branch for review",
"For browser-based review: ensure `contentrain serve` is running, direct user to http://localhost:3333/normalize",
"For terminal workflow: use contentrain_merge to merge the branch locally",
"After merge, run `npx contentrain generate` to update SDK client",
"After review, proceed with mode:reuse to patch source files"
]
};
} catch (error) {
await tx.cleanup();
throw error;
}
}
async function applyReuse(projectRoot, input) {
const config = await readConfig(projectRoot);
if (!config) throw new Error("Project not initialized. Run contentrain_init first.");
const { scope, patches, dry_run } = input;
if (!scope.model && !scope.domain) throw new Error("Scope required: provide model or domain. Whole-project patching is not allowed.");
if (patches.length > MAX_PATCHES) throw new Error(`Too many patches (${patches.length}). Maximum ${MAX_PATCHES} per operation. Split into multiple calls.`);
if (scope.model) {
if (!await readModel(projectRoot, scope.model)) throw new Error(`Model "${scope.model}" not found. Run extract phase first.`);
}
const scopeWarnings = [];
for (const patch of patches) {
const pathError = validatePatchPath(patch.file);
if (pathError) throw new Error(`Invalid patch path: ${pathError}`);
}
if (scope.model || scope.domain) {
const models = await listModels(projectRoot);
const scopeModels = scope.model ? models.filter((m) => m.id === scope.model) : scope.domain ? models.filter((m) => m.domain === scope.domain) : models;
if (scopeModels.length === 0) throw new Error(`No models found for scope ${scope.model ? `model="${scope.model}"` : `domain="${scope.domain}"`}`);
const { autoDetectSourceDirs } = await import("./core/scan-config.mjs");
const sourceDirs = await autoDetectSourceDirs(projectRoot);
const allowedPrefixes = sourceDirs.map((d) => d === "." ? "" : d + "/");
for (const patch of patches) {
const normalizedPath = patch.file.replace(/\\/g, "/");
if (normalizedPath.startsWith(".contentrain/") || normalizedPath.includes("/.contentrain/")) throw new Error(`Cannot patch content/config files directly: "${patch.file}". Reuse patches source files only.`);
if (sourceDirs.length > 0 && sourceDirs[0] !== ".") {
if (!allowedPrefixes.some((prefix) => prefix === "" || normalizedPath.startsWith(prefix))) throw new Error(`Patch file "${patch.file}" is outside detected source directories (${sourceDirs.join(", ")}). Reuse patches must target source files within the project's source tree.`);
}
}
if (scope.model || scope.domain) {
const sourcesRaw = await readText(join(projectRoot, ".contentrain", "normalize-sources.json"));
if (!sourcesRaw) if (scopeModels.find((m) => m.id === scope.model)?.kind === "dictionary") scopeWarnings.push("normalize-sources.json not found. Dictionary models do not generate per-file source maps. Scope enforcement is based on source-tree locality only.");
else if (dry_run !== false) scopeWarnings.push("normalize-sources.json not found. Semantic scope enforcement is unavailable. Merge the extract branch first, then reuse will have full scope protection.");
else throw new Error("Cannot execute reuse: normalize-sources.json not found on base branch. The extract branch must be merged before reuse can execute. This ensures semantic scope enforcement protects against out-of-scope patching.");
else {
const sourcesData = JSON.parse(sourcesRaw);
let allowedSourceFiles = [];
let scopeLabel = "";
if (scope.model) {
const modelSources = sourcesData.models?.[scope.model]?.source_files;
if (modelSources) allowedSourceFiles = modelSources;
scopeLabel = `model "${scope.model}"`;
} else if (scope.domain) {
const domainModelIds = scopeModels.map((m) => m.id);
for (const modelId of domainModelIds) {
const modelSources = sourcesData.models?.[modelId]?.source_files;
if (modelSources) allowedSourceFiles.push(...modelSources);
}
allowedSourceFiles = [...new Set(allowedSourceFiles)];
scopeLabel = `domain "${scope.domain}"`;
}
if (allowedSourceFiles.length > 0) {
const outOfScopePatches = [];
for (const patch of patches) {
const normalizedPath = patch.file.replace(/\\/g, "/");
if (!allowedSourceFiles.includes(normalizedPath)) outOfScopePatches.push(patch.file);
}
if (outOfScopePatches.length > 0) throw new Error(`Scope enforcement: ${outOfScopePatches.length} patch file(s) are not associated with ${scopeLabel}. Out-of-scope files: ${outOfScopePatches.join(", ")}. Known source files: ${allowedSourceFiles.join(", ")}.`);
}
}
}
}
const patchesByFile = /* @__PURE__ */ new Map();
for (const patch of patches) {
if (!patchesByFile.has(patch.file)) patchesByFile.set(patch.file, []);
patchesByFile.get(patch.file).push(patch);
}
const filesToModify = [...patchesByFile.keys()];
const importsToAdd = patches.filter((p) => p.import_statement).length;
if (dry_run !== false) return {
dry_run: true,
preview: {
files_to_modify: filesToModify,
patches_count: patches.length,
imports_to_add: importsToAdd
},
...scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {},
next_steps: [
...scopeWarnings.length > 0 ? [`WARNING: ${scopeWarnings.length} patch file(s) not in extract source map — verify intent`] : [],
"Review the files and patches above",
"Call contentrain_apply with mode:reuse and dry_run:false to execute"
]
};
const reuseHealth = await checkBranchHealth(projectRoot);
if (reuseHealth.blocked) return {
dry_run: false,
error: `Branch blocked: ${reuseHealth.message}`,
next_steps: ["Merge or delete old contentrain/* branches before executing reuse."]
};
const scopeTarget = scope.model ?? scope.domain;
const branchName = buildBranchName("normalize/reuse", scopeTarget);
const tx = await createTransaction(projectRoot, branchName, { workflowOverride: "review" });
const filesModified = [];
let patchesApplied = 0;
let importsAdded = 0;
const patchesSkipped = [];
const frameworkWarnings = [];
const syntaxErrors = [];
try {
await tx.write(async (wt) => {
for (const [relFile, filePatches] of patchesByFile) {
const absPath = join(wt, relFile);
if (!await pathExists(absPath)) {
for (const p of filePatches) patchesSkipped.push({
file: relFile,
line: p.line,
reason: "file not found"
});
continue;
}
const content = await readText(absPath);
if (content === null) {
for (const p of filePatches) patchesSkipped.push({
file: relFile,
line: p.line,
reason: "file unreadable"
});
continue;
}
const sorted = [...filePatches].toSorted((a, b) => b.line - a.line);
const lines = content.split("\n");
let fileModified = false;
for (const patch of sorted) {
const isTagTextContext = (lines[patch.line - 1] ?? "").includes(`>${patch.old_value}<`);
const replacementContext = isTagTextContext ? "tag_text" : "other";
if (isTagTextContext) {
const fwWarning = validateFrameworkExpression(relFile, patch.new_expression, replacementContext);
if (fwWarning) frameworkWarnings.push({
file: relFile,
warning: fwWarning
});
}
if (applyPatchToLines(lines, patch)) {
patchesApplied++;
fileModified = true;
} else patchesSkipped.push({
file: relFile,
line: patch.line,
reason: "old_value not found at or near specified line"
});
}
const importStatements = new Set(filePatches.filter((p) => p.import_statement).map((p) => p.import_statement));
if (importStatements.size > 0) {
const added = addImportsToLines(lines, importStatements);
importsAdded += added;
if (added > 0) fileModified = true;
}
if (fileModified) {
const newContent = lines.join("\n");
await writeText(absPath, newContent);
filesModified.push(relFile);
const syntaxError = checkSyntax(relFile, newContent);
if (syntaxError) syntaxErrors.push({
file: relFile,
error: syntaxError
});
}
}
await writeContext(wt, {
tool: "contentrain_apply",
model: scopeTarget,
locale: config.locales.default
});
});
if (filesModified.length === 0) {
await tx.cleanup();
return {
dry_run: false,
results: {
files_modified: [],
patches_applied: 0,
patches_skipped: patchesSkipped,
imports_added: 0,
framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : void 0
},
next_steps: ["No files were modified. Check patch definitions and try again."]
};
}
const commitMsg = `[contentrain] normalize: reuse ${scopeTarget} — patch ${filesModified.length} files (${patchesApplied} replacements)`;
await tx.commit(commitMsg);
const gitResult = {
branch: branchName,
action: "pending-review",
commit: ""
};
try {
const completed = await tx.complete();
gitResult.action = completed.action;
gitResult.commit = completed.commit;
} catch {
gitResult.action = "pending-review";
} finally {
await tx.cleanup();
}
return {
dry_run: false,
results: {
files_modified: filesModified,
patches_applied: patchesApplied,
patches_skipped: patchesSkipped,
imports_added: importsAdded,
framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : void 0,
syntax_errors: syntaxErrors.length > 0 ? syntaxErrors : void 0
},
...scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {},
git: gitResult,
next_steps: [
"Run contentrain_validate to verify the patched files",
patchesSkipped.length > 0 ? `${patchesSkipped.length} patches were skipped — review and retry if needed` : "",
syntaxErrors.length > 0 ? `WARNING: ${syntaxErrors.length} file(s) may have syntax errors after patching — review manually` : "",
scopeWarnings.length > 0 ? `NOTE: ${scopeWarnings.length} patch file(s) not in extract source map` : "",
"Run contentrain_submit to push the branch for review",
"For review: direct user to http://localhost:3333/branches or use contentrain_merge",
"After all reuse phases complete, run `npx contentrain generate` to update SDK types"
].filter(Boolean)
};
} catch (error) {
await tx.cleanup();
throw error;
}
}
/**
* Apply a single patch to a lines array. Mutates lines in place.
* Uses line hint for proximity matching — searches ±10 lines from hint.
*/
function applyPatchToLines(lines, patch) {
const { line, old_value, new_expression } = patch;
const lineIdx = line - 1;
const searchStart = Math.max(0, lineIdx - 10);
const searchEnd = Math.min(lines.length, lineIdx + 11);
if (lineIdx >= 0 && lineIdx < lines.length) {
const replaced = replaceInLine(lines[lineIdx], old_value, new_expression);
if (replaced !== null) {
lines[lineIdx] = replaced;
return true;
}
}
for (let i = searchStart; i < searchEnd; i++) {
if (i === lineIdx) continue;
const replaced = replaceInLine(lines[i], old_value, new_expression);
if (replaced !== null) {
lines[i] = replaced;
return true;
}
}
return false;
}
/**
* Replace old_value with new_expression in a single line.
* Matches the string literal (quoted or unquoted tag text).
* Returns the modified line, or null if not found.
*
* Guardrail #4: Safer patch matching — word boundary awareness and
* ambiguity rejection for plain text fallback.
*/
function replaceInLine(line, oldValue, newExpression) {
for (const quote of [
"\"",
"'",
"`"
]) {
const quoted = `${quote}${oldValue}${quote}`;
if (line.includes(quoted)) return line.replace(quoted, newExpression);
}
if (line.includes(`>${oldValue}<`)) return line.replace(`>${oldValue}<`, `>${newExpression}<`);
if (line.includes(oldValue)) {
if (countOccurrences(line, oldValue) > 1) return null;
const idx = line.indexOf(oldValue);
const charBefore = idx > 0 ? line[idx - 1] : "";
const charAfter = idx + oldValue.length < line.length ? line[idx + oldValue.length] : "";
const oldStartsWithWord = oldValue.length > 0 && isWordChar(oldValue[0]);
const oldEndsWithWord = oldValue.length > 0 && isWordChar(oldValue[oldValue.length - 1]);
if (oldStartsWithWord && isWordChar(charBefore)) return null;
if (oldEndsWithWord && isWordChar(charAfter)) return null;
return line.replace(oldValue, newExpression);
}
return null;
}
/** Count non-overlapping occurrences of a substring */
function countOccurrences(str, sub) {
let count = 0;
let pos = 0;
while (pos <= str.length - sub.length) {
const idx = str.indexOf(sub, pos);
if (idx === -1) break;
count++;
pos = idx + sub.length;
}
return count;
}
/** Check if a character is a word character (letter, digit, underscore) */
function isWordChar(ch) {
return /\w/.test(ch);
}
/**
* Add import statements to the top of a file (after existing imports).
* Deduplicates — won't add if the import already exists.
* Returns number of imports actually added.
*/
function addImportsToLines(lines, imports) {
let added = 0;
const existingContent = lines.join("\n");
let lastImportIdx = -1;
let inMultiLineImport = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (inMultiLineImport) {
lastImportIdx = i;
if (trimmed.includes("}")) inMultiLineImport = false;
continue;
}
if (trimmed.startsWith("import ") || trimmed.startsWith("import{")) {
lastImportIdx = i;
if (trimmed.includes("{") && !trimmed.includes("}")) inMultiLineImport = true;
continue;
}
if (lastImportIdx >= 0 && trimmed.length > 0 && !trimmed.startsWith("//") && !trimmed.startsWith("/*") && !trimmed.startsWith("*")) break;
}
let insertAt;
if (lastImportIdx >= 0) insertAt = lastImportIdx + 1;
else {
insertAt = 0;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (i === 0 && trimmed.startsWith("#!")) {
insertAt = i + 1;
continue;
}
if (trimmed === "'use client'" || trimmed === "\"use client\"" || trimmed === "'use server'" || trimmed === "\"use server\"" || trimmed === "'use client';" || trimmed === "\"use client\";" || trimmed === "'use server';" || trimmed === "\"use server\";") {
insertAt = i + 1;
continue;
}
if (insertAt > 0 && trimmed.length > 0) break;
if (insertAt === 0 && trimmed.length > 0) break;
}
}
const toInsert = [];
for (const imp of imports) if (!existingContent.includes(imp)) {
toInsert.push(imp);
added++;
}
if (toInsert.length > 0) lines.splice(insertAt, 0, ...toInsert);
return added;
}
//#endregion
export { detectFileFramework as a, validatePatchPath as c, checkSyntax as i, applyExtract as n, replaceInLine as o, applyReuse as r, validateFrameworkExpression as s, PATCHABLE_EXTENSIONS as t };
//# sourceMappingURL=apply-manager-UXUTYFZn.mjs.map
{"version":3,"file":"apply-manager-UXUTYFZn.mjs","names":[],"sources":["../src/core/apply-manager.ts"],"sourcesContent":["import type { ModelDefinition, FieldDef, FileFramework } from '@contentrain/types'\nimport { join, extname } from 'node:path'\nimport { readText, writeText, pathExists } from '../util/fs.js'\nimport { readModel, writeModel, listModels, validateModelDefinition } from './model-manager.js'\nimport { writeContent, resolveContentDir, resolveJsonFilePath, resolveMdFilePath, type ContentEntry } from './content-manager.js'\nimport { readConfig } from './config.js'\nimport { writeContext } from './context.js'\nimport { createTransaction, buildBranchName } from '../git/transaction.js'\nimport { checkBranchHealth } from '../git/branch-lifecycle.js'\n\n// ─── Types ───\n\nexport interface ExtractionEntry {\n model: string\n kind: 'singleton' | 'collection' | 'dictionary' | 'document'\n domain: string\n i18n?: boolean\n fields?: Record<string, FieldDef>\n entries: Array<{\n locale?: string\n slug?: string\n data: Record<string, unknown>\n source?: { file: string; line: number; value: string }\n sources?: Array<{ file: string; line: number; key: string; value: string }>\n }>\n}\n\nexport interface ExtractionInput {\n extractions: ExtractionEntry[]\n dry_run?: boolean\n}\n\nexport interface ExtractionPreview {\n models_to_create: string[]\n models_to_update: string[]\n total_entries: number\n content_files: string[]\n}\n\nexport interface ExtractionResult {\n dry_run: boolean\n preview?: ExtractionPreview\n error?: string\n validation_errors?: string[]\n results?: {\n models_created: string[]\n models_updated: string[]\n entries_written: number\n source_map: Array<{ model: string; locale: string; value: string; file: string; line: number }>\n }\n git?: { branch: string; action: string; commit: string }\n context_updated?: boolean\n next_steps: string[]\n}\n\nexport interface PatchEntry {\n file: string\n line: number\n old_value: string\n new_expression: string\n import_statement?: string\n}\n\nexport interface ReuseInput {\n scope: { model?: string; domain?: string }\n patches: PatchEntry[]\n dry_run?: boolean\n}\n\nexport interface SyntaxError {\n file: string\n error: string\n}\n\nexport interface ReuseResult {\n dry_run: boolean\n error?: string\n scope_warnings?: string[]\n preview?: {\n files_to_modify: string[]\n patches_count: number\n imports_to_add: number\n }\n results?: {\n files_modified: string[]\n patches_applied: number\n patches_skipped: Array<{ file: string; line: number; reason: string }>\n imports_added: number\n framework_warnings?: Array<{ file: string; warning: string }>\n syntax_errors?: SyntaxError[]\n }\n git?: { branch: string; action: string; commit: string }\n next_steps: string[]\n}\n\n// ─── Constants ───\n\nconst MAX_PATCHES = 100\n\n/** File extensions allowed for patching — scannable source files only */\nexport const PATCHABLE_EXTENSIONS = new Set([\n '.vue', '.tsx', '.jsx', '.ts', '.js', '.mjs', '.astro', '.svelte',\n])\n\n/** Directories that must never be patched */\nconst FORBIDDEN_PATH_SEGMENTS = new Set([\n '.contentrain', 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt',\n])\n\n// ─── Framework Detection (Guardrail #2) ───\n\nexport type { FileFramework } from '@contentrain/types'\n\nexport function detectFileFramework(filePath: string): FileFramework {\n const ext = extname(filePath).toLowerCase()\n switch (ext) {\n case '.vue': return 'vue'\n case '.svelte': return 'svelte'\n case '.tsx':\n case '.jsx': return 'jsx'\n case '.astro': return 'astro'\n case '.ts':\n case '.js':\n case '.mjs': return 'script'\n default: return 'script'\n }\n}\n\n/**\n * Validate that a replacement expression uses the correct template syntax\n * for the target file's framework. Returns a warning string or null.\n */\nexport function validateFrameworkExpression(\n filePath: string,\n newExpression: string,\n context: 'tag_text' | 'other',\n): string | null {\n if (context !== 'tag_text') return null\n\n const framework = detectFileFramework(filePath)\n\n switch (framework) {\n case 'vue':\n if (!newExpression.includes('{{')) {\n return `Vue file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{{\" — expected Vue template syntax like {{ $t('key') }}`\n }\n break\n case 'jsx':\n if (!newExpression.includes('{')) {\n return `JSX file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{\" — expected JSX syntax like {t('key')}`\n }\n break\n case 'svelte':\n if (!newExpression.includes('{')) {\n return `Svelte file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{\" — expected Svelte syntax like {$t('key')}`\n }\n break\n case 'astro':\n if (!newExpression.includes('{')) {\n return `Astro file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{\" — expected Astro syntax like {t('key')}`\n }\n break\n case 'script':\n // Script files don't have template interpolation — warn if attempting tag text replacement\n return `Script file \"${filePath}\": tag text replacement not applicable for .ts/.js files`\n }\n\n return null\n}\n\n// ─── Scope Validation (Guardrail #1) ───\n\n/**\n * Validate that a patch file path is safe and within allowed scope.\n * Returns an error message or null if valid.\n */\nexport function validatePatchPath(filePath: string): string | null {\n const normalizedPath = filePath.replace(/\\\\/g, '/')\n\n // Reject path traversal\n if (normalizedPath.includes('..')) {\n return `Path traversal detected: \"${filePath}\"`\n }\n\n // Reject absolute paths\n if (normalizedPath.startsWith('/')) {\n return `Absolute path not allowed: \"${filePath}\"`\n }\n\n // Reject forbidden directories\n const segments = normalizedPath.split('/')\n for (const seg of segments) {\n if (FORBIDDEN_PATH_SEGMENTS.has(seg)) {\n return `Patching files inside \"${seg}/\" is not allowed: \"${filePath}\"`\n }\n }\n\n // Reject non-scannable extensions\n const ext = extname(filePath).toLowerCase()\n if (!PATCHABLE_EXTENSIONS.has(ext)) {\n return `File extension \"${ext}\" is not patchable. Allowed: ${[...PATCHABLE_EXTENSIONS].join(', ')}. Path: \"${filePath}\"`\n }\n\n return null\n}\n\n// ─── Syntax Check (Guardrail #5) ───\n\n/**\n * Perform a basic syntax check on a patched file.\n * Returns an error message or null if syntax appears valid.\n */\nexport function checkSyntax(filePath: string, content: string): string | null {\n const ext = extname(filePath).toLowerCase()\n\n switch (ext) {\n case '.ts':\n case '.tsx':\n case '.js':\n case '.jsx':\n case '.mjs':\n return checkJsSyntax(content)\n case '.vue':\n return checkVueSyntax(content)\n case '.svelte':\n case '.astro':\n return checkTagBalance(content)\n default:\n return null\n }\n}\n\n/**\n * Basic JS/TS syntax check: bracket/paren/brace balance + string literal closure.\n * This is intentionally conservative — it catches obvious breakage without\n * requiring a full parser.\n */\nfunction checkJsSyntax(content: string): string | null {\n const stack: string[] = []\n const pairs: Record<string, string> = { ')': '(', ']': '[', '}': '{' }\n let inString: string | null = null\n let escaped = false\n let inLineComment = false\n let inBlockComment = false\n\n for (let i = 0; i < content.length; i++) {\n const ch = content[i]!\n const next = content[i + 1]\n\n // Handle escape sequences inside strings\n if (escaped) {\n escaped = false\n continue\n }\n\n if (ch === '\\\\' && inString !== null) {\n escaped = true\n continue\n }\n\n // Line comment\n if (!inString && !inBlockComment && ch === '/' && next === '/') {\n inLineComment = true\n continue\n }\n if (inLineComment) {\n if (ch === '\\n') inLineComment = false\n continue\n }\n\n // Block comment\n if (!inString && !inBlockComment && ch === '/' && next === '*') {\n inBlockComment = true\n i++ // skip *\n continue\n }\n if (inBlockComment) {\n if (ch === '*' && next === '/') {\n inBlockComment = false\n i++ // skip /\n }\n continue\n }\n\n // String handling\n if (inString !== null) {\n if (ch === inString) {\n // Template literal allows multi-line, others don't\n inString = null\n } else if (inString !== '`' && ch === '\\n') {\n return `Unterminated string literal near offset ${i}`\n }\n continue\n }\n\n if (ch === '\"' || ch === \"'\" || ch === '`') {\n inString = ch\n continue\n }\n\n // Bracket matching\n if (ch === '(' || ch === '[' || ch === '{') {\n stack.push(ch)\n } else if (ch === ')' || ch === ']' || ch === '}') {\n const expected = pairs[ch]!\n if (stack.length === 0) {\n return `Unmatched closing \"${ch}\" near offset ${i}`\n }\n const top = stack.pop()!\n if (top !== expected) {\n return `Mismatched bracket: expected closing for \"${top}\" but found \"${ch}\" near offset ${i}`\n }\n }\n }\n\n if (inString !== null) {\n return `Unterminated string literal (opened with ${inString})`\n }\n\n if (stack.length > 0) {\n return `Unclosed bracket \"${stack[stack.length - 1]}\" — ${stack.length} unclosed bracket(s)`\n }\n\n return null\n}\n\n/**\n * Vue SFC syntax check: ensure <template>, <script>, <style> tags are balanced.\n */\nfunction checkVueSyntax(content: string): string | null {\n const tagBalance = checkTagBalance(content)\n if (tagBalance) return tagBalance\n\n // Vue-specific: check that SFC root tags are present and balanced\n for (const tag of ['template', 'script']) {\n const openRe = new RegExp(`<${tag}[\\\\s>]`, 'g')\n const closeRe = new RegExp(`</${tag}>`, 'g')\n const opens = content.match(openRe)?.length ?? 0\n const closes = content.match(closeRe)?.length ?? 0\n if (opens !== closes) {\n return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`\n }\n }\n\n return null\n}\n\n/**\n * Basic tag balance check for HTML-like files.\n * Checks that self-closing tags are handled and major structural tags are balanced.\n */\nfunction checkTagBalance(content: string): string | null {\n // Check for common structural tags balance\n const structuralTags = ['div', 'section', 'main', 'header', 'footer', 'nav', 'article', 'aside', 'ul', 'ol', 'table']\n\n for (const tag of structuralTags) {\n const openRe = new RegExp(`<${tag}[\\\\s>]`, 'g')\n const closeRe = new RegExp(`</${tag}>`, 'g')\n const opens = content.match(openRe)?.length ?? 0\n const closes = content.match(closeRe)?.length ?? 0\n if (opens !== closes) {\n return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`\n }\n }\n\n return null\n}\n\n// ─── Extract Mode ───\n\nexport async function applyExtract(\n projectRoot: string,\n input: ExtractionInput,\n): Promise<ExtractionResult> {\n const config = await readConfig(projectRoot)\n if (!config) throw new Error('Project not initialized. Run contentrain_init first.')\n\n const { extractions, dry_run } = input\n\n // Analyze what will happen\n const existingModels = await listModels(projectRoot)\n const existingIds = new Set(existingModels.map(m => m.id))\n\n const modelsToCreate: string[] = []\n const modelsToUpdate: string[] = []\n const contentFiles: string[] = []\n let totalEntries = 0\n\n const validationErrors: string[] = []\n\n for (const ext of extractions) {\n // Validate model definition with same rules as model_save\n const modelErrors = validateModelDefinition({\n id: ext.model,\n kind: ext.kind,\n fields: ext.fields as Record<string, unknown> | undefined,\n })\n if (modelErrors.length > 0) {\n validationErrors.push(...modelErrors.map(e => `[${ext.model}] ${e}`))\n }\n\n for (const entry of ext.entries) {\n if (ext.kind === 'dictionary') {\n if (entry.data['id'] !== undefined || entry.data['slug'] !== undefined) {\n validationErrors.push(`[${ext.model}] Dictionary entries should not have id or slug`)\n }\n for (const [key, val] of Object.entries(entry.data)) {\n if (typeof val !== 'string') {\n validationErrors.push(`[${ext.model}] Dictionary entry value for key \"${key}\" must be a string, got ${typeof val}`)\n }\n }\n } else if (ext.kind === 'document') {\n if (!entry.slug && !entry.data['slug']) {\n validationErrors.push(`[${ext.model}] Document entries must have a slug`)\n }\n } else if (ext.kind === 'collection') {\n if (entry.slug !== undefined || entry.data['slug'] !== undefined) {\n validationErrors.push(`[${ext.model}] Collection entries should not have slug`)\n }\n } else if (ext.kind === 'singleton') {\n if (entry.data['id'] !== undefined || entry.slug !== undefined || entry.data['slug'] !== undefined) {\n validationErrors.push(`[${ext.model}] Singleton entries should not have id or slug`)\n }\n }\n }\n\n if (existingIds.has(ext.model)) {\n modelsToUpdate.push(ext.model)\n } else {\n modelsToCreate.push(ext.model)\n }\n totalEntries += ext.entries.length\n\n // Guardrail #3: Preview-Execute Parity — use real model metadata if it exists\n let previewModel: ModelDefinition\n if (existingIds.has(ext.model)) {\n const real = await readModel(projectRoot, ext.model)\n if (real) {\n previewModel = real\n } else {\n previewModel = { id: ext.model, kind: ext.kind, domain: ext.domain, i18n: ext.i18n ?? true } as ModelDefinition\n }\n } else {\n previewModel = { id: ext.model, kind: ext.kind, domain: ext.domain, i18n: ext.i18n ?? true } as ModelDefinition\n }\n\n const cDir = resolveContentDir(projectRoot, previewModel)\n for (const entry of ext.entries) {\n const locale = entry.locale ?? config.locales.default\n if (ext.kind === 'document' && entry.slug) {\n contentFiles.push(resolveMdFilePath(cDir, previewModel, locale, entry.slug))\n } else {\n contentFiles.push(resolveJsonFilePath(cDir, previewModel, locale))\n }\n }\n }\n\n const preview: ExtractionPreview = {\n models_to_create: modelsToCreate,\n models_to_update: modelsToUpdate,\n total_entries: totalEntries,\n content_files: [...new Set(contentFiles)],\n }\n\n // Dry run — return preview only (include validation errors if any)\n if (dry_run !== false) {\n return {\n dry_run: true,\n preview,\n ...(validationErrors.length > 0 ? { validation_errors: validationErrors } : {}),\n next_steps: [\n ...(validationErrors.length > 0\n ? [`WARNING: ${validationErrors.length} validation error(s) found — fix before executing`]\n : []),\n 'Review the preview above',\n 'Call contentrain_apply with mode:extract and dry_run:false to execute',\n ],\n }\n }\n\n // Block execute if validation errors exist\n if (validationErrors.length > 0) {\n return {\n dry_run: false,\n error: 'Model validation failed — cannot execute extract with invalid model definitions',\n validation_errors: validationErrors,\n next_steps: ['Fix the validation errors and retry'],\n }\n }\n\n // Branch health gate\n const health = await checkBranchHealth(projectRoot)\n if (health.blocked) {\n return {\n error: health.message,\n action: 'blocked' as const,\n hint: 'Merge or delete old contentrain/* branches before executing normalize.',\n } as unknown as ExtractionResult\n }\n\n // Execute — git transaction (always review mode for normalize)\n const branchName = buildBranchName('normalize', 'extract')\n const tx = await createTransaction(projectRoot, branchName, { workflowOverride: 'review' })\n const sourceMap: Array<{ model: string; locale: string; value: string; file: string; line: number }> = []\n const modelsCreated: string[] = []\n const modelsUpdated: string[] = []\n let entriesWritten = 0\n\n try {\n await tx.write(async (wt) => {\n for (const ext of extractions) {\n // Create or merge model\n const existing = await readModel(wt, ext.model)\n if (existing) {\n // Merge fields: add new, keep existing\n if (ext.fields) {\n const merged = { ...existing.fields, ...ext.fields }\n // Only overwrite if new fields were actually added\n const newFieldNames = Object.keys(ext.fields).filter(k => !(k in (existing.fields ?? {})))\n if (newFieldNames.length > 0) {\n existing.fields = merged\n await writeModel(wt, existing)\n modelsUpdated.push(ext.model)\n }\n }\n } else {\n // Create new model\n const newModel: ModelDefinition = {\n id: ext.model,\n name: ext.model.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),\n kind: ext.kind,\n domain: ext.domain,\n i18n: ext.i18n ?? true,\n fields: ext.fields,\n }\n await writeModel(wt, newModel)\n modelsCreated.push(ext.model)\n }\n\n // Write content entries\n const model = (await readModel(wt, ext.model))!\n const entries: ContentEntry[] = ext.entries.map(e => ({\n locale: e.locale,\n slug: e.slug,\n data: e.data,\n }))\n\n const wtConfig = await readConfig(wt) ?? config\n await writeContent(wt, model, entries, wtConfig)\n entriesWritten += entries.length\n\n // Track source map\n for (const entry of ext.entries) {\n // For dictionary entries with per-key source tracking\n if (ext.kind === 'dictionary' && entry.sources) {\n for (const s of entry.sources) {\n sourceMap.push({\n model: ext.model,\n locale: entry.locale ?? config.locales.default,\n value: s.value,\n file: s.file,\n line: s.line,\n })\n }\n } else if (entry.source) {\n sourceMap.push({\n model: ext.model,\n locale: entry.locale ?? config.locales.default,\n value: entry.source.value,\n file: entry.source.file,\n line: entry.source.line,\n })\n }\n }\n }\n\n // Write source map for reuse scope enforcement\n if (sourceMap.length > 0) {\n const sourcesByModel: Record<string, { source_files: string[]; entry_count: number }> = {}\n for (const s of sourceMap) {\n if (!sourcesByModel[s.model]) {\n sourcesByModel[s.model] = { source_files: [], entry_count: 0 }\n }\n const modelEntry = sourcesByModel[s.model]!\n if (!modelEntry.source_files.includes(s.file)) {\n modelEntry.source_files.push(s.file)\n }\n modelEntry.entry_count++\n }\n const sourcesJson = JSON.stringify({\n version: 1,\n created_at: new Date().toISOString(),\n models: sourcesByModel,\n }, null, 2) + '\\n'\n // Write to worktree only — merge brings it to main\n // Phase 2 (reuse) must wait for extract branch to be merged first\n await writeText(join(wt, '.contentrain', 'normalize-sources.json'), sourcesJson)\n }\n\n // Update context\n await writeContext(wt, {\n tool: 'contentrain_apply',\n model: extractions.map(e => e.model).join(','),\n locale: config.locales.default,\n entries: extractions.flatMap(e => e.entries.map(en => en.slug ?? 'entry')),\n })\n })\n\n const commitMsg = `[contentrain] normalize: extract ${entriesWritten} entries to ${extractions.length} models`\n await tx.commit(commitMsg)\n\n const gitResult = { branch: branchName, action: 'pending-review', commit: '' }\n try {\n const completed = await tx.complete()\n gitResult.action = completed.action\n gitResult.commit = completed.commit\n } catch {\n gitResult.action = 'pending-review'\n } finally {\n await tx.cleanup()\n }\n\n return {\n dry_run: false,\n results: {\n models_created: modelsCreated,\n models_updated: modelsUpdated,\n entries_written: entriesWritten,\n source_map: sourceMap,\n },\n git: gitResult,\n context_updated: true,\n next_steps: [\n 'Run contentrain_validate to check the extracted content',\n 'Run contentrain_submit to push the branch for review',\n 'For browser-based review: ensure `contentrain serve` is running, direct user to http://localhost:3333/normalize',\n 'For terminal workflow: use contentrain_merge to merge the branch locally',\n 'After merge, run `npx contentrain generate` to update SDK client',\n 'After review, proceed with mode:reuse to patch source files',\n ],\n }\n } catch (error) {\n await tx.cleanup()\n throw error\n }\n}\n\n// ─── Reuse Mode ───\n\nexport async function applyReuse(\n projectRoot: string,\n input: ReuseInput,\n): Promise<ReuseResult> {\n const config = await readConfig(projectRoot)\n if (!config) throw new Error('Project not initialized. Run contentrain_init first.')\n\n const { scope, patches, dry_run } = input\n\n // Validate scope\n if (!scope.model && !scope.domain) {\n throw new Error('Scope required: provide model or domain. Whole-project patching is not allowed.')\n }\n\n // Validate patch count\n if (patches.length > MAX_PATCHES) {\n throw new Error(`Too many patches (${patches.length}). Maximum ${MAX_PATCHES} per operation. Split into multiple calls.`)\n }\n\n // Check content exists for scope (soft warning)\n if (scope.model) {\n const model = await readModel(projectRoot, scope.model)\n if (!model) {\n throw new Error(`Model \"${scope.model}\" not found. Run extract phase first.`)\n }\n }\n\n const scopeWarnings: string[] = []\n\n // Guardrail #1: Scope Real Enforcement\n // Step 1: Path safety — every patch must target a valid, patchable source file\n for (const patch of patches) {\n const pathError = validatePatchPath(patch.file)\n if (pathError) {\n throw new Error(`Invalid patch path: ${pathError}`)\n }\n }\n\n // Step 2: Semantic scope — verify scope model/domain exists and cross-check patch files\n if (scope.model || scope.domain) {\n const models = await listModels(projectRoot)\n const scopeModels = scope.model\n ? models.filter(m => m.id === scope.model)\n : scope.domain\n ? models.filter(m => m.domain === scope.domain)\n : models\n\n if (scopeModels.length === 0) {\n throw new Error(`No models found for scope ${scope.model ? `model=\"${scope.model}\"` : `domain=\"${scope.domain}\"`}`)\n }\n\n // Step 3: Verify patch files are source files (not content/config/meta files)\n // and belong to detectable source directories (not random locations)\n const { autoDetectSourceDirs } = await import('./scan-config.js')\n const sourceDirs = await autoDetectSourceDirs(projectRoot)\n\n // Build allowed file prefixes from source dirs\n const allowedPrefixes = sourceDirs.map(d => d === '.' ? '' : d + '/')\n\n for (const patch of patches) {\n const normalizedPath = patch.file.replace(/\\\\/g, '/')\n\n // Reject patches targeting .contentrain/ directory (content files should never be patched by reuse)\n if (normalizedPath.startsWith('.contentrain/') || normalizedPath.includes('/.contentrain/')) {\n throw new Error(`Cannot patch content/config files directly: \"${patch.file}\". Reuse patches source files only.`)\n }\n\n // If source dirs were detected (not just \".\"), verify patch files are within them\n if (sourceDirs.length > 0 && sourceDirs[0] !== '.') {\n const inSourceDir = allowedPrefixes.some(prefix =>\n prefix === '' || normalizedPath.startsWith(prefix),\n )\n if (!inSourceDir) {\n throw new Error(\n `Patch file \"${patch.file}\" is outside detected source directories (${sourceDirs.join(', ')}). ` +\n `Reuse patches must target source files within the project's source tree.`,\n )\n }\n }\n }\n\n // Step 4: Semantic source→model cross-check via normalize-sources.json\n // Source map is written by extract into the review branch worktree.\n // It only exists on base after extract branch is merged.\n // If missing: dry_run proceeds with warning, execute is blocked.\n if (scope.model || scope.domain) {\n const sourcesPath = join(projectRoot, '.contentrain', 'normalize-sources.json')\n const sourcesRaw = await readText(sourcesPath)\n\n if (!sourcesRaw) {\n // Source map not found — check if scoped model is a dictionary\n // Dictionaries store all keys in one entry, so per-file source tracking\n // is not available. Allow reuse with a warning instead of blocking.\n const scopedModel = scopeModels.find(m => m.id === scope.model)\n const isDictionary = scopedModel?.kind === 'dictionary'\n\n if (isDictionary) {\n scopeWarnings.push(\n 'normalize-sources.json not found. Dictionary models do not generate per-file source maps. ' +\n 'Scope enforcement is based on source-tree locality only.',\n )\n } else if (dry_run !== false) {\n scopeWarnings.push(\n 'normalize-sources.json not found. Semantic scope enforcement is unavailable. ' +\n 'Merge the extract branch first, then reuse will have full scope protection.',\n )\n } else {\n throw new Error(\n 'Cannot execute reuse: normalize-sources.json not found on base branch. ' +\n 'The extract branch must be merged before reuse can execute. ' +\n 'This ensures semantic scope enforcement protects against out-of-scope patching.',\n )\n }\n } else {\n const sourcesData = JSON.parse(sourcesRaw) as {\n models?: Record<string, { source_files?: string[] }>\n }\n\n // Collect all allowed source files for the scope\n let allowedSourceFiles: string[] = []\n let scopeLabel = ''\n\n if (scope.model) {\n const modelSources = sourcesData.models?.[scope.model]?.source_files\n if (modelSources) allowedSourceFiles = modelSources\n scopeLabel = `model \"${scope.model}\"`\n } else if (scope.domain) {\n const domainModelIds = scopeModels.map(m => m.id)\n for (const modelId of domainModelIds) {\n const modelSources = sourcesData.models?.[modelId]?.source_files\n if (modelSources) allowedSourceFiles.push(...modelSources)\n }\n allowedSourceFiles = [...new Set(allowedSourceFiles)]\n scopeLabel = `domain \"${scope.domain}\"`\n }\n\n if (allowedSourceFiles.length > 0) {\n const outOfScopePatches: string[] = []\n for (const patch of patches) {\n const normalizedPath = patch.file.replace(/\\\\/g, '/')\n if (!allowedSourceFiles.includes(normalizedPath)) {\n outOfScopePatches.push(patch.file)\n }\n }\n if (outOfScopePatches.length > 0) {\n throw new Error(\n `Scope enforcement: ${outOfScopePatches.length} patch file(s) are not associated with ${scopeLabel}. ` +\n `Out-of-scope files: ${outOfScopePatches.join(', ')}. ` +\n `Known source files: ${allowedSourceFiles.join(', ')}.`,\n )\n }\n }\n }\n }\n }\n\n // Group patches by file\n const patchesByFile = new Map<string, PatchEntry[]>()\n for (const patch of patches) {\n if (!patchesByFile.has(patch.file)) {\n patchesByFile.set(patch.file, [])\n }\n patchesByFile.get(patch.file)!.push(patch)\n }\n\n const filesToModify = [...patchesByFile.keys()]\n const importsToAdd = patches.filter(p => p.import_statement).length\n\n // Dry run — return preview only\n if (dry_run !== false) {\n return {\n dry_run: true,\n preview: {\n files_to_modify: filesToModify,\n patches_count: patches.length,\n imports_to_add: importsToAdd,\n },\n ...(scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {}),\n next_steps: [\n ...(scopeWarnings.length > 0 ? [`WARNING: ${scopeWarnings.length} patch file(s) not in extract source map — verify intent`] : []),\n 'Review the files and patches above',\n 'Call contentrain_apply with mode:reuse and dry_run:false to execute',\n ],\n }\n }\n\n // Branch health gate\n const reuseHealth = await checkBranchHealth(projectRoot)\n if (reuseHealth.blocked) {\n return {\n dry_run: false,\n error: `Branch blocked: ${reuseHealth.message}`,\n next_steps: ['Merge or delete old contentrain/* branches before executing reuse.'],\n }\n }\n\n // Execute — git transaction\n const scopeTarget = scope.model ?? scope.domain!\n const branchName = buildBranchName('normalize/reuse', scopeTarget)\n const tx = await createTransaction(projectRoot, branchName, { workflowOverride: 'review' })\n\n const filesModified: string[] = []\n let patchesApplied = 0\n let importsAdded = 0\n const patchesSkipped: Array<{ file: string; line: number; reason: string }> = []\n const frameworkWarnings: Array<{ file: string; warning: string }> = []\n const syntaxErrors: SyntaxError[] = []\n\n try {\n await tx.write(async (wt) => {\n for (const [relFile, filePatches] of patchesByFile) {\n const absPath = join(wt, relFile)\n\n if (!(await pathExists(absPath))) {\n for (const p of filePatches) {\n patchesSkipped.push({ file: relFile, line: p.line, reason: 'file not found' })\n }\n continue\n }\n\n const content = await readText(absPath)\n if (content === null) {\n for (const p of filePatches) {\n patchesSkipped.push({ file: relFile, line: p.line, reason: 'file unreadable' })\n }\n continue\n }\n\n // Sort patches by line DESC (bottom-up to avoid line shifts)\n const sorted = [...filePatches].toSorted((a, b) => b.line - a.line)\n\n const lines = content.split('\\n')\n let fileModified = false\n\n for (const patch of sorted) {\n // Determine replacement context before applying\n const targetLine = lines[patch.line - 1] ?? ''\n const isTagTextContext = targetLine.includes(`>${patch.old_value}<`)\n const replacementContext = isTagTextContext ? 'tag_text' as const : 'other' as const\n\n // Guardrail #2: Framework-aware validation — only warn for tag text replacements\n if (isTagTextContext) {\n const fwWarning = validateFrameworkExpression(relFile, patch.new_expression, replacementContext)\n if (fwWarning) {\n frameworkWarnings.push({ file: relFile, warning: fwWarning })\n }\n }\n\n const applied = applyPatchToLines(lines, patch)\n if (applied) {\n patchesApplied++\n fileModified = true\n } else {\n patchesSkipped.push({ file: relFile, line: patch.line, reason: 'old_value not found at or near specified line' })\n }\n }\n\n // Add imports (deduplicate)\n const importStatements = new Set(\n filePatches\n .filter(p => p.import_statement)\n .map(p => p.import_statement!),\n )\n\n if (importStatements.size > 0) {\n const added = addImportsToLines(lines, importStatements)\n importsAdded += added\n if (added > 0) fileModified = true\n }\n\n if (fileModified) {\n const newContent = lines.join('\\n')\n await writeText(absPath, newContent)\n filesModified.push(relFile)\n\n // Guardrail #5: Syntax check after patching\n const syntaxError = checkSyntax(relFile, newContent)\n if (syntaxError) {\n syntaxErrors.push({ file: relFile, error: syntaxError })\n }\n }\n }\n\n // Update context\n await writeContext(wt, {\n tool: 'contentrain_apply',\n model: scopeTarget,\n locale: config.locales.default,\n })\n })\n\n if (filesModified.length === 0) {\n await tx.cleanup()\n return {\n dry_run: false,\n results: {\n files_modified: [],\n patches_applied: 0,\n patches_skipped: patchesSkipped,\n imports_added: 0,\n framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : undefined,\n },\n next_steps: ['No files were modified. Check patch definitions and try again.'],\n }\n }\n\n const commitMsg = `[contentrain] normalize: reuse ${scopeTarget} — patch ${filesModified.length} files (${patchesApplied} replacements)`\n await tx.commit(commitMsg)\n\n const gitResult = { branch: branchName, action: 'pending-review', commit: '' }\n try {\n const completed = await tx.complete()\n gitResult.action = completed.action\n gitResult.commit = completed.commit\n } catch {\n gitResult.action = 'pending-review'\n } finally {\n await tx.cleanup()\n }\n\n return {\n dry_run: false,\n results: {\n files_modified: filesModified,\n patches_applied: patchesApplied,\n patches_skipped: patchesSkipped,\n imports_added: importsAdded,\n framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : undefined,\n syntax_errors: syntaxErrors.length > 0 ? syntaxErrors : undefined,\n },\n ...(scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {}),\n git: gitResult,\n next_steps: [\n 'Run contentrain_validate to verify the patched files',\n patchesSkipped.length > 0 ? `${patchesSkipped.length} patches were skipped — review and retry if needed` : '',\n syntaxErrors.length > 0 ? `WARNING: ${syntaxErrors.length} file(s) may have syntax errors after patching — review manually` : '',\n scopeWarnings.length > 0 ? `NOTE: ${scopeWarnings.length} patch file(s) not in extract source map` : '',\n 'Run contentrain_submit to push the branch for review',\n 'For review: direct user to http://localhost:3333/branches or use contentrain_merge',\n 'After all reuse phases complete, run `npx contentrain generate` to update SDK types',\n ].filter(Boolean),\n }\n } catch (error) {\n await tx.cleanup()\n throw error\n }\n}\n\n// ─── Patch Helpers ───\n\n/**\n * Apply a single patch to a lines array. Mutates lines in place.\n * Uses line hint for proximity matching — searches ±10 lines from hint.\n */\nfunction applyPatchToLines(lines: string[], patch: PatchEntry): boolean {\n const { line, old_value, new_expression } = patch\n const lineIdx = line - 1 // 0-based\n\n // Search range: ±10 lines from hint\n const searchStart = Math.max(0, lineIdx - 10)\n const searchEnd = Math.min(lines.length, lineIdx + 11)\n\n // First pass: exact line match\n if (lineIdx >= 0 && lineIdx < lines.length) {\n const replaced = replaceInLine(lines[lineIdx]!, old_value, new_expression)\n if (replaced !== null) {\n lines[lineIdx] = replaced\n return true\n }\n }\n\n // Second pass: proximity search\n for (let i = searchStart; i < searchEnd; i++) {\n if (i === lineIdx) continue // already tried\n const replaced = replaceInLine(lines[i]!, old_value, new_expression)\n if (replaced !== null) {\n lines[i] = replaced\n return true\n }\n }\n\n return false\n}\n\n/**\n * Replace old_value with new_expression in a single line.\n * Matches the string literal (quoted or unquoted tag text).\n * Returns the modified line, or null if not found.\n *\n * Guardrail #4: Safer patch matching — word boundary awareness and\n * ambiguity rejection for plain text fallback.\n */\nexport function replaceInLine(line: string, oldValue: string, newExpression: string): string | null {\n // Try exact match of the value in quoted strings\n // Match: \"old_value\", 'old_value', `old_value`\n for (const quote of ['\"', \"'\", '`']) {\n const quoted = `${quote}${oldValue}${quote}`\n if (line.includes(quoted)) {\n return line.replace(quoted, newExpression)\n }\n }\n\n // Try unquoted tag text match: >old_value<\n // The agent provides the complete new_expression with correct framework syntax:\n // JSX: {t('key')}\n // Vue: {{ $t('key') }}\n // Svelte: {$t('key')}\n // So we insert the expression as-is between > and <, without wrapping in braces.\n if (line.includes(`>${oldValue}<`)) {\n return line.replace(`>${oldValue}<`, `>${newExpression}<`)\n }\n\n // Guardrail #4: Safer plain text fallback\n // Only match if old_value appears at a word boundary and is unambiguous\n if (line.includes(oldValue)) {\n // Count occurrences — if multiple, it's ambiguous\n const occurrences = countOccurrences(line, oldValue)\n if (occurrences > 1) {\n return null // ambiguous — let proximity search handle it\n }\n\n // Word boundary check: don't replace \"Submit\" inside \"SubmitButton\"\n // Reject if the old_value is a substring of a larger word — check if\n // adjacent characters are word characters that extend the token.\n const idx = line.indexOf(oldValue)\n const charBefore = idx > 0 ? line[idx - 1]! : ''\n const charAfter = idx + oldValue.length < line.length ? line[idx + oldValue.length]! : ''\n\n const oldStartsWithWord = oldValue.length > 0 && isWordChar(oldValue[0]!)\n const oldEndsWithWord = oldValue.length > 0 && isWordChar(oldValue[oldValue.length - 1]!)\n\n // If old_value starts with a word char and char before is also word char, it's a partial match\n if (oldStartsWithWord && isWordChar(charBefore)) {\n return null\n }\n // If old_value ends with a word char and char after is also word char, it's a partial match\n if (oldEndsWithWord && isWordChar(charAfter)) {\n return null\n }\n\n return line.replace(oldValue, newExpression)\n }\n\n return null\n}\n\n/** Count non-overlapping occurrences of a substring */\nfunction countOccurrences(str: string, sub: string): number {\n let count = 0\n let pos = 0\n while (pos <= str.length - sub.length) {\n const idx = str.indexOf(sub, pos)\n if (idx === -1) break\n count++\n pos = idx + sub.length\n }\n return count\n}\n\n/** Check if a character is a word character (letter, digit, underscore) */\nfunction isWordChar(ch: string): boolean {\n return /\\w/.test(ch)\n}\n\n/**\n * Add import statements to the top of a file (after existing imports).\n * Deduplicates — won't add if the import already exists.\n * Returns number of imports actually added.\n */\nfunction addImportsToLines(lines: string[], imports: Set<string>): number {\n let added = 0\n const existingContent = lines.join('\\n')\n\n // Find the last import line position (handles multi-line imports)\n let lastImportIdx = -1\n let inMultiLineImport = false\n for (let i = 0; i < lines.length; i++) {\n const trimmed = lines[i]!.trim()\n if (inMultiLineImport) {\n lastImportIdx = i\n if (trimmed.includes('}')) inMultiLineImport = false\n continue\n }\n if (trimmed.startsWith('import ') || trimmed.startsWith('import{')) {\n lastImportIdx = i\n // Check if this is a multi-line import (has { but no closing })\n if (trimmed.includes('{') && !trimmed.includes('}')) {\n inMultiLineImport = true\n }\n continue\n }\n // Stop searching after a non-import, non-empty, non-comment line following imports\n if (lastImportIdx >= 0 && trimmed.length > 0 && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*')) {\n break\n }\n }\n\n let insertAt: number\n if (lastImportIdx >= 0) {\n insertAt = lastImportIdx + 1\n } else {\n // No existing imports found — insert after shebang and directive lines\n insertAt = 0\n for (let i = 0; i < lines.length; i++) {\n const trimmed = lines[i]!.trim()\n if (i === 0 && trimmed.startsWith('#!')) {\n insertAt = i + 1\n continue\n }\n if (trimmed === \"'use client'\" || trimmed === '\"use client\"'\n || trimmed === \"'use server'\" || trimmed === '\"use server\"'\n || trimmed === \"'use client';\" || trimmed === '\"use client\";'\n || trimmed === \"'use server';\" || trimmed === '\"use server\";') {\n insertAt = i + 1\n continue\n }\n if (insertAt > 0 && trimmed.length > 0) break\n if (insertAt === 0 && trimmed.length > 0) break\n }\n }\n const toInsert: string[] = []\n\n for (const imp of imports) {\n // Check if this import already exists (by checking the from clause)\n if (!existingContent.includes(imp)) {\n toInsert.push(imp)\n added++\n }\n }\n\n if (toInsert.length > 0) {\n lines.splice(insertAt, 0, ...toInsert)\n }\n\n return added\n}\n"],"mappings":"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"}
import { t as readConfig } from "./config-DlFcTxFG.mjs";
import { CONTENTRAIN_BRANCH } from "@contentrain/types";
import { simpleGit } from "simple-git";
//#region src/git/identity.ts
/**
* Git identity + guard-safe `simple-git` construction for MCP write operations.
*
* simple-git >= 3.34 bundles `@simple-git/argv-parser`, whose block-unsafe
* guard rejects a `git` invocation when any of ~18 "unsafe" variables (EDITOR,
* GIT_ASKPASS, PAGER, GIT_SSH_COMMAND, GIT_PROXY_COMMAND, …) is passed
* EXPLICITLY through `.env()`. Crucially, the guard only scans the object
* handed to `.env()` — it never inspects the inherited process environment.
*
* The rule this module enforces: NEVER spread `process.env` into `.env()`.
* - Commit identity is supplied as `-c user.*` config (guard-safe: `user.name`
* / `user.email` are not on any unsafe list, and git honours them for both
* the author and the committer). See {@link authorConfig}.
* - The rare instance that genuinely needs the inherited environment — network
* push/fetch, which relies on the host's askpass/SSH/proxy setup to
* authenticate — opts out of the affected guard categories via `unsafe`
* instead of hiding the environment. See {@link NETWORK_UNSAFE}.
*/
const DEFAULT_AUTHOR_NAME = "Contentrain";
const DEFAULT_AUTHOR_EMAIL = "ai@contentrain.io";
/**
* Commit identity as `-c` config entries for `simpleGit(dir, { config })`.
* Passed as arguments (not env) so the block-unsafe guard is never triggered,
* regardless of what the host process exports. Sets author + committer alike.
*/
function authorConfig() {
const name = process.env["CONTENTRAIN_AUTHOR_NAME"] ?? DEFAULT_AUTHOR_NAME;
const email = process.env["CONTENTRAIN_AUTHOR_EMAIL"] ?? DEFAULT_AUTHOR_EMAIL;
return [`user.name=${name}`, `user.email=${email}`];
}
/**
* Guard opt-outs for network `git` instances that MUST inherit the real
* environment (credential askpass helpers, SSH agent, proxy) to authenticate a
* push/fetch. Covers every guard category reachable from an inherited env var,
* so the command never trips regardless of what the host (VS Code, CI) exports
* — while still leaving arg-injection protections (custom binaries, `ext::`
* protocol, `--upload-pack`) intact.
*/
const NETWORK_UNSAFE = {
allowUnsafeAskPass: true,
allowUnsafeConfigEnvCount: true,
allowUnsafeConfigPaths: true,
allowUnsafeDiffExternal: true,
allowUnsafeEditor: true,
allowUnsafeGitProxy: true,
allowUnsafePager: true,
allowUnsafeSshCommand: true,
allowUnsafeTemplateDir: true
};
//#endregion
//#region src/git/branch-lifecycle.ts
/**
* Lists all local contentrain/* branches, deletes those already merged
* into the base branch, and returns the count of remaining unmerged ones.
*/
async function cleanupMergedBranches(projectRoot) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
const baseBranch = config?.repository?.default_branch ?? process.env["CONTENTRAIN_BRANCH"] ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
const contentrainBranches = (await git.branchLocal()).all.filter((b) => b.startsWith("cr/")).filter((b) => b !== CONTENTRAIN_BRANCH);
if (contentrainBranches.length === 0) return {
deleted: 0,
remaining: 0,
deletedBranches: []
};
let mergedSet;
try {
mergedSet = await classifyMergedBranches(projectRoot, contentrainBranches, CONTENTRAIN_BRANCH);
} catch {
try {
mergedSet = await classifyMergedBranches(projectRoot, contentrainBranches, baseBranch);
} catch {
return {
deleted: 0,
remaining: contentrainBranches.length,
deletedBranches: []
};
}
}
const mergedContentrain = contentrainBranches.filter((b) => mergedSet.has(b));
const deletedBranches = [];
const retentionMs = (config?.branchRetention ?? 30) * 24 * 60 * 60 * 1e3;
const now = Date.now();
for (const branch of mergedContentrain) try {
const timestampRaw = (await git.raw([
"log",
"-1",
"--format=%ct",
branch
])).trim();
if (now - Number(timestampRaw) * 1e3 < retentionMs) continue;
await git.raw([
"branch",
"-D",
branch
]);
deletedBranches.push(branch);
} catch {}
const remaining = contentrainBranches.length - deletedBranches.length;
return {
deleted: deletedBranches.length,
remaining,
deletedBranches
};
}
/**
* Check branch health: count contentrain/* branches and return warning/blocked status.
* - 50+ branches: warning
* - 80+ branches: blocked
*/
async function checkBranchHealth(projectRoot) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
const baseBranch = config?.repository?.default_branch ?? process.env["CONTENTRAIN_BRANCH"] ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
const contentrainBranches = (await git.branchLocal()).all.filter((b) => b.startsWith("cr/")).filter((b) => b !== CONTENTRAIN_BRANCH);
const total = contentrainBranches.length;
const warnLimit = config?.branchWarnLimit ?? 50;
const blockLimit = config?.branchBlockLimit ?? 80;
let mergedCount = 0;
const classifyOpts = { fallbackThreshold: warnLimit };
try {
mergedCount = (await classifyMergedBranches(projectRoot, contentrainBranches, CONTENTRAIN_BRANCH, classifyOpts)).size;
} catch {
try {
mergedCount = (await classifyMergedBranches(projectRoot, contentrainBranches, baseBranch, classifyOpts)).size;
} catch {}
}
const unmerged = total - mergedCount;
const warning = unmerged >= warnLimit;
const blocked = unmerged >= blockLimit;
let message;
if (blocked) message = `BLOCKED: ${unmerged} active contentrain branches (limit: ${blockLimit}). Run cleanup or merge/delete old branches before creating new ones.`;
else if (warning) message = `WARNING: ${unmerged} active contentrain branches. Consider merging or deleting old branches (warning at ${warnLimit}, blocked at ${blockLimit}).`;
return {
total,
merged: mergedCount,
unmerged,
warning,
blocked,
message
};
}
/**
* Compute the diff between a feature branch and its base.
*
* Defaults `base` to `CONTENTRAIN_BRANCH` — the singleton content-
* tracking branch every feature branch forks from. Passing the repo's
* default branch (e.g. `main`) is almost always a bug: when
* `contentrain` is ahead of `main`, the diff picks up unrelated
* historical content changes that the feature branch did not produce.
*
* Used by `contentrain serve` (branch detail view), the `contentrain
* diff` CLI command, and any Studio-side driver that needs to preview
* a feature branch before approving it.
*/
async function branchDiff(projectRoot, opts) {
const git = simpleGit(projectRoot);
const base = opts.base ?? CONTENTRAIN_BRANCH;
const range = `${base}...${opts.branch}`;
const [stat, patch, summary] = await Promise.all([
git.diff([range, "--stat"]),
git.diff([range]),
git.diffSummary([range])
]);
return {
branch: opts.branch,
base,
stat,
patch,
filesChanged: summary.changed
};
}
/**
* Merged-verdict caches.
*
* - `pairVerdictCache` — keyed by `(tipSha, intoTipSha, cap)`: the
* relationship between two FIXED commits never changes, so entries are
* permanently valid.
* - `mergedTipCache` — keyed by `(tipSha, into NAME)`: once a tip is merged
* into a branch, it stays merged as that branch advances (merged-ness is
* monotonic), so positives survive `contentrain` moving forward. This is
* what keeps the per-write branch-health gate cheap in long-lived
* processes (MCP server, `contentrain serve`).
*/
const pairVerdictCache = /* @__PURE__ */ new Map();
const mergedTipCache = /* @__PURE__ */ new Map();
const MERGED_CACHE_LIMIT = 1e4;
const DEFAULT_MAX_CHERRY_COMMITS = 200;
/** Concurrent `git cherry` subprocesses during classification. */
const CHERRY_CONCURRENCY = 8;
async function isTipMerged(git, tip, intoTip, maxCherryCommits, intoName) {
const tipKey = intoName ? `${tip}→${intoName}` : void 0;
if (tipKey && mergedTipCache.has(tipKey)) return true;
const pairKey = `${tip}:${intoTip}:${maxCherryCommits}`;
const cached = pairVerdictCache.get(pairKey);
if (cached !== void 0) return cached;
let merged = false;
try {
const lines = (await git.raw([
"cherry",
intoTip,
tip
])).split("\n").map((line) => line.trim()).filter(Boolean);
if (lines.length === 0) merged = true;
else if (lines.length <= maxCherryCommits) merged = !lines.some((line) => line.startsWith("+"));
} catch {
merged = false;
}
if (pairVerdictCache.size >= MERGED_CACHE_LIMIT) pairVerdictCache.clear();
pairVerdictCache.set(pairKey, merged);
if (merged && tipKey) {
if (mergedTipCache.size >= MERGED_CACHE_LIMIT) mergedTipCache.clear();
mergedTipCache.set(tipKey, true);
}
return merged;
}
/**
* Robust merged check for a single ref: ancestry fast-path, then a bounded
* `git cherry` (patch-id) fallback that survives base-history rewrites.
* Returns false when either ref cannot be resolved.
*/
async function isRefMerged(git, ref, into, opts) {
let tip;
let intoTip;
try {
const lines = (await git.raw([
"rev-parse",
ref,
into
])).trim().split("\n");
tip = lines[0]?.trim();
intoTip = lines[1]?.trim();
} catch {
return false;
}
if (!tip || !intoTip) return false;
return isTipMerged(git, tip, intoTip, opts?.maxCherryCommits ?? DEFAULT_MAX_CHERRY_COMMITS, into);
}
/**
* Classify which of the given local branches are merged into `into`
* (default: the contentrain branch). One `git branch --merged` call covers
* the ancestry-merged majority; only the remainder pays the patch-id
* fallback (bounded concurrency, verdicts cached).
*
* `opts.fallbackThreshold` skips the patch-id fallback entirely when fewer
* than that many branches are ancestry-unmerged — the fallback can only
* LOWER the unmerged count, so callers that merely compare the count
* against a limit (the hot pre-write gate) pay nothing in the normal case.
*
* Throws when `into` does not resolve — callers use this to fall back to
* the base branch (mirrors the previous `branch --merged` semantics).
*/
async function classifyMergedBranches(projectRoot, branches, into = CONTENTRAIN_BRANCH, opts) {
const git = simpleGit(projectRoot);
if (branches.length === 0) {
await git.raw([
"branch",
"--merged",
into
]);
return /* @__PURE__ */ new Set();
}
const mergedRaw = await git.raw([
"branch",
"--merged",
into
]);
const ancestryMerged = new Set(mergedRaw.split("\n").map((b) => b.replace(/^\*?\s+/, "").trim()).filter(Boolean));
const merged = /* @__PURE__ */ new Set();
const rest = [];
for (const branch of branches) if (ancestryMerged.has(branch)) merged.add(branch);
else rest.push(branch);
if (rest.length === 0 || rest.length < (opts?.fallbackThreshold ?? 0)) return merged;
let tips;
try {
tips = (await git.raw([
"rev-parse",
into,
...rest
])).trim().split("\n").map((s) => s.trim());
} catch {
return merged;
}
const intoTip = tips[0];
if (!intoTip) return merged;
for (let i = 0; i < rest.length; i += CHERRY_CONCURRENCY) {
const chunk = rest.slice(i, i + CHERRY_CONCURRENCY);
const verdicts = await Promise.all(chunk.map((branch, j) => {
const tip = tips[i + j + 1];
return tip ? isTipMerged(simpleGit(projectRoot), tip, intoTip, DEFAULT_MAX_CHERRY_COMMITS, into) : Promise.resolve(false);
}));
for (const [j, verdict] of verdicts.entries()) if (verdict) merged.add(chunk[j]);
}
return merged;
}
const REMOTE_PUSH_TIMEOUT_MS = 1e4;
const REMOTE_LIST_TIMEOUT_MS = 5e3;
function contentrainRemoteName() {
return process.env["CONTENTRAIN_REMOTE"] ?? "origin";
}
/**
* simple-git instance hardened for network operations: `timeout.block` kills
* the child after N ms without output (hung SSH passphrase prompts), and
* `GIT_TERMINAL_PROMPT=0` refuses interactive HTTPS credential prompts.
*
* This is the one place that legitimately inherits the real environment — push
* needs the host's askpass/SSH/proxy setup to authenticate. Because `.env()` is
* therefore unavoidable, `unsafe` opts out of the block-unsafe guard categories
* that inherited variables (EDITOR, GIT_ASKPASS, …) would otherwise trip; see
* NETWORK_UNSAFE. Arg-injection protections stay intact.
*/
function networkGit(projectRoot, timeoutMs) {
return simpleGit({
baseDir: projectRoot,
timeout: { block: timeoutMs },
unsafe: NETWORK_UNSAFE
}).env({
...process.env,
GIT_TERMINAL_PROMPT: "0"
});
}
async function resolveRemote(git) {
try {
const remotes = await git.getRemotes();
const name = contentrainRemoteName();
return remotes.some((r) => r.name === name) ? name : null;
} catch {
return null;
}
}
/**
* Best-effort delete of a cr/* branch on the configured remote. Never
* throws: expected conditions land in `skipped`, real failures in
* `warning`. Gated by `config.remoteBranchCleanup` (default: on).
*
* Pass `opts.config` when the caller already read it (avoids a re-read);
* `null` means "no config" and applies the default gate.
*/
async function deleteRemoteBranch(projectRoot, branch, opts) {
if (!branch.startsWith("cr/") || branch === CONTENTRAIN_BRANCH) return {
deleted: false,
skipped: "protected"
};
if (!((opts?.config === void 0 ? await readConfig(projectRoot) : opts.config)?.remoteBranchCleanup ?? true)) return {
deleted: false,
skipped: "disabled"
};
const git = networkGit(projectRoot, opts?.timeoutMs ?? REMOTE_PUSH_TIMEOUT_MS);
const remote = await resolveRemote(git);
if (!remote) return {
deleted: false,
skipped: "no-remote"
};
try {
await git.push([
remote,
"--delete",
branch
]);
return { deleted: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/remote ref does not exist|couldn't find remote ref/i.test(message)) return {
deleted: false,
skipped: "not-found"
};
return {
deleted: false,
warning: `Could not delete "${branch}" on ${remote}: ${message}`
};
}
}
/**
* Authoritative list of cr/* branches on the configured remote via
* `ls-remote --heads` (no fetch, no stale remote-tracking refs). Returns
* null when no remote is configured. Never throws.
*/
async function listRemoteCrBranches(projectRoot, opts) {
const git = networkGit(projectRoot, opts?.timeoutMs ?? REMOTE_LIST_TIMEOUT_MS);
const remote = await resolveRemote(git);
if (!remote) return null;
try {
return {
remote,
branches: (await git.raw([
"ls-remote",
"--heads",
remote,
"refs/heads/cr/*"
])).split("\n").map((line) => line.trim()).filter(Boolean).map((line) => {
const [sha, ref] = line.split(" ");
return {
sha: sha?.trim() ?? "",
name: ref?.trim().replace(/^refs\/heads\//, "") ?? ""
};
}).filter((b) => b.name.startsWith("cr/") && b.name !== CONTENTRAIN_BRANCH)
};
} catch (error) {
return {
remote,
branches: [],
error: error instanceof Error ? error.message : String(error)
};
}
}
const PRUNE_PUSH_CHUNK = 50;
/**
* Delete already-merged cr/* branches on the remote in batches. Merged-state
* uses the same ancestry + patch-id classification as the local cleanup, so
* branches leaked before a base-history rewrite are still recognised.
* Ignores `branchRetention` — a merged remote copy only produces phantom
* reviews. Never throws; gated by `config.remoteBranchCleanup`.
*/
async function pruneMergedRemoteBranches(projectRoot, opts) {
if (!((opts?.config === void 0 ? await readConfig(projectRoot) : opts.config)?.remoteBranchCleanup ?? true)) return {
deleted: [],
kept: [],
errors: [],
skipped: "disabled"
};
const listed = await listRemoteCrBranches(projectRoot, { timeoutMs: opts?.timeoutMs });
if (!listed) return {
deleted: [],
kept: [],
errors: [],
skipped: "no-remote"
};
if (listed.error) return {
deleted: [],
kept: [],
errors: [listed.error],
skipped: "offline"
};
if (listed.branches.length === 0) return {
deleted: [],
kept: [],
errors: []
};
const git = networkGit(projectRoot, opts?.timeoutMs ?? REMOTE_PUSH_TIMEOUT_MS);
if ((await Promise.all(listed.branches.map((b) => git.raw([
"cat-file",
"-t",
b.sha
]).then(() => true).catch(() => false)))).some((present) => !present)) try {
await git.fetch(listed.remote, `+refs/heads/cr/*:refs/remotes/${listed.remote}/cr/*`);
} catch {}
const kept = [];
const candidates = [];
const verdicts = await Promise.all(listed.branches.map((b) => isRefMerged(git, b.sha, CONTENTRAIN_BRANCH)));
for (const [i, merged] of verdicts.entries()) {
const name = listed.branches[i].name;
if (merged) candidates.push(name);
else kept.push(name);
}
const limit = opts?.max ?? Number.POSITIVE_INFINITY;
const toDelete = candidates.slice(0, limit);
kept.push(...candidates.slice(toDelete.length));
if (opts?.dryRun) return {
deleted: toDelete,
kept,
errors: []
};
const deleted = [];
const errors = [];
for (let i = 0; i < toDelete.length; i += PRUNE_PUSH_CHUNK) {
const chunk = toDelete.slice(i, i + PRUNE_PUSH_CHUNK);
try {
await git.push([
listed.remote,
"--delete",
...chunk
]);
deleted.push(...chunk);
} catch {
for (const branch of chunk) try {
await git.push([
listed.remote,
"--delete",
branch
]);
deleted.push(branch);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/remote ref does not exist|couldn't find remote ref/i.test(message)) deleted.push(branch);
else errors.push(`${branch}: ${message}`);
}
}
}
return {
deleted,
kept,
errors
};
}
//#endregion
export { deleteRemoteBranch as a, pruneMergedRemoteBranches as c, cleanupMergedBranches as i, authorConfig as l, checkBranchHealth as n, isRefMerged as o, classifyMergedBranches as r, listRemoteCrBranches as s, branchDiff as t };
//# sourceMappingURL=branch-lifecycle-Dd84lx37.mjs.map
{"version":3,"file":"branch-lifecycle-Dd84lx37.mjs","names":[],"sources":["../src/git/identity.ts","../src/git/branch-lifecycle.ts"],"sourcesContent":["/**\n * Git identity + guard-safe `simple-git` construction for MCP write operations.\n *\n * simple-git >= 3.34 bundles `@simple-git/argv-parser`, whose block-unsafe\n * guard rejects a `git` invocation when any of ~18 \"unsafe\" variables (EDITOR,\n * GIT_ASKPASS, PAGER, GIT_SSH_COMMAND, GIT_PROXY_COMMAND, …) is passed\n * EXPLICITLY through `.env()`. Crucially, the guard only scans the object\n * handed to `.env()` — it never inspects the inherited process environment.\n *\n * The rule this module enforces: NEVER spread `process.env` into `.env()`.\n * - Commit identity is supplied as `-c user.*` config (guard-safe: `user.name`\n * / `user.email` are not on any unsafe list, and git honours them for both\n * the author and the committer). See {@link authorConfig}.\n * - The rare instance that genuinely needs the inherited environment — network\n * push/fetch, which relies on the host's askpass/SSH/proxy setup to\n * authenticate — opts out of the affected guard categories via `unsafe`\n * instead of hiding the environment. See {@link NETWORK_UNSAFE}.\n */\n\nconst DEFAULT_AUTHOR_NAME = 'Contentrain'\nconst DEFAULT_AUTHOR_EMAIL = 'ai@contentrain.io'\n\n/**\n * Commit identity as `-c` config entries for `simpleGit(dir, { config })`.\n * Passed as arguments (not env) so the block-unsafe guard is never triggered,\n * regardless of what the host process exports. Sets author + committer alike.\n */\nexport function authorConfig(): string[] {\n const name = process.env['CONTENTRAIN_AUTHOR_NAME'] ?? DEFAULT_AUTHOR_NAME\n const email = process.env['CONTENTRAIN_AUTHOR_EMAIL'] ?? DEFAULT_AUTHOR_EMAIL\n return [`user.name=${name}`, `user.email=${email}`]\n}\n\n/**\n * Guard opt-outs for network `git` instances that MUST inherit the real\n * environment (credential askpass helpers, SSH agent, proxy) to authenticate a\n * push/fetch. Covers every guard category reachable from an inherited env var,\n * so the command never trips regardless of what the host (VS Code, CI) exports\n * — while still leaving arg-injection protections (custom binaries, `ext::`\n * protocol, `--upload-pack`) intact.\n */\nexport const NETWORK_UNSAFE = {\n allowUnsafeAskPass: true,\n allowUnsafeConfigEnvCount: true,\n allowUnsafeConfigPaths: true,\n allowUnsafeDiffExternal: true,\n allowUnsafeEditor: true,\n allowUnsafeGitProxy: true,\n allowUnsafePager: true,\n allowUnsafeSshCommand: true,\n allowUnsafeTemplateDir: true,\n}\n","import { simpleGit, type SimpleGit } from 'simple-git'\nimport { CONTENTRAIN_BRANCH, type ContentrainConfig } from '@contentrain/types'\nimport { readConfig } from '../core/config.js'\nimport { NETWORK_UNSAFE } from './identity.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 *\n * This is the one place that legitimately inherits the real environment — push\n * needs the host's askpass/SSH/proxy setup to authenticate. Because `.env()` is\n * therefore unavoidable, `unsafe` opts out of the block-unsafe guard categories\n * that inherited variables (EDITOR, GIT_ASKPASS, …) would otherwise trip; see\n * NETWORK_UNSAFE. Arg-injection protections stay intact.\n */\nfunction networkGit(projectRoot: string, timeoutMs: number): SimpleGit {\n return simpleGit({ baseDir: projectRoot, timeout: { block: timeoutMs }, unsafe: NETWORK_UNSAFE })\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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"}
import { a as readJson, i as readDir, o as readText, r as pathExists, t as contentrainDir } from "./fs-DLbVB-Ek.mjs";
import { t as readConfig } from "./config-DlFcTxFG.mjs";
import { g as resolveLocaleStrategy, h as resolveJsonFilePath, m as resolveContentDir, o as listModels, s as readModel } from "./model-manager-BhLsUgaB.mjs";
import { n as checkBranchHealth, s as listRemoteCrBranches } from "./branch-lifecycle-Dd84lx37.mjs";
import { i as autoDetectSourceDirs, o as discoverFiles } from "./scan-config-BGUflS8t.mjs";
import { join } from "node:path";
import { stat } from "node:fs/promises";
import { simpleGit } from "simple-git";
//#region src/core/doctor.ts
async function runDoctor(projectRoot, options = {}) {
const checks = [];
try {
const version = await simpleGit(projectRoot).version();
checks.push({
name: "Git",
pass: true,
detail: `v${version.major}.${version.minor}.${version.patch}`
});
} catch {
checks.push({
name: "Git",
pass: false,
detail: "Not installed or not in PATH",
severity: "error"
});
}
const hasGit = await pathExists(join(projectRoot, ".git"));
checks.push({
name: "Git repository",
pass: hasGit,
detail: hasGit ? projectRoot : "No .git directory found",
severity: hasGit ? void 0 : "error"
});
const nodeVersion = process.versions.node;
const [major] = nodeVersion.split(".").map(Number);
const nodePass = (major ?? 0) >= 22;
checks.push({
name: "Node.js",
pass: nodePass,
detail: `v${nodeVersion}${nodePass ? "" : " (requires ≥22)"}`,
severity: nodePass ? void 0 : "error"
});
const crDir = contentrainDir(projectRoot);
const hasCrDir = await pathExists(crDir);
const hasConfig = await pathExists(join(crDir, "config.json"));
const hasModels = await pathExists(join(crDir, "models"));
const hasContent = await pathExists(join(crDir, "content"));
const structurePass = hasCrDir && hasConfig && hasModels && hasContent;
checks.push({
name: ".contentrain/ structure",
pass: structurePass,
detail: !hasCrDir ? "Not initialized — run `contentrain init`" : [
hasConfig ? null : "missing config.json",
hasModels ? null : "missing models/",
hasContent ? null : "missing content/"
].filter(Boolean).join(", ") || "OK",
severity: structurePass ? void 0 : "error"
});
let config = null;
if (hasConfig) {
config = await readConfig(projectRoot);
checks.push({
name: "Config",
pass: config !== null,
detail: config ? `stack: ${config.stack}, locales: ${config.locales.supported.join(", ")}` : "Failed to parse config.json",
severity: config ? void 0 : "error"
});
}
if (hasCrDir) try {
const models = await listModels(projectRoot);
const allParseable = (await Promise.all(models.map((m) => readModel(projectRoot, m.id)))).every((r) => r !== null);
checks.push({
name: "Models",
pass: allParseable,
detail: `${models.length} model(s)${allParseable ? ", all valid" : ", some failed to parse"}`,
severity: allParseable ? void 0 : "error"
});
} catch {
checks.push({
name: "Models",
pass: false,
detail: "Failed to read models",
severity: "error"
});
}
if (hasCrDir) {
const orphans = await findOrphanContent(projectRoot);
checks.push({
name: "Orphan content",
pass: orphans.length === 0,
detail: orphans.length === 0 ? "None" : `Found: ${orphans.join(", ")}`,
severity: orphans.length === 0 ? void 0 : "warning"
});
}
if (hasGit) {
try {
const health = await checkBranchHealth(projectRoot);
checks.push({
name: "Pending branches",
pass: !health.blocked && !health.warning,
detail: health.message ?? (health.unmerged === 0 ? "None" : `${health.unmerged} active cr/* branch(es)`),
severity: health.blocked ? "error" : health.warning ? "warning" : void 0
});
} catch {
checks.push({
name: "Pending branches",
pass: true,
detail: "Could not check"
});
}
const remoteList = await listRemoteCrBranches(projectRoot, { timeoutMs: 5e3 });
if (remoteList) if (remoteList.error) checks.push({
name: "Remote branches",
pass: true,
detail: `Could not check ${remoteList.remote} (offline?)`,
severity: "info"
});
else {
const count = remoteList.branches.length;
const warn = count >= (config?.branchWarnLimit ?? 50);
checks.push({
name: "Remote branches",
pass: !warn,
detail: count === 0 ? `None on ${remoteList.remote}` : `${count} cr/* branch(es) on ${remoteList.remote}${warn ? " — run `contentrain prune` to remove merged leftovers" : ""}`,
severity: warn ? "warning" : void 0
});
}
}
const clientDir = join(crDir, "client");
const modelsDir = join(crDir, "models");
if (await pathExists(clientDir) && await pathExists(modelsDir)) try {
const [clientStat, modelsStat] = await Promise.all([stat(clientDir), stat(modelsDir)]);
const fresh = clientStat.mtimeMs >= modelsStat.mtimeMs;
checks.push({
name: "SDK client",
pass: fresh,
detail: fresh ? "Up to date" : "Stale — run `contentrain generate`",
severity: fresh ? void 0 : "warning"
});
} catch {
checks.push({
name: "SDK client",
pass: true,
detail: "Could not check"
});
}
let usage;
if (options.usage && hasCrDir && config) {
const [unusedKeys, duplicateValues, missingLocaleKeys] = await Promise.all([
analyzeUnusedKeys(projectRoot, config),
analyzeDuplicateValues(projectRoot, config),
analyzeMissingLocaleKeys(projectRoot, config)
]);
usage = {
unusedKeys,
duplicateValues,
missingLocaleKeys
};
checks.push({
name: "Unused content keys",
pass: unusedKeys.length === 0,
detail: unusedKeys.length === 0 ? "All keys referenced in source" : `${unusedKeys.length} key(s) not referenced in source code`,
severity: unusedKeys.length === 0 ? void 0 : "warning"
});
checks.push({
name: "Duplicate dictionary values",
pass: duplicateValues.length === 0,
detail: duplicateValues.length === 0 ? "No duplicate values" : `${duplicateValues.length} value(s) mapped to multiple keys`,
severity: duplicateValues.length === 0 ? void 0 : "warning"
});
checks.push({
name: "Locale key coverage",
pass: missingLocaleKeys.length === 0,
detail: missingLocaleKeys.length === 0 ? "All locales have matching keys" : `${missingLocaleKeys.length} key(s) missing in some locales`,
severity: missingLocaleKeys.length === 0 ? void 0 : "warning"
});
}
const passed = checks.filter((c) => c.pass).length;
const failed = checks.length - passed;
const warnings = checks.filter((c) => !c.pass && c.severity === "warning").length;
const report = {
checks,
summary: {
total: checks.length,
passed,
failed,
warnings
}
};
if (usage) report.usage = usage;
return report;
}
async function findOrphanContent(projectRoot) {
const crDir = contentrainDir(projectRoot);
const models = await listModels(projectRoot);
const orphans = [];
const knownContentDirs = /* @__PURE__ */ new Set();
for (const m of models) {
const full = await readModel(projectRoot, m.id);
const modelForPath = full ? {
...full,
content_path: full.content_path ?? m.content_path
} : {
id: m.id,
name: m.id,
kind: m.kind,
domain: m.domain,
i18n: m.i18n,
fields: {},
content_path: m.content_path
};
knownContentDirs.add(resolveContentDir(projectRoot, modelForPath));
}
const contentDir = join(crDir, "content");
if (await pathExists(contentDir)) {
const domains = await readDir(contentDir);
for (const domain of domains) {
const domainDir = join(contentDir, domain);
const entries = await readDir(domainDir);
for (const entry of entries) {
if (entry === ".gitkeep") continue;
const entryDir = join(domainDir, entry);
if (!knownContentDirs.has(entryDir)) orphans.push(`${domain}/${entry}`);
}
}
}
for (const dir of knownContentDirs) {
if (dir.startsWith(contentDir)) continue;
if (!await pathExists(dir)) {
orphans.push(`(missing custom path) ${dir}`);
continue;
}
await readDir(dir);
}
return orphans;
}
async function analyzeUnusedKeys(projectRoot, config) {
const files = await discoverFiles(projectRoot, { paths: await autoDetectSourceDirs(projectRoot) });
if (files.length === 0) return [];
const allSource = (await Promise.all(files.map(async (relPath) => {
return await readText(join(projectRoot, relPath)) ?? "";
}))).join("\n");
const models = await listModels(projectRoot);
const defaultLocale = config.locales.default;
const unused = [];
for (const m of models) {
const fullModel = await readModel(projectRoot, m.id);
if (!fullModel) continue;
const keys = await extractContentKeys(projectRoot, fullModel, defaultLocale);
for (const key of keys) if (!allSource.includes(key)) unused.push({
model: m.id,
kind: m.kind,
key,
locale: defaultLocale
});
}
return unused;
}
async function extractContentKeys(projectRoot, model, locale) {
const cDir = resolveContentDir(projectRoot, model);
if (!await pathExists(cDir)) return [];
switch (model.kind) {
case "dictionary": {
const data = await readJson(resolveJsonFilePath(cDir, model, locale));
return data ? Object.keys(data) : [];
}
case "collection": {
const data = await readJson(resolveJsonFilePath(cDir, model, locale));
return data ? Object.keys(data) : [];
}
case "document": {
const strategy = resolveLocaleStrategy(model);
const slugs = [];
if (!model.i18n) {
const files = await readDir(cDir);
for (const f of files) if (f.endsWith(".md")) slugs.push(f.replace(".md", ""));
} else if (strategy === "file") {
const dirs = await readDir(cDir);
for (const d of dirs) if (!d.startsWith(".")) slugs.push(d);
} else if (strategy === "suffix") {
const files = await readDir(cDir);
const suffix = `.${locale}.md`;
for (const f of files) if (f.endsWith(suffix)) slugs.push(f.slice(0, -suffix.length));
} else if (strategy === "directory") {
const localeDir = join(cDir, locale);
if (await pathExists(localeDir)) {
const files = await readDir(localeDir);
for (const f of files) if (f.endsWith(".md")) slugs.push(f.replace(".md", ""));
}
} else {
const files = await readDir(cDir);
for (const f of files) if (f.endsWith(".md")) slugs.push(f.replace(".md", ""));
}
return slugs;
}
case "singleton": return [];
default: return [];
}
}
async function analyzeDuplicateValues(projectRoot, config) {
const models = await listModels(projectRoot);
const result = [];
for (const m of models) {
if (m.kind !== "dictionary") continue;
const fullModel = await readModel(projectRoot, m.id);
if (!fullModel) continue;
const cDir = resolveContentDir(projectRoot, fullModel);
for (const locale of config.locales.supported) {
const data = await readJson(resolveJsonFilePath(cDir, fullModel, locale));
if (!data) continue;
const valueToKeys = /* @__PURE__ */ new Map();
for (const [key, value] of Object.entries(data)) {
const arr = valueToKeys.get(value);
if (arr) arr.push(key);
else valueToKeys.set(value, [key]);
}
for (const [value, keys] of valueToKeys) if (keys.length > 1) result.push({
model: m.id,
locale,
value,
keys
});
}
}
return result;
}
async function analyzeMissingLocaleKeys(projectRoot, config) {
if (config.locales.supported.length < 2) return [];
const models = await listModels(projectRoot);
const result = [];
const defaultLocale = config.locales.default;
const otherLocales = config.locales.supported.filter((l) => l !== defaultLocale);
for (const m of models) {
if (m.kind !== "dictionary" && m.kind !== "collection") continue;
if (!m.i18n) continue;
const fullModel = await readModel(projectRoot, m.id);
if (!fullModel) continue;
const cDir = resolveContentDir(projectRoot, fullModel);
const defaultData = await readJson(resolveJsonFilePath(cDir, fullModel, defaultLocale));
if (!defaultData) continue;
const defaultKeys = new Set(Object.keys(defaultData));
for (const locale of otherLocales) {
const localeData = await readJson(resolveJsonFilePath(cDir, fullModel, locale));
const localeKeys = localeData ? new Set(Object.keys(localeData)) : /* @__PURE__ */ new Set();
for (const key of defaultKeys) if (!localeKeys.has(key)) result.push({
model: m.id,
key,
missingIn: locale
});
}
}
return result;
}
//#endregion
export { runDoctor as t };
//# sourceMappingURL=doctor-BwSJW1sS.mjs.map
{"version":3,"file":"doctor-BwSJW1sS.mjs","names":[],"sources":["../src/core/doctor.ts"],"sourcesContent":["import { join } from 'node:path'\nimport { stat } from 'node:fs/promises'\nimport { simpleGit } from 'simple-git'\nimport type { ContentrainConfig, ModelDefinition } from '@contentrain/types'\nimport { readConfig } from './config.js'\nimport { listModels, readModel } from './model-manager.js'\nimport { resolveContentDir, resolveJsonFilePath, resolveLocaleStrategy } from './content-manager.js'\nimport { autoDetectSourceDirs, discoverFiles } from './scan-config.js'\nimport { checkBranchHealth, listRemoteCrBranches } from '../git/branch-lifecycle.js'\nimport { contentrainDir, pathExists, readDir, readJson, readText } from '../util/fs.js'\n\n/**\n * Doctor — project health report.\n *\n * The public entry point is `runDoctor(projectRoot, { usage? })`. It is\n * inherently local-filesystem work (Node version, git install, file\n * mtimes, orphan directory detection), so the MCP tool surface gates\n * it behind the `localWorktree` capability — same pattern as\n * `contentrain_setup` and normalize.\n *\n * The report is structured JSON so three consumers can share it:\n *\n * - The `contentrain doctor` CLI command pretty-prints the checks.\n * - The Serve UI `/api/doctor` route returns the report to the\n * Dashboard's Doctor panel.\n * - Automation (CI, Studio) gets a deterministic JSON shape it can\n * assert against.\n *\n * Usage analysis (`--usage`) is a heavier, opt-in branch — it scans\n * every source file in the repo for content-key references. Kept\n * behind the flag so the default doctor run stays fast.\n */\n\nexport type CheckSeverity = 'error' | 'warning' | 'info'\n\nexport interface DoctorCheck {\n name: string\n pass: boolean\n detail: string\n /**\n * `error` — default for failing checks. Blocks a clean bill of health.\n * `warning` — failing-but-not-blocking (e.g. pending branches above\n * threshold, stale SDK client).\n * `info` — passed check; pure informational.\n */\n severity?: CheckSeverity\n}\n\nexport interface UnusedKeyEntry {\n model: string\n kind: string\n key: string\n locale: string\n}\n\nexport interface DuplicateValueEntry {\n model: string\n locale: string\n value: string\n keys: string[]\n}\n\nexport interface MissingLocaleEntry {\n model: string\n key: string\n missingIn: string\n}\n\nexport interface DoctorUsageAnalysis {\n unusedKeys: UnusedKeyEntry[]\n duplicateValues: DuplicateValueEntry[]\n missingLocaleKeys: MissingLocaleEntry[]\n}\n\nexport interface DoctorReport {\n checks: DoctorCheck[]\n summary: {\n total: number\n passed: number\n failed: number\n warnings: number\n }\n /** Present only when `options.usage === true`. */\n usage?: DoctorUsageAnalysis\n}\n\nexport interface RunDoctorOptions {\n /** Run heavier `--usage` analysis (unused keys, duplicates, locale gaps). */\n usage?: boolean\n}\n\nexport async function runDoctor(\n projectRoot: string,\n options: RunDoctorOptions = {},\n): Promise<DoctorReport> {\n const checks: DoctorCheck[] = []\n\n // ─── 1. Git installed ───\n try {\n const git = simpleGit(projectRoot)\n const version = await git.version()\n checks.push({\n name: 'Git',\n pass: true,\n detail: `v${version.major}.${version.minor}.${version.patch}`,\n })\n } catch {\n checks.push({ name: 'Git', pass: false, detail: 'Not installed or not in PATH', severity: 'error' })\n }\n\n // ─── 2. Git repo initialized ───\n const hasGit = await pathExists(join(projectRoot, '.git'))\n checks.push({\n name: 'Git repository',\n pass: hasGit,\n detail: hasGit ? projectRoot : 'No .git directory found',\n severity: hasGit ? undefined : 'error',\n })\n\n // ─── 3. Node version ───\n const nodeVersion = process.versions.node\n const [major] = nodeVersion.split('.').map(Number)\n const nodePass = (major ?? 0) >= 22\n checks.push({\n name: 'Node.js',\n pass: nodePass,\n detail: `v${nodeVersion}${nodePass ? '' : ' (requires ≥22)'}`,\n severity: nodePass ? undefined : 'error',\n })\n\n // ─── 4. .contentrain/ structure ───\n const crDir = contentrainDir(projectRoot)\n const hasCrDir = await pathExists(crDir)\n const hasConfig = await pathExists(join(crDir, 'config.json'))\n const hasModels = await pathExists(join(crDir, 'models'))\n const hasContent = await pathExists(join(crDir, 'content'))\n const structurePass = hasCrDir && hasConfig && hasModels && hasContent\n\n checks.push({\n name: '.contentrain/ structure',\n pass: structurePass,\n detail: !hasCrDir\n ? 'Not initialized — run `contentrain init`'\n : [\n hasConfig ? null : 'missing config.json',\n hasModels ? null : 'missing models/',\n hasContent ? null : 'missing content/',\n ].filter(Boolean).join(', ') || 'OK',\n severity: structurePass ? undefined : 'error',\n })\n\n // ─── 5. Config parseable ───\n let config: ContentrainConfig | null = null\n if (hasConfig) {\n config = await readConfig(projectRoot)\n checks.push({\n name: 'Config',\n pass: config !== null,\n detail: config\n ? `stack: ${config.stack}, locales: ${config.locales.supported.join(', ')}`\n : 'Failed to parse config.json',\n severity: config ? undefined : 'error',\n })\n }\n\n // ─── 6. Models all parseable ───\n if (hasCrDir) {\n try {\n const models = await listModels(projectRoot)\n const parseResults = await Promise.all(models.map(m => readModel(projectRoot, m.id)))\n const allParseable = parseResults.every(r => r !== null)\n checks.push({\n name: 'Models',\n pass: allParseable,\n detail: `${models.length} model(s)${allParseable ? ', all valid' : ', some failed to parse'}`,\n severity: allParseable ? undefined : 'error',\n })\n } catch {\n checks.push({ name: 'Models', pass: false, detail: 'Failed to read models', severity: 'error' })\n }\n }\n\n // ─── 7. Orphan content ───\n if (hasCrDir) {\n const orphans = await findOrphanContent(projectRoot)\n checks.push({\n name: 'Orphan content',\n pass: orphans.length === 0,\n detail: orphans.length === 0 ? 'None' : `Found: ${orphans.join(', ')}`,\n severity: orphans.length === 0 ? undefined : 'warning',\n })\n }\n\n // ─── 8. Stale contentrain branches ───\n if (hasGit) {\n try {\n const health = await checkBranchHealth(projectRoot)\n checks.push({\n name: 'Pending branches',\n pass: !health.blocked && !health.warning,\n detail: health.message\n ?? (health.unmerged === 0 ? 'None' : `${health.unmerged} active cr/* branch(es)`),\n severity: health.blocked ? 'error' : health.warning ? 'warning' : undefined,\n })\n } catch {\n checks.push({ name: 'Pending branches', pass: true, detail: 'Could not check' })\n }\n\n // ─── 8b. Remote cr/* branches ───\n // Authoritative ls-remote count (local branch pressure cannot see the\n // remote pile). Best-effort: skipped entirely without a remote, and an\n // unreachable remote is informational — doctor never fails offline.\n const remoteList = await listRemoteCrBranches(projectRoot, { timeoutMs: 5000 })\n if (remoteList) {\n if (remoteList.error) {\n checks.push({\n name: 'Remote branches',\n pass: true,\n detail: `Could not check ${remoteList.remote} (offline?)`,\n severity: 'info',\n })\n } else {\n const count = remoteList.branches.length\n const warnLimit = config?.branchWarnLimit ?? 50\n const warn = count >= warnLimit\n checks.push({\n name: 'Remote branches',\n pass: !warn,\n detail: count === 0\n ? `None on ${remoteList.remote}`\n : `${count} cr/* branch(es) on ${remoteList.remote}${warn ? ' — run `contentrain prune` to remove merged leftovers' : ''}`,\n severity: warn ? 'warning' : undefined,\n })\n }\n }\n }\n\n // ─── 9. SDK client freshness ───\n const clientDir = join(crDir, 'client')\n const modelsDir = join(crDir, 'models')\n if (await pathExists(clientDir) && await pathExists(modelsDir)) {\n try {\n const [clientStat, modelsStat] = await Promise.all([stat(clientDir), stat(modelsDir)])\n const fresh = clientStat.mtimeMs >= modelsStat.mtimeMs\n checks.push({\n name: 'SDK client',\n pass: fresh,\n detail: fresh ? 'Up to date' : 'Stale — run `contentrain generate`',\n severity: fresh ? undefined : 'warning',\n })\n } catch {\n checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' })\n }\n }\n\n // ─── 10–12. Usage analysis (optional) ───\n let usage: DoctorUsageAnalysis | undefined\n if (options.usage && hasCrDir && config) {\n const [unusedKeys, duplicateValues, missingLocaleKeys] = await Promise.all([\n analyzeUnusedKeys(projectRoot, config),\n analyzeDuplicateValues(projectRoot, config),\n analyzeMissingLocaleKeys(projectRoot, config),\n ])\n usage = { unusedKeys, duplicateValues, missingLocaleKeys }\n\n checks.push({\n name: 'Unused content keys',\n pass: unusedKeys.length === 0,\n detail: unusedKeys.length === 0\n ? 'All keys referenced in source'\n : `${unusedKeys.length} key(s) not referenced in source code`,\n severity: unusedKeys.length === 0 ? undefined : 'warning',\n })\n\n checks.push({\n name: 'Duplicate dictionary values',\n pass: duplicateValues.length === 0,\n detail: duplicateValues.length === 0\n ? 'No duplicate values'\n : `${duplicateValues.length} value(s) mapped to multiple keys`,\n severity: duplicateValues.length === 0 ? undefined : 'warning',\n })\n\n checks.push({\n name: 'Locale key coverage',\n pass: missingLocaleKeys.length === 0,\n detail: missingLocaleKeys.length === 0\n ? 'All locales have matching keys'\n : `${missingLocaleKeys.length} key(s) missing in some locales`,\n severity: missingLocaleKeys.length === 0 ? undefined : 'warning',\n })\n }\n\n const passed = checks.filter(c => c.pass).length\n const failed = checks.length - passed\n const warnings = checks.filter(c => !c.pass && c.severity === 'warning').length\n\n const report: DoctorReport = {\n checks,\n summary: { total: checks.length, passed, failed, warnings },\n }\n if (usage) report.usage = usage\n return report\n}\n\nasync function findOrphanContent(projectRoot: string): Promise<string[]> {\n const crDir = contentrainDir(projectRoot)\n const models = await listModels(projectRoot)\n const orphans: string[] = []\n\n const knownContentDirs = new Set<string>()\n for (const m of models) {\n const full = await readModel(projectRoot, m.id)\n const modelForPath = full\n ? {\n ...full,\n content_path: full.content_path ?? (m as { content_path?: string }).content_path,\n }\n : {\n id: m.id,\n name: m.id,\n kind: m.kind,\n domain: m.domain,\n i18n: m.i18n,\n fields: {},\n content_path: (m as { content_path?: string }).content_path,\n }\n knownContentDirs.add(resolveContentDir(projectRoot, modelForPath))\n }\n\n const contentDir = join(crDir, 'content')\n if (await pathExists(contentDir)) {\n const domains = await readDir(contentDir)\n for (const domain of domains) {\n const domainDir = join(contentDir, domain)\n const entries = await readDir(domainDir)\n for (const entry of entries) {\n if (entry === '.gitkeep') continue\n const entryDir = join(domainDir, entry)\n if (!knownContentDirs.has(entryDir)) {\n orphans.push(`${domain}/${entry}`)\n }\n }\n }\n }\n\n for (const dir of knownContentDirs) {\n if (dir.startsWith(contentDir)) continue\n if (!await pathExists(dir)) {\n orphans.push(`(missing custom path) ${dir}`)\n continue\n }\n await readDir(dir)\n }\n\n return orphans\n}\n\nasync function analyzeUnusedKeys(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<UnusedKeyEntry[]> {\n const sourceDirs = await autoDetectSourceDirs(projectRoot)\n const files = await discoverFiles(projectRoot, { paths: sourceDirs })\n if (files.length === 0) return []\n\n const chunks = await Promise.all(\n files.map(async (relPath) => {\n const content = await readText(join(projectRoot, relPath))\n return content ?? ''\n }),\n )\n const allSource = chunks.join('\\n')\n\n const models = await listModels(projectRoot)\n const defaultLocale = config.locales.default\n const unused: UnusedKeyEntry[] = []\n\n for (const m of models) {\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const keys = await extractContentKeys(projectRoot, fullModel, defaultLocale)\n for (const key of keys) {\n if (!allSource.includes(key)) {\n unused.push({ model: m.id, kind: m.kind, key, locale: defaultLocale })\n }\n }\n }\n\n return unused\n}\n\nasync function extractContentKeys(\n projectRoot: string,\n model: ModelDefinition,\n locale: string,\n): Promise<string[]> {\n const cDir = resolveContentDir(projectRoot, model)\n if (!await pathExists(cDir)) return []\n\n switch (model.kind) {\n case 'dictionary': {\n const filePath = resolveJsonFilePath(cDir, model, locale)\n const data = await readJson<Record<string, string>>(filePath)\n return data ? Object.keys(data) : []\n }\n case 'collection': {\n const filePath = resolveJsonFilePath(cDir, model, locale)\n const data = await readJson<Record<string, Record<string, unknown>>>(filePath)\n return data ? Object.keys(data) : []\n }\n case 'document': {\n const strategy = resolveLocaleStrategy(model)\n const slugs: string[] = []\n if (!model.i18n) {\n const files = await readDir(cDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n } else if (strategy === 'file') {\n const dirs = await readDir(cDir)\n for (const d of dirs) if (!d.startsWith('.')) slugs.push(d)\n } else if (strategy === 'suffix') {\n const files = await readDir(cDir)\n const suffix = `.${locale}.md`\n for (const f of files) if (f.endsWith(suffix)) slugs.push(f.slice(0, -suffix.length))\n } else if (strategy === 'directory') {\n const localeDir = join(cDir, locale)\n if (await pathExists(localeDir)) {\n const files = await readDir(localeDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n }\n } else {\n const files = await readDir(cDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n }\n return slugs\n }\n case 'singleton':\n return []\n default:\n return []\n }\n}\n\nasync function analyzeDuplicateValues(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<DuplicateValueEntry[]> {\n const models = await listModels(projectRoot)\n const result: DuplicateValueEntry[] = []\n\n for (const m of models) {\n if (m.kind !== 'dictionary') continue\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const cDir = resolveContentDir(projectRoot, fullModel)\n for (const locale of config.locales.supported) {\n const filePath = resolveJsonFilePath(cDir, fullModel, locale)\n const data = await readJson<Record<string, string>>(filePath)\n if (!data) continue\n\n const valueToKeys = new Map<string, string[]>()\n for (const [key, value] of Object.entries(data)) {\n const arr = valueToKeys.get(value)\n if (arr) arr.push(key)\n else valueToKeys.set(value, [key])\n }\n\n for (const [value, keys] of valueToKeys) {\n if (keys.length > 1) {\n result.push({ model: m.id, locale, value, keys })\n }\n }\n }\n }\n\n return result\n}\n\nasync function analyzeMissingLocaleKeys(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<MissingLocaleEntry[]> {\n if (config.locales.supported.length < 2) return []\n\n const models = await listModels(projectRoot)\n const result: MissingLocaleEntry[] = []\n const defaultLocale = config.locales.default\n const otherLocales = config.locales.supported.filter(l => l !== defaultLocale)\n\n for (const m of models) {\n if (m.kind !== 'dictionary' && m.kind !== 'collection') continue\n if (!m.i18n) continue\n\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const cDir = resolveContentDir(projectRoot, fullModel)\n const defaultPath = resolveJsonFilePath(cDir, fullModel, defaultLocale)\n const defaultData = await readJson<Record<string, unknown>>(defaultPath)\n if (!defaultData) continue\n const defaultKeys = new Set(Object.keys(defaultData))\n\n for (const locale of otherLocales) {\n const localePath = resolveJsonFilePath(cDir, fullModel, locale)\n const localeData = await readJson<Record<string, unknown>>(localePath)\n const localeKeys = localeData ? new Set(Object.keys(localeData)) : new Set<string>()\n\n for (const key of defaultKeys) {\n if (!localeKeys.has(key)) {\n result.push({ model: m.id, key, missingIn: locale })\n }\n }\n }\n }\n\n return result\n}\n"],"mappings":"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"}
import { t as LOCAL_CAPABILITIES } from "./contracts-DfL0BfrD.mjs";
import { a as applyChangesToWorktree } from "./ops-Dh2T3u53.mjs";
import { t as readConfig } from "./config-DlFcTxFG.mjs";
import { C as LocalReader } from "./model-manager-BhLsUgaB.mjs";
import { a as deleteRemoteBranch, r as classifyMergedBranches } from "./branch-lifecycle-Dd84lx37.mjs";
import { i as mergeBranch$1, n as createTransaction } from "./transaction-DhVoGigz.mjs";
import { CONTENTRAIN_BRANCH } from "@contentrain/types";
import { simpleGit } from "simple-git";
//#region src/providers/local/branch-ops.ts
/**
* Branch/merge/diff helpers backed by the local simple-git worktree flow.
*
* Pure functions composed into `LocalProvider` — mirroring the shape of
* `providers/github/branch-ops.ts` so the two providers share the same
* surface at the `RepoProvider` boundary.
*/
async function getDefaultBranch(projectRoot) {
const config = await readConfig(projectRoot);
if (config?.repository?.default_branch) return config.repository.default_branch;
const envBranch = process.env["CONTENTRAIN_BRANCH"];
if (envBranch) return envBranch;
return (await simpleGit(projectRoot).raw(["branch", "--show-current"])).trim() || "main";
}
async function listBranches(projectRoot, prefix) {
const summary = await simpleGit(projectRoot).branchLocal();
const names = prefix ? summary.all.filter((n) => n.startsWith(prefix)) : summary.all;
const branches = [];
for (const name of names) {
const info = summary.branches[name];
branches.push({
name,
sha: info?.commit ?? ""
});
}
return branches;
}
async function createBranch(projectRoot, name, fromRef) {
await simpleGit(projectRoot).raw([
"branch",
name,
fromRef
]);
}
async function deleteBranch(projectRoot, name) {
await simpleGit(projectRoot).deleteLocalBranch(name, true);
await deleteRemoteBranch(projectRoot, name);
}
async function getBranchDiff(projectRoot, branch, base) {
const raw = await simpleGit(projectRoot).raw([
"diff",
"--name-status",
`${base}...${branch}`
]);
const diffs = [];
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
const [code, ...pathParts] = line.split(" ");
const path = pathParts[pathParts.length - 1];
if (!code || !path) continue;
const status = code.startsWith("A") ? "added" : code.startsWith("D") ? "removed" : "modified";
diffs.push({
path,
status,
before: null,
after: null
});
}
return diffs;
}
async function mergeBranch(projectRoot, branch, into) {
if (into !== CONTENTRAIN_BRANCH) throw Object.assign(/* @__PURE__ */ new Error(`LocalProvider.mergeBranch only supports merging into "${CONTENTRAIN_BRANCH}" (got "${into}"). The local flow merges feature branches into the content-tracking branch and fast-forwards the base branch via update-ref.`), {
code: "UNSUPPORTED_MERGE_TARGET",
agent_hint: `Pass "${CONTENTRAIN_BRANCH}" as the merge target, or use a non-local provider that supports arbitrary targets.`,
developer_action: `Merge "${branch}" into "${CONTENTRAIN_BRANCH}" instead.`
});
const result = await mergeBranch$1(projectRoot, branch);
return {
merged: true,
sha: result.commit,
pullRequestUrl: null,
sync: result.sync,
...result.remote ? { remote: result.remote } : {}
};
}
async function isMerged(projectRoot, branch, into) {
try {
return (await classifyMergedBranches(projectRoot, [branch], into)).has(branch);
} catch {
return false;
}
}
//#endregion
//#region src/providers/local/provider.ts
const DEFAULT_AUTHOR_NAME = "Contentrain";
const DEFAULT_AUTHOR_EMAIL = "ai@contentrain.io";
/**
* LocalProvider — the local-filesystem, worktree-backed content provider.
*
* Implements the full `RepoProvider` surface:
* - Reader methods delegate to `LocalReader`.
* - `applyPlan` wraps `createTransaction` and returns `LocalApplyResult`
* (a superset of `Commit` carrying workflow action + selective sync).
* - Branch ops mirror `GitHubProvider` — thin wrappers over the local
* simple-git helpers in `./branch-ops.ts`.
*
* `mergeBranch` only supports merging into the singleton
* `CONTENTRAIN_BRANCH`; the local flow advances the base branch via
* `update-ref` in `transaction.mergeBranch`, so arbitrary merge targets
* would bypass that invariant.
*/
var LocalProvider = class {
capabilities = LOCAL_CAPABILITIES;
reader;
constructor(projectRoot) {
this.projectRoot = projectRoot;
this.reader = new LocalReader(projectRoot);
}
readFile(path, ref) {
return this.reader.readFile(path, ref);
}
listDirectory(path, ref) {
return this.reader.listDirectory(path, ref);
}
fileExists(path, ref) {
return this.reader.fileExists(path, ref);
}
async applyPlan(input) {
const tx = await createTransaction(this.projectRoot, input.branch, { workflowOverride: input.workflowOverride });
try {
await tx.write(async (wt) => {
await applyChangesToWorktree(wt, input.changes);
});
await tx.commit(input.message, input.context);
const gitResult = await tx.complete();
return {
sha: gitResult.commit,
message: input.message,
author: {
name: process.env["CONTENTRAIN_AUTHOR_NAME"] ?? DEFAULT_AUTHOR_NAME,
email: process.env["CONTENTRAIN_AUTHOR_EMAIL"] ?? DEFAULT_AUTHOR_EMAIL
},
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
workflowAction: gitResult.action,
sync: gitResult.sync,
warning: gitResult.warning
};
} finally {
await tx.cleanup();
}
}
listBranches(prefix) {
return listBranches(this.projectRoot, prefix);
}
async createBranch(name, fromRef) {
const resolved = fromRef ?? CONTENTRAIN_BRANCH;
await createBranch(this.projectRoot, name, resolved);
}
deleteBranch(name) {
return deleteBranch(this.projectRoot, name);
}
getBranchDiff(branch, base) {
const resolved = base ?? CONTENTRAIN_BRANCH;
return getBranchDiff(this.projectRoot, branch, resolved);
}
mergeBranch(branch, into) {
return mergeBranch(this.projectRoot, branch, into);
}
isMerged(branch, into) {
const resolved = into ?? CONTENTRAIN_BRANCH;
return isMerged(this.projectRoot, branch, resolved);
}
getDefaultBranch() {
return getDefaultBranch(this.projectRoot);
}
};
//#endregion
export { isMerged as n, LocalProvider as t };
//# sourceMappingURL=local-BIE23GuI.mjs.map
{"version":3,"file":"local-BIE23GuI.mjs","names":["mergeBranchOp","listBranchesOp","createBranchOp","deleteBranchOp","getBranchDiffOp","mergeBranchOp","isMergedOp","getDefaultBranchOp"],"sources":["../src/providers/local/branch-ops.ts","../src/providers/local/provider.ts"],"sourcesContent":["import { simpleGit } from 'simple-git'\nimport { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js'\nimport { readConfig } from '../../core/config.js'\nimport { classifyMergedBranches, deleteRemoteBranch } from '../../git/branch-lifecycle.js'\nimport { mergeBranch as mergeBranchOp } from '../../git/transaction.js'\n\n/**\n * Branch/merge/diff helpers backed by the local simple-git worktree flow.\n *\n * Pure functions composed into `LocalProvider` — mirroring the shape of\n * `providers/github/branch-ops.ts` so the two providers share the same\n * surface at the `RepoProvider` boundary.\n */\n\nexport async function getDefaultBranch(projectRoot: string): Promise<string> {\n const config = await readConfig(projectRoot)\n if (config?.repository?.default_branch) return config.repository.default_branch\n const envBranch = process.env['CONTENTRAIN_BRANCH']\n if (envBranch) return envBranch\n const git = simpleGit(projectRoot)\n const current = (await git.raw(['branch', '--show-current'])).trim()\n return current || 'main'\n}\n\nexport async function listBranches(\n projectRoot: string,\n prefix?: string,\n): Promise<Branch[]> {\n const git = simpleGit(projectRoot)\n const summary = await git.branchLocal()\n const names = prefix\n ? summary.all.filter(n => n.startsWith(prefix))\n : summary.all\n const branches: Branch[] = []\n for (const name of names) {\n const info = summary.branches[name]\n branches.push({ name, sha: info?.commit ?? '' })\n }\n return branches\n}\n\nexport async function createBranch(\n projectRoot: string,\n name: string,\n fromRef: string,\n): Promise<void> {\n const git = simpleGit(projectRoot)\n await git.raw(['branch', name, fromRef])\n}\n\nexport async function deleteBranch(\n projectRoot: string,\n name: string,\n): Promise<void> {\n const git = simpleGit(projectRoot)\n await git.deleteLocalBranch(name, true)\n // Parity with the remote-API providers, whose deleteBranch removes the\n // remote ref: best-effort, config-gated inside the helper, never throws.\n await deleteRemoteBranch(projectRoot, name)\n}\n\nexport async function getBranchDiff(\n projectRoot: string,\n branch: string,\n base: string,\n): Promise<FileDiff[]> {\n const git = simpleGit(projectRoot)\n const raw = await git.raw(['diff', '--name-status', `${base}...${branch}`])\n const diffs: FileDiff[] = []\n for (const line of raw.split('\\n')) {\n if (!line.trim()) continue\n const [code, ...pathParts] = line.split('\\t')\n const path = pathParts[pathParts.length - 1]\n if (!code || !path) continue\n const status: FileDiff['status'] = code.startsWith('A')\n ? 'added'\n : code.startsWith('D')\n ? 'removed'\n : 'modified'\n diffs.push({ path, status, before: null, after: null })\n }\n return diffs\n}\n\nexport async function mergeBranch(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<MergeResult> {\n if (into !== CONTENTRAIN_BRANCH) {\n throw Object.assign(new Error(\n `LocalProvider.mergeBranch only supports merging into \"${CONTENTRAIN_BRANCH}\" (got \"${into}\"). `\n + `The local flow merges feature branches into the content-tracking branch and fast-forwards the base branch via update-ref.`,\n ), {\n code: 'UNSUPPORTED_MERGE_TARGET',\n agent_hint: `Pass \"${CONTENTRAIN_BRANCH}\" as the merge target, or use a non-local provider that supports arbitrary targets.`,\n developer_action: `Merge \"${branch}\" into \"${CONTENTRAIN_BRANCH}\" instead.`,\n })\n }\n const result = await mergeBranchOp(projectRoot, branch)\n return {\n merged: true,\n sha: result.commit,\n pullRequestUrl: null,\n sync: result.sync,\n ...(result.remote ? { remote: result.remote } : {}),\n }\n}\n\nexport async function isMerged(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<boolean> {\n try {\n const merged = await classifyMergedBranches(projectRoot, [branch], into)\n return merged.has(branch)\n } catch {\n return false\n }\n}\n","import { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type {\n Branch,\n FileDiff,\n MergeResult,\n ProviderCapabilities,\n RepoProvider,\n} from '../../core/contracts/index.js'\nimport { LOCAL_CAPABILITIES } from '../../core/contracts/index.js'\nimport { applyChangesToWorktree } from '../../core/ops/index.js'\nimport { createTransaction } from '../../git/transaction.js'\nimport {\n createBranch as createBranchOp,\n deleteBranch as deleteBranchOp,\n getBranchDiff as getBranchDiffOp,\n getDefaultBranch as getDefaultBranchOp,\n isMerged as isMergedOp,\n listBranches as listBranchesOp,\n mergeBranch as mergeBranchOp,\n} from './branch-ops.js'\nimport { LocalReader } from './reader.js'\nimport type { LocalApplyPlanInput, LocalApplyResult } from './types.js'\n\nconst DEFAULT_AUTHOR_NAME = 'Contentrain'\nconst DEFAULT_AUTHOR_EMAIL = 'ai@contentrain.io'\n\n/**\n * LocalProvider — the local-filesystem, worktree-backed content provider.\n *\n * Implements the full `RepoProvider` surface:\n * - Reader methods delegate to `LocalReader`.\n * - `applyPlan` wraps `createTransaction` and returns `LocalApplyResult`\n * (a superset of `Commit` carrying workflow action + selective sync).\n * - Branch ops mirror `GitHubProvider` — thin wrappers over the local\n * simple-git helpers in `./branch-ops.ts`.\n *\n * `mergeBranch` only supports merging into the singleton\n * `CONTENTRAIN_BRANCH`; the local flow advances the base branch via\n * `update-ref` in `transaction.mergeBranch`, so arbitrary merge targets\n * would bypass that invariant.\n */\nexport class LocalProvider implements RepoProvider {\n readonly capabilities: ProviderCapabilities = LOCAL_CAPABILITIES\n private readonly reader: LocalReader\n\n constructor(public readonly projectRoot: string) {\n this.reader = new LocalReader(projectRoot)\n }\n\n readFile(path: string, ref?: string): Promise<string> {\n return this.reader.readFile(path, ref)\n }\n\n listDirectory(path: string, ref?: string): Promise<string[]> {\n return this.reader.listDirectory(path, ref)\n }\n\n fileExists(path: string, ref?: string): Promise<boolean> {\n return this.reader.fileExists(path, ref)\n }\n\n async applyPlan(input: LocalApplyPlanInput): Promise<LocalApplyResult> {\n const tx = await createTransaction(this.projectRoot, input.branch, {\n workflowOverride: input.workflowOverride,\n })\n try {\n await tx.write(async (wt) => {\n await applyChangesToWorktree(wt, input.changes)\n })\n await tx.commit(input.message, input.context)\n const gitResult = await tx.complete()\n return {\n sha: gitResult.commit,\n message: input.message,\n author: {\n name: process.env['CONTENTRAIN_AUTHOR_NAME'] ?? DEFAULT_AUTHOR_NAME,\n email: process.env['CONTENTRAIN_AUTHOR_EMAIL'] ?? DEFAULT_AUTHOR_EMAIL,\n },\n timestamp: new Date().toISOString(),\n workflowAction: gitResult.action,\n sync: gitResult.sync,\n warning: gitResult.warning,\n }\n } finally {\n await tx.cleanup()\n }\n }\n\n listBranches(prefix?: string): Promise<Branch[]> {\n return listBranchesOp(this.projectRoot, prefix)\n }\n\n async createBranch(name: string, fromRef?: string): Promise<void> {\n const resolved = fromRef ?? CONTENTRAIN_BRANCH\n await createBranchOp(this.projectRoot, name, resolved)\n }\n\n deleteBranch(name: string): Promise<void> {\n return deleteBranchOp(this.projectRoot, name)\n }\n\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]> {\n const resolved = base ?? CONTENTRAIN_BRANCH\n return getBranchDiffOp(this.projectRoot, branch, resolved)\n }\n\n mergeBranch(branch: string, into: string): Promise<MergeResult> {\n return mergeBranchOp(this.projectRoot, branch, into)\n }\n\n isMerged(branch: string, into?: string): Promise<boolean> {\n const resolved = into ?? CONTENTRAIN_BRANCH\n return isMergedOp(this.projectRoot, branch, resolved)\n }\n\n getDefaultBranch(): Promise<string> {\n return getDefaultBranchOp(this.projectRoot)\n }\n}\n"],"mappings":"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"}

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

import { t as readConfig } from "./config-DlFcTxFG.mjs";
import { r as writeContext } from "./context-CYN__o3Q.mjs";
import { a as deleteRemoteBranch, l as authorConfig } from "./branch-lifecycle-Dd84lx37.mjs";
import { t as branchTimestamp } from "./id-DV_T9Ic8.mjs";
import { join } from "node:path";
import { CONTENTRAIN_BRANCH } from "@contentrain/types";
import { rm } from "node:fs/promises";
import { simpleGit } from "simple-git";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
//#region src/providers/local/migration.ts
/**
* Migration: the first MCP release used `contentrain/*` feature branches.
* Once we introduced the singleton `contentrain` branch (tracking the
* committed content state), those old feature branches became a ref-
* namespace conflict — git cannot hold both `contentrain` (a leaf ref)
* and `contentrain/foo` (implying a directory) simultaneously.
*
* `migrateLegacyBranches` removes the old-prefix branches so the
* singleton `contentrain` ref can be created. It is idempotent and
* safe to call before every `ensureContentBranch` run.
*
* Strategy:
* 1. Delete merged `contentrain/*` branches first (`-d`). Their commits
* are already on the base branch via the old auto-merge flow.
* 2. Force-delete whatever remains (`-D`). Any unmerged leftover is
* from an abandoned or partially-committed legacy branch — content
* on `main` always wins, and the singleton `contentrain` branch is
* about to be created from `main`/`baseBranch` anyway.
*
* Returns the number of branches that were deleted. Callers may log it;
* the git transaction layer does not need the count for correctness.
*/
async function migrateLegacyBranches(git, baseBranch) {
if ((await git.branchLocal()).all.filter((b) => b.startsWith("contentrain/")).length === 0) return 0;
let deleted = 0;
let mergedLegacy = [];
try {
mergedLegacy = (await git.raw([
"branch",
"--merged",
baseBranch
])).split("\n").map((b) => b.trim().replace(/^\*\s*/, "")).filter((b) => b.startsWith("contentrain/"));
} catch {}
for (const b of mergedLegacy) try {
await git.raw([
"branch",
"-d",
b
]);
deleted++;
} catch {}
const remaining = (await git.branchLocal()).all.filter((b) => b.startsWith("contentrain/"));
for (const b of remaining) try {
await git.raw([
"branch",
"-D",
b
]);
deleted++;
} catch {}
return deleted;
}
//#endregion
//#region src/git/transaction.ts
async function ensureContentBranch(projectRoot) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
if ((await git.branchLocal()).all.includes(CONTENTRAIN_BRANCH)) return;
const baseBranch = config?.repository?.default_branch || (await git.raw(["branch", "--show-current"])).trim() || "main";
await migrateLegacyBranches(git, baseBranch);
await git.branch([CONTENTRAIN_BRANCH, baseBranch]);
const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
try {
if ((await git.getRemotes()).some((r) => r.name === remoteName)) await git.push([
"-u",
remoteName,
CONTENTRAIN_BRANCH
]);
} catch {}
}
async function selectiveSync(projectRoot, _worktreePath, contentrainTip, _previousBaseRef, dirtyFilesBeforeUpdate) {
const git = simpleGit(projectRoot);
const synced = [];
const skipped = [];
const compareRef = _previousBaseRef ?? contentrainTip;
let changedFiles = [];
try {
changedFiles = (await git.raw([
"diff-tree",
"--name-only",
"-r",
"--no-commit-id",
compareRef,
contentrainTip
])).split("\n").filter((f) => f.trim().length > 0);
} catch {
try {
changedFiles = (await git.raw([
"ls-tree",
"-r",
"--name-only",
contentrainTip,
".contentrain/"
])).split("\n").filter((f) => f.trim().length > 0);
} catch {
return {
synced,
skipped
};
}
}
if (changedFiles.length === 0) return {
synced,
skipped
};
const dirtyFiles = dirtyFilesBeforeUpdate ?? /* @__PURE__ */ new Set();
const filesInTip = /* @__PURE__ */ new Set();
try {
const lsOutput = await git.raw([
"ls-tree",
"-r",
"--name-only",
contentrainTip,
"--",
...changedFiles
]);
for (const f of lsOutput.split("\n")) {
const trimmed = f.trim();
if (trimmed) filesInTip.add(trimmed);
}
} catch {
for (const file of changedFiles) try {
await git.raw([
"cat-file",
"-e",
`${contentrainTip}:${file}`
]);
filesInTip.add(file);
} catch {}
}
const toCheckout = [];
const toRemove = [];
for (const file of changedFiles) if (dirtyFiles.has(file)) skipped.push(file);
else if (filesInTip.has(file)) toCheckout.push(file);
else toRemove.push(file);
if (toCheckout.length > 0) try {
await git.checkout([
"HEAD",
"--",
...toCheckout
]);
synced.push(...toCheckout);
} catch {
for (const file of toCheckout) try {
await git.checkout([
"HEAD",
"--",
file
]);
synced.push(file);
} catch {
skipped.push(file);
}
}
await Promise.all(toRemove.map(async (file) => {
try {
await rm(join(projectRoot, file), { force: true });
synced.push(file);
} catch {
skipped.push(file);
}
}));
return {
synced,
skipped,
warning: skipped.length > 0 ? `${skipped.length} file(s) skipped due to local changes: ${skipped.join(", ")}. Commit your changes, then run: git checkout HEAD -- ${skipped.join(" ")}` : void 0
};
}
async function createTransaction(projectRoot, branchName, options) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
const workflow = options?.workflowOverride ?? config?.workflow ?? "auto-merge";
const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
let baseBranch = process.env["CONTENTRAIN_BRANCH"] ?? config?.repository?.default_branch ?? "";
let currentBranch = "";
let hasRemote = false;
const [branchResult, remotes] = await Promise.all([git.raw(["branch", "--show-current"]).catch(() => ""), git.getRemotes().catch(() => [])]);
currentBranch = branchResult.trim();
if (!baseBranch) baseBranch = currentBranch || "main";
hasRemote = remotes.some((r) => r.name === remoteName);
if (currentBranch === CONTENTRAIN_BRANCH) throw Object.assign(/* @__PURE__ */ new Error(`The '${CONTENTRAIN_BRANCH}' branch is checked out in your working directory. Contentrain manages this branch automatically. Switch to your working branch and retry.`), {
code: "CONTENT_BRANCH_CHECKED_OUT",
agent_hint: "Ask the developer to switch to their working branch (e.g., main or a feature branch), then retry the operation.",
developer_action: `git checkout ${baseBranch}`
});
await ensureContentBranch(projectRoot);
if (hasRemote) await Promise.all([git.fetch(remoteName, baseBranch).catch(() => {}), git.fetch(remoteName, CONTENTRAIN_BRANCH).catch(() => {})]);
const worktreePath = join(tmpdir(), `cr-${randomUUID()}`);
const branch = branchName;
await git.raw([
"worktree",
"add",
worktreePath,
CONTENTRAIN_BRANCH
]);
const wtGit = simpleGit(worktreePath, { config: authorConfig() });
try {
await wtGit.merge([baseBranch, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
if (hasRemote) try {
await wtGit.merge([`${remoteName}/${baseBranch}`, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
}
}
if (hasRemote) try {
await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
}
await wtGit.checkout(["-b", branch]);
let commitHash = "";
let pendingReview = false;
let savedContextUpdate;
return {
worktree: worktreePath,
branch,
async write(callback) {
await callback(worktreePath);
},
async commit(message, contextUpdate) {
savedContextUpdate = contextUpdate;
await wtGit.add(".");
commitHash = (await wtGit.commit(message, {
"--allow-empty": null,
"--no-verify": null
})).commit || "";
return commitHash;
},
async complete() {
if (workflow === "review") {
if (hasRemote) await git.push(remoteName, branch);
pendingReview = true;
return {
action: "pending-review",
commit: commitHash
};
}
await wtGit.checkout(CONTENTRAIN_BRANCH);
try {
await wtGit.merge([branch, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
throw Object.assign(/* @__PURE__ */ new Error(`Merge conflict when merging branch "${branch}" into "${CONTENTRAIN_BRANCH}". The branch still exists with your changes intact. Resolve the conflict manually, or delete the branch and retry.`), {
code: "CONTENT_BRANCH_MERGE_CONFLICT",
agent_hint: "The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.",
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branch}`
});
}
if (savedContextUpdate) await regenerateContextOnContentrain(wtGit, worktreePath, savedContextUpdate);
const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([
wtGit.raw(["rev-parse", "HEAD"]).then((s) => s.trim()),
git.raw(["rev-parse", baseBranch]).then((s) => s.trim()),
git.status()
]);
const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map((f) => f.path));
if (!await isAncestor(git, previousBaseRef, contentrainTip)) throw Object.assign(/* @__PURE__ */ new Error(`Cannot fast-forward "${baseBranch}" to contentrain tip. The base branch has diverged. Merge "${baseBranch}" into "${CONTENTRAIN_BRANCH}" first.`), {
code: "BASE_UPDATE_FAILED",
agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`
});
await git.raw([
"update-ref",
`refs/heads/${baseBranch}`,
contentrainTip
]);
try {
await git.raw(["read-tree", "HEAD"]);
} catch {
try {
await git.raw(["reset", "HEAD"]);
} catch {}
}
const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate);
if (hasRemote) {
try {
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {
try {
await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH);
await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, "--no-edit"]);
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {}
}
try {
await git.push(remoteName, baseBranch);
} catch {}
}
return {
action: "auto-merged",
commit: commitHash,
sync,
...sync.warning ? { warning: sync.warning } : {}
};
},
async cleanup() {
try {
await git.raw([
"worktree",
"remove",
worktreePath,
"--force"
]);
} catch {}
if (!pendingReview) await safeDeleteBranch(git, branch);
}
};
}
async function mergeBranch(projectRoot, branchName) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
const baseBranch = process.env["CONTENTRAIN_BRANCH"] ?? config?.repository?.default_branch ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
await ensureContentBranch(projectRoot);
let hasRemote = false;
try {
hasRemote = (await git.getRemotes()).some((r) => r.name === remoteName);
} catch {
hasRemote = false;
}
const worktreePath = join(tmpdir(), `cr-merge-${randomUUID()}`);
await git.raw([
"worktree",
"add",
worktreePath,
CONTENTRAIN_BRANCH
]);
const wtGit = simpleGit(worktreePath, { config: authorConfig() });
try {
try {
await wtGit.merge([branchName, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
throw Object.assign(/* @__PURE__ */ new Error(`Merge conflict when merging branch "${branchName}" into "${CONTENTRAIN_BRANCH}". The branch still exists with your changes intact. Resolve the conflict manually, or delete the branch and retry.`), {
code: "CONTENT_BRANCH_MERGE_CONFLICT",
agent_hint: "The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.",
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branchName}`
});
}
await regenerateContextOnContentrain(wtGit, worktreePath, {
tool: "contentrain_merge",
model: "*"
});
const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([
wtGit.raw(["rev-parse", "HEAD"]).then((s) => s.trim()),
git.raw(["rev-parse", baseBranch]).then((s) => s.trim()),
git.status()
]);
const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map((f) => f.path));
if (!await isAncestor(git, previousBaseRef, contentrainTip)) throw Object.assign(/* @__PURE__ */ new Error(`Cannot fast-forward "${baseBranch}" to contentrain tip. The base branch has diverged. Merge "${baseBranch}" into "${CONTENTRAIN_BRANCH}" first.`), {
code: "BASE_UPDATE_FAILED",
agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`
});
await git.raw([
"update-ref",
`refs/heads/${baseBranch}`,
contentrainTip
]);
try {
await git.raw(["read-tree", "HEAD"]);
} catch {
try {
await git.raw(["reset", "HEAD"]);
} catch {}
}
const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate);
if (hasRemote) {
try {
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {
try {
await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH);
await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, "--no-edit"]);
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {}
}
try {
await git.push(remoteName, baseBranch);
} catch {}
}
await safeDeleteBranch(git, branchName);
let remote;
if (hasRemote) remote = await deleteRemoteBranch(projectRoot, branchName, { config });
return {
action: "merged",
commit: contentrainTip,
sync,
...remote ? { remote } : {}
};
} finally {
try {
await git.raw([
"worktree",
"remove",
worktreePath,
"--force"
]);
} catch {}
}
}
function buildBranchName(scope, target, locale) {
const ts = branchTimestamp();
const parts = [
"cr",
scope,
target
];
if (locale) parts.push(locale);
parts.push(ts);
return parts.join("/");
}
/**
* True when `ancestor` is an ancestor of (or equal to) `descendant`.
* Implemented with `rev-list --count` because `merge-base --is-ancestor`
* signals via exit code with empty stderr — simple-git reports that as
* success, so it cannot express a negative verdict.
*/
async function isAncestor(git, ancestor, descendant) {
try {
return Number((await git.raw([
"rev-list",
"--count",
ancestor,
`^${descendant}`
])).trim()) === 0;
} catch {
return false;
}
}
/**
* Force-delete a local branch, swallowing all errors. Never deletes the
* singleton `contentrain` branch. Used to prune feature branches after they
* are merged (auto-merge / contentrain_merge) or when a transaction fails
* before completing — so failed/merged `cr/*` refs do not accumulate.
*/
async function safeDeleteBranch(git, branch) {
if (!branch || branch === CONTENTRAIN_BRANCH) return;
try {
await git.raw([
"branch",
"-D",
branch
]);
} catch {}
}
/**
* Regenerate `.contentrain/context.json` deterministically inside a worktree
* that is currently on the `contentrain` branch, then commit it (hooks
* bypassed). Called AFTER a feature branch is merged so context.json is only
* ever written on `contentrain`, single-threaded — eliminating the per-branch
* merge conflicts that came from committing it on every feature branch.
*/
async function regenerateContextOnContentrain(wtGit, worktreePath, contextUpdate) {
await writeContext(worktreePath, contextUpdate);
await wtGit.add(".contentrain/context.json");
try {
await wtGit.commit("[contentrain] context: update", { "--no-verify": null });
} catch {}
}
//#endregion
export { mergeBranch as i, createTransaction as n, ensureContentBranch as r, buildBranchName as t };
//# sourceMappingURL=transaction-DhVoGigz.mjs.map
{"version":3,"file":"transaction-DhVoGigz.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
-1

@@ -1,1 +0,1 @@

{"version":3,"file":"branch-lifecycle-AwBJIheA.d.mts","names":[],"sources":["../src/git/branch-lifecycle.ts"],"mappings":";;;;UAIiB,aAAA;EACf,OAAA;EACA,SAAA;EACA,eAAA;AAAA;AAAA,UAGe,iBAAA;EACf,KAAA;EACA,MAAA;EACA,QAAA;EACA,OAAA;EACA,OAAA;EACA,OAAA;AAAA;;;;;iBAOoB,qBAAA,CAAsB,WAAA,WAAsB,OAAA,CAAQ,aAAA;;;;;;iBAoEpD,iBAAA,CAAkB,WAAA,WAAsB,OAAA,CAAQ,iBAAA;AAAA,UAkDrD,gBAAA;;EAEf,MAAA;EAxH0C;EA0H1C,IAAA;EA1HwE;EA4HxE,IAAA;EA5HqF;EA8HrF,KAAA;EA1DqC;EA4DrC,YAAA;AAAA;;;;;;AAVF;;;;;;;;iBA0BsB,UAAA,CACpB,WAAA,UACA,IAAA;EAAQ,MAAA;EAAgB,IAAA;AAAA,IACvB,OAAA,CAAQ,gBAAA;;;;;;iBA0FW,WAAA,CACpB,GAAA,EAAK,SAAA,EACL,GAAA,UACA,IAAA,UACA,IAAA;EAAS,gBAAA;AAAA,IACR,OAAA;;;;AALH;;;;;;;;;;;iBAiCsB,sBAAA,CACpB,WAAA,UACA,QAAA,YACA,IAAA,WACA,IAAA;EAAS,iBAAA;AAAA,IACR,OAAA,CAAQ,GAAA;AAAA,UA4EM,kBAAA;EACf,OAAA;EA7EQ;EA+ER,OAAA;EAlFA;EAoFA,OAAA;AAAA;;;;;;AALF;;;iBAgBsB,kBAAA,CACpB,WAAA,UACA,MAAA,UACA,IAAA;EAAS,MAAA,GAAS,iBAAA;EAA0B,SAAA;AAAA,IAC3C,OAAA,CAAQ,kBAAA;AAAA,UAuBM,gBAAA;EACf,MAAA;EACA,QAAA;IAAY,IAAA;IAAc,GAAA;EAAA;EAzBjB;EA2BT,KAAA;AAAA;;;;;;iBAQoB,oBAAA,CACpB,WAAA,UACA,IAAA;EAAS,SAAA;AAAA,IACR,OAAA,CAAQ,gBAAA;AAAA,UAoBM,iBAAA;EA1DY;EA4D3B,OAAA;EACA,IAAA;EACA,MAAA;EACA,OAAA;AAAA;;;;;;;;iBAYoB,yBAAA,CACpB,WAAA,UACA,IAAA;EAAS,MAAA,GAAS,iBAAA;EAA0B,GAAA;EAAc,MAAA;EAAkB,SAAA;AAAA,IAC3E,OAAA,CAAQ,iBAAA"}
{"version":3,"file":"branch-lifecycle-AwBJIheA.d.mts","names":[],"sources":["../src/git/branch-lifecycle.ts"],"mappings":";;;;UAKiB,aAAA;EACf,OAAA;EACA,SAAA;EACA,eAAA;AAAA;AAAA,UAGe,iBAAA;EACf,KAAA;EACA,MAAA;EACA,QAAA;EACA,OAAA;EACA,OAAA;EACA,OAAA;AAAA;;;;;iBAOoB,qBAAA,CAAsB,WAAA,WAAsB,OAAA,CAAQ,aAAA;;;;;;iBAoEpD,iBAAA,CAAkB,WAAA,WAAsB,OAAA,CAAQ,iBAAA;AAAA,UAkDrD,gBAAA;;EAEf,MAAA;EAxH0C;EA0H1C,IAAA;EA1HwE;EA4HxE,IAAA;EA5HqF;EA8HrF,KAAA;EA1DqC;EA4DrC,YAAA;AAAA;;;;;;AAVF;;;;;;;;iBA0BsB,UAAA,CACpB,WAAA,UACA,IAAA;EAAQ,MAAA;EAAgB,IAAA;AAAA,IACvB,OAAA,CAAQ,gBAAA;;;;;;iBA0FW,WAAA,CACpB,GAAA,EAAK,SAAA,EACL,GAAA,UACA,IAAA,UACA,IAAA;EAAS,gBAAA;AAAA,IACR,OAAA;;;;AALH;;;;;;;;;;;iBAiCsB,sBAAA,CACpB,WAAA,UACA,QAAA,YACA,IAAA,WACA,IAAA;EAAS,iBAAA;AAAA,IACR,OAAA,CAAQ,GAAA;AAAA,UAkFM,kBAAA;EACf,OAAA;EAnFQ;EAqFR,OAAA;EAxFA;EA0FA,OAAA;AAAA;;;;;;AALF;;;iBAgBsB,kBAAA,CACpB,WAAA,UACA,MAAA,UACA,IAAA;EAAS,MAAA,GAAS,iBAAA;EAA0B,SAAA;AAAA,IAC3C,OAAA,CAAQ,kBAAA;AAAA,UAuBM,gBAAA;EACf,MAAA;EACA,QAAA;IAAY,IAAA;IAAc,GAAA;EAAA;EAzBjB;EA2BT,KAAA;AAAA;;;;;;iBAQoB,oBAAA,CACpB,WAAA,UACA,IAAA;EAAS,SAAA;AAAA,IACR,OAAA,CAAQ,gBAAA;AAAA,UAoBM,iBAAA;EA1DY;EA4D3B,OAAA;EACA,IAAA;EACA,MAAA;EACA,OAAA;AAAA;;;;;;;;iBAYoB,yBAAA,CACpB,WAAA,UACA,IAAA;EAAS,MAAA,GAAS,iBAAA;EAA0B,GAAA;EAAc,MAAA;EAAkB,SAAA;AAAA,IAC3E,OAAA,CAAQ,iBAAA"}

@@ -7,6 +7,6 @@ import "../serialization-B1CEzR4H.mjs";

import "../context-CYN__o3Q.mjs";
import "../branch-lifecycle-D7tXOE4f.mjs";
import "../branch-lifecycle-Dd84lx37.mjs";
import "../id-DV_T9Ic8.mjs";
import "../transaction-qliKvBRL.mjs";
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-BnPbPXFG.mjs";
import "../transaction-DhVoGigz.mjs";
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-UXUTYFZn.mjs";
export { PATCHABLE_EXTENSIONS, applyExtract, applyReuse, checkSyntax, detectFileFramework, replaceInLine, validateFrameworkExpression, validatePatchPath };

@@ -5,5 +5,5 @@ import "../fs-DLbVB-Ek.mjs";

import "../meta-manager-C900rEyG.mjs";
import "../branch-lifecycle-D7tXOE4f.mjs";
import "../branch-lifecycle-Dd84lx37.mjs";
import "../scan-config-BGUflS8t.mjs";
import { t as runDoctor } from "../doctor-BTToHt0y.mjs";
import { t as runDoctor } from "../doctor-BwSJW1sS.mjs";
export { runDoctor };
import "../fs-DLbVB-Ek.mjs";
import "../config-DlFcTxFG.mjs";
import { a as deleteRemoteBranch, c as pruneMergedRemoteBranches, i as cleanupMergedBranches, n as checkBranchHealth, o as isRefMerged, r as classifyMergedBranches, s as listRemoteCrBranches, t as branchDiff } from "../branch-lifecycle-D7tXOE4f.mjs";
import { a as deleteRemoteBranch, c as pruneMergedRemoteBranches, i as cleanupMergedBranches, n as checkBranchHealth, o as isRefMerged, r as classifyMergedBranches, s as listRemoteCrBranches, t as branchDiff } from "../branch-lifecycle-Dd84lx37.mjs";
export { branchDiff, checkBranchHealth, classifyMergedBranches, cleanupMergedBranches, deleteRemoteBranch, isRefMerged, listRemoteCrBranches, pruneMergedRemoteBranches };

@@ -1,1 +0,1 @@

{"version":3,"file":"transaction.d.mts","names":[],"sources":["../../src/git/transaction.ts"],"mappings":";;;;UAaiB,aAAA;EACf,IAAA;EACA,KAAA;EACA,MAAA;EACA,OAAA;AAAA;AAAA,UAGe,cAAA;EACf,QAAA;EACA,MAAA;EACA,KAAA,CAAM,QAAA,GAAW,YAAA,aAAyB,OAAA,SAAgB,OAAA;EAC1D,MAAA,CAAO,OAAA,UAAiB,aAAA,GAAgB,aAAA,GAAgB,OAAA;EACxD,QAAA,IAAY,OAAA;IAAU,MAAA;IAA0C,MAAA;IAAgB,IAAA,GAAO,UAAA;IAAY,OAAA;EAAA;EACnG,OAAA,IAAW,OAAA;AAAA;AAAA,iBAGS,mBAAA,CAAoB,WAAA,WAAsB,OAAA;AAAA,iBA+J1C,iBAAA,CACpB,WAAA,UACA,UAAA,UACA,OAAA;EAAY,gBAAA,GAAmB,YAAA;AAAA,IAC9B,OAAA,CAAQ,cAAA;AAAA,iBA2OW,WAAA,CACpB,WAAA,UACA,UAAA,WACC,OAAA;EAAU,MAAA;EAAkB,MAAA;EAAgB,IAAA,EAAM,UAAA;EAAY,MAAA,GAAS,kBAAA;AAAA;AAAA,iBAqI1D,eAAA,CAAgB,KAAA,UAAe,MAAA,UAAgB,MAAA"}
{"version":3,"file":"transaction.d.mts","names":[],"sources":["../../src/git/transaction.ts"],"mappings":";;;;UAciB,aAAA;EACf,IAAA;EACA,KAAA;EACA,MAAA;EACA,OAAA;AAAA;AAAA,UAGe,cAAA;EACf,QAAA;EACA,MAAA;EACA,KAAA,CAAM,QAAA,GAAW,YAAA,aAAyB,OAAA,SAAgB,OAAA;EAC1D,MAAA,CAAO,OAAA,UAAiB,aAAA,GAAgB,aAAA,GAAgB,OAAA;EACxD,QAAA,IAAY,OAAA;IAAU,MAAA;IAA0C,MAAA;IAAgB,IAAA,GAAO,UAAA;IAAY,OAAA;EAAA;EACnG,OAAA,IAAW,OAAA;AAAA;AAAA,iBAGS,mBAAA,CAAoB,WAAA,WAAsB,OAAA;AAAA,iBA4I1C,iBAAA,CACpB,WAAA,UACA,UAAA,UACA,OAAA;EAAY,gBAAA,GAAmB,YAAA;AAAA,IAC9B,OAAA,CAAQ,cAAA;AAAA,iBA4OW,WAAA,CACpB,WAAA,UACA,UAAA,WACC,OAAA;EAAU,MAAA;EAAkB,MAAA;EAAgB,IAAA,EAAM,UAAA;EAAY,MAAA,GAAS,kBAAA;AAAA;AAAA,iBAsI1D,eAAA,CAAgB,KAAA,UAAe,MAAA,UAAgB,MAAA"}

@@ -7,5 +7,5 @@ import "../serialization-B1CEzR4H.mjs";

import "../context-CYN__o3Q.mjs";
import "../branch-lifecycle-D7tXOE4f.mjs";
import "../branch-lifecycle-Dd84lx37.mjs";
import "../id-DV_T9Ic8.mjs";
import { i as mergeBranch, n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "../transaction-qliKvBRL.mjs";
import { i as mergeBranch, n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "../transaction-DhVoGigz.mjs";
export { buildBranchName, createTransaction, ensureContentBranch, mergeBranch };

@@ -10,9 +10,9 @@ #!/usr/bin/env node

import "./context-CYN__o3Q.mjs";
import "./branch-lifecycle-D7tXOE4f.mjs";
import "./branch-lifecycle-Dd84lx37.mjs";
import "./id-DV_T9Ic8.mjs";
import "./transaction-qliKvBRL.mjs";
import "./local-BZEUWBoC.mjs";
import "./transaction-DhVoGigz.mjs";
import "./local-BIE23GuI.mjs";
import "./detect-wVSI9VuY.mjs";
import "./annotations-D3tlsF38.mjs";
import { n as createServer } from "./server-DtabGAY6.mjs";
import { n as createServer } from "./server-BkDuFvVt.mjs";
import "./validator-8vaPxSba.mjs";

@@ -23,4 +23,4 @@ import "./scan-config-BGUflS8t.mjs";

import "./tsx-parser-B_aI_C2r.mjs";
import "./apply-manager-BnPbPXFG.mjs";
import "./doctor-BTToHt0y.mjs";
import "./apply-manager-UXUTYFZn.mjs";
import "./doctor-BwSJW1sS.mjs";
import { resolve } from "node:path";

@@ -27,0 +27,0 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

@@ -9,6 +9,6 @@ import "../../contracts-DfL0BfrD.mjs";

import "../../context-CYN__o3Q.mjs";
import "../../branch-lifecycle-D7tXOE4f.mjs";
import "../../branch-lifecycle-Dd84lx37.mjs";
import "../../id-DV_T9Ic8.mjs";
import "../../transaction-qliKvBRL.mjs";
import { t as LocalProvider } from "../../local-BZEUWBoC.mjs";
import "../../transaction-DhVoGigz.mjs";
import { t as LocalProvider } from "../../local-BIE23GuI.mjs";
export { LocalProvider, LocalReader };

@@ -9,9 +9,9 @@ import "./contracts-DfL0BfrD.mjs";

import "./context-CYN__o3Q.mjs";
import "./branch-lifecycle-D7tXOE4f.mjs";
import "./branch-lifecycle-Dd84lx37.mjs";
import "./id-DV_T9Ic8.mjs";
import "./transaction-qliKvBRL.mjs";
import "./local-BZEUWBoC.mjs";
import "./transaction-DhVoGigz.mjs";
import "./local-BIE23GuI.mjs";
import "./detect-wVSI9VuY.mjs";
import "./annotations-D3tlsF38.mjs";
import { n as createServer, t as DEFAULT_INSTRUCTIONS } from "./server-DtabGAY6.mjs";
import { n as createServer, t as DEFAULT_INSTRUCTIONS } from "./server-BkDuFvVt.mjs";
import "./validator-8vaPxSba.mjs";

@@ -22,4 +22,4 @@ import "./scan-config-BGUflS8t.mjs";

import "./tsx-parser-B_aI_C2r.mjs";
import "./apply-manager-BnPbPXFG.mjs";
import "./doctor-BTToHt0y.mjs";
import "./apply-manager-UXUTYFZn.mjs";
import "./doctor-BwSJW1sS.mjs";
export { DEFAULT_INSTRUCTIONS, createServer };

@@ -9,9 +9,9 @@ import "../../contracts-DfL0BfrD.mjs";

import "../../context-CYN__o3Q.mjs";
import "../../branch-lifecycle-D7tXOE4f.mjs";
import "../../branch-lifecycle-Dd84lx37.mjs";
import "../../id-DV_T9Ic8.mjs";
import "../../transaction-qliKvBRL.mjs";
import "../../local-BZEUWBoC.mjs";
import "../../transaction-DhVoGigz.mjs";
import "../../local-BIE23GuI.mjs";
import "../../detect-wVSI9VuY.mjs";
import "../../annotations-D3tlsF38.mjs";
import { n as createServer } from "../../server-DtabGAY6.mjs";
import { n as createServer } from "../../server-BkDuFvVt.mjs";
import "../../validator-8vaPxSba.mjs";

@@ -22,4 +22,4 @@ import "../../scan-config-BGUflS8t.mjs";

import "../../tsx-parser-B_aI_C2r.mjs";
import "../../apply-manager-BnPbPXFG.mjs";
import "../../doctor-BTToHt0y.mjs";
import "../../apply-manager-UXUTYFZn.mjs";
import "../../doctor-BwSJW1sS.mjs";
import { randomUUID } from "node:crypto";

@@ -26,0 +26,0 @@ import http from "node:http";

{
"name": "@contentrain/mcp",
"version": "1.10.0",
"version": "1.10.1",
"mcpName": "io.github.Contentrain/contentrain",

@@ -158,3 +158,3 @@ "license": "MIT",

"@modelcontextprotocol/sdk": "^1.12.0",
"simple-git": "^3.27.0",
"simple-git": "^3.36.0",
"typescript": "^5.7.0",

@@ -161,0 +161,0 @@ "zod": "^3.24.0",

import { c as writeText, o as readText, r as pathExists } from "./fs-DLbVB-Ek.mjs";
import { t as readConfig } from "./config-DlFcTxFG.mjs";
import { S as writeContent, _ as resolveMdFilePath, c as validateModelDefinition, h as resolveJsonFilePath, l as writeModel, m as resolveContentDir, o as listModels, s as readModel } from "./model-manager-BhLsUgaB.mjs";
import { r as writeContext } from "./context-CYN__o3Q.mjs";
import { n as checkBranchHealth } from "./branch-lifecycle-D7tXOE4f.mjs";
import { n as createTransaction, t as buildBranchName } from "./transaction-qliKvBRL.mjs";
import { extname, join } from "node:path";
//#region src/core/apply-manager.ts
const MAX_PATCHES = 100;
/** File extensions allowed for patching — scannable source files only */
const PATCHABLE_EXTENSIONS = new Set([
".vue",
".tsx",
".jsx",
".ts",
".js",
".mjs",
".astro",
".svelte"
]);
/** Directories that must never be patched */
const FORBIDDEN_PATH_SEGMENTS = new Set([
".contentrain",
"node_modules",
".git",
"dist",
"build",
".next",
".nuxt"
]);
function detectFileFramework(filePath) {
switch (extname(filePath).toLowerCase()) {
case ".vue": return "vue";
case ".svelte": return "svelte";
case ".tsx":
case ".jsx": return "jsx";
case ".astro": return "astro";
case ".ts":
case ".js":
case ".mjs": return "script";
default: return "script";
}
}
/**
* Validate that a replacement expression uses the correct template syntax
* for the target file's framework. Returns a warning string or null.
*/
function validateFrameworkExpression(filePath, newExpression, context) {
if (context !== "tag_text") return null;
switch (detectFileFramework(filePath)) {
case "vue":
if (!newExpression.includes("{{")) return `Vue file "${filePath}": tag text expression "${newExpression}" does not contain "{{" — expected Vue template syntax like {{ $t('key') }}`;
break;
case "jsx":
if (!newExpression.includes("{")) return `JSX file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected JSX syntax like {t('key')}`;
break;
case "svelte":
if (!newExpression.includes("{")) return `Svelte file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected Svelte syntax like {$t('key')}`;
break;
case "astro":
if (!newExpression.includes("{")) return `Astro file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected Astro syntax like {t('key')}`;
break;
case "script": return `Script file "${filePath}": tag text replacement not applicable for .ts/.js files`;
}
return null;
}
/**
* Validate that a patch file path is safe and within allowed scope.
* Returns an error message or null if valid.
*/
function validatePatchPath(filePath) {
const normalizedPath = filePath.replace(/\\/g, "/");
if (normalizedPath.includes("..")) return `Path traversal detected: "${filePath}"`;
if (normalizedPath.startsWith("/")) return `Absolute path not allowed: "${filePath}"`;
const segments = normalizedPath.split("/");
for (const seg of segments) if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return `Patching files inside "${seg}/" is not allowed: "${filePath}"`;
const ext = extname(filePath).toLowerCase();
if (!PATCHABLE_EXTENSIONS.has(ext)) return `File extension "${ext}" is not patchable. Allowed: ${[...PATCHABLE_EXTENSIONS].join(", ")}. Path: "${filePath}"`;
return null;
}
/**
* Perform a basic syntax check on a patched file.
* Returns an error message or null if syntax appears valid.
*/
function checkSyntax(filePath, content) {
switch (extname(filePath).toLowerCase()) {
case ".ts":
case ".tsx":
case ".js":
case ".jsx":
case ".mjs": return checkJsSyntax(content);
case ".vue": return checkVueSyntax(content);
case ".svelte":
case ".astro": return checkTagBalance(content);
default: return null;
}
}
/**
* Basic JS/TS syntax check: bracket/paren/brace balance + string literal closure.
* This is intentionally conservative — it catches obvious breakage without
* requiring a full parser.
*/
function checkJsSyntax(content) {
const stack = [];
const pairs = {
")": "(",
"]": "[",
"}": "{"
};
let inString = null;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
for (let i = 0; i < content.length; i++) {
const ch = content[i];
const next = content[i + 1];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\" && inString !== null) {
escaped = true;
continue;
}
if (!inString && !inBlockComment && ch === "/" && next === "/") {
inLineComment = true;
continue;
}
if (inLineComment) {
if (ch === "\n") inLineComment = false;
continue;
}
if (!inString && !inBlockComment && ch === "/" && next === "*") {
inBlockComment = true;
i++;
continue;
}
if (inBlockComment) {
if (ch === "*" && next === "/") {
inBlockComment = false;
i++;
}
continue;
}
if (inString !== null) {
if (ch === inString) inString = null;
else if (inString !== "`" && ch === "\n") return `Unterminated string literal near offset ${i}`;
continue;
}
if (ch === "\"" || ch === "'" || ch === "`") {
inString = ch;
continue;
}
if (ch === "(" || ch === "[" || ch === "{") stack.push(ch);
else if (ch === ")" || ch === "]" || ch === "}") {
const expected = pairs[ch];
if (stack.length === 0) return `Unmatched closing "${ch}" near offset ${i}`;
const top = stack.pop();
if (top !== expected) return `Mismatched bracket: expected closing for "${top}" but found "${ch}" near offset ${i}`;
}
}
if (inString !== null) return `Unterminated string literal (opened with ${inString})`;
if (stack.length > 0) return `Unclosed bracket "${stack[stack.length - 1]}" — ${stack.length} unclosed bracket(s)`;
return null;
}
/**
* Vue SFC syntax check: ensure <template>, <script>, <style> tags are balanced.
*/
function checkVueSyntax(content) {
const tagBalance = checkTagBalance(content);
if (tagBalance) return tagBalance;
for (const tag of ["template", "script"]) {
const openRe = new RegExp(`<${tag}[\\s>]`, "g");
const closeRe = new RegExp(`</${tag}>`, "g");
const opens = content.match(openRe)?.length ?? 0;
const closes = content.match(closeRe)?.length ?? 0;
if (opens !== closes) return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`;
}
return null;
}
/**
* Basic tag balance check for HTML-like files.
* Checks that self-closing tags are handled and major structural tags are balanced.
*/
function checkTagBalance(content) {
for (const tag of [
"div",
"section",
"main",
"header",
"footer",
"nav",
"article",
"aside",
"ul",
"ol",
"table"
]) {
const openRe = new RegExp(`<${tag}[\\s>]`, "g");
const closeRe = new RegExp(`</${tag}>`, "g");
const opens = content.match(openRe)?.length ?? 0;
const closes = content.match(closeRe)?.length ?? 0;
if (opens !== closes) return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`;
}
return null;
}
async function applyExtract(projectRoot, input) {
const config = await readConfig(projectRoot);
if (!config) throw new Error("Project not initialized. Run contentrain_init first.");
const { extractions, dry_run } = input;
const existingModels = await listModels(projectRoot);
const existingIds = new Set(existingModels.map((m) => m.id));
const modelsToCreate = [];
const modelsToUpdate = [];
const contentFiles = [];
let totalEntries = 0;
const validationErrors = [];
for (const ext of extractions) {
const modelErrors = validateModelDefinition({
id: ext.model,
kind: ext.kind,
fields: ext.fields
});
if (modelErrors.length > 0) validationErrors.push(...modelErrors.map((e) => `[${ext.model}] ${e}`));
for (const entry of ext.entries) if (ext.kind === "dictionary") {
if (entry.data["id"] !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Dictionary entries should not have id or slug`);
for (const [key, val] of Object.entries(entry.data)) if (typeof val !== "string") validationErrors.push(`[${ext.model}] Dictionary entry value for key "${key}" must be a string, got ${typeof val}`);
} else if (ext.kind === "document") {
if (!entry.slug && !entry.data["slug"]) validationErrors.push(`[${ext.model}] Document entries must have a slug`);
} else if (ext.kind === "collection") {
if (entry.slug !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Collection entries should not have slug`);
} else if (ext.kind === "singleton") {
if (entry.data["id"] !== void 0 || entry.slug !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Singleton entries should not have id or slug`);
}
if (existingIds.has(ext.model)) modelsToUpdate.push(ext.model);
else modelsToCreate.push(ext.model);
totalEntries += ext.entries.length;
let previewModel;
if (existingIds.has(ext.model)) {
const real = await readModel(projectRoot, ext.model);
if (real) previewModel = real;
else previewModel = {
id: ext.model,
kind: ext.kind,
domain: ext.domain,
i18n: ext.i18n ?? true
};
} else previewModel = {
id: ext.model,
kind: ext.kind,
domain: ext.domain,
i18n: ext.i18n ?? true
};
const cDir = resolveContentDir(projectRoot, previewModel);
for (const entry of ext.entries) {
const locale = entry.locale ?? config.locales.default;
if (ext.kind === "document" && entry.slug) contentFiles.push(resolveMdFilePath(cDir, previewModel, locale, entry.slug));
else contentFiles.push(resolveJsonFilePath(cDir, previewModel, locale));
}
}
const preview = {
models_to_create: modelsToCreate,
models_to_update: modelsToUpdate,
total_entries: totalEntries,
content_files: [...new Set(contentFiles)]
};
if (dry_run !== false) return {
dry_run: true,
preview,
...validationErrors.length > 0 ? { validation_errors: validationErrors } : {},
next_steps: [
...validationErrors.length > 0 ? [`WARNING: ${validationErrors.length} validation error(s) found — fix before executing`] : [],
"Review the preview above",
"Call contentrain_apply with mode:extract and dry_run:false to execute"
]
};
if (validationErrors.length > 0) return {
dry_run: false,
error: "Model validation failed — cannot execute extract with invalid model definitions",
validation_errors: validationErrors,
next_steps: ["Fix the validation errors and retry"]
};
const health = await checkBranchHealth(projectRoot);
if (health.blocked) return {
error: health.message,
action: "blocked",
hint: "Merge or delete old contentrain/* branches before executing normalize."
};
const branchName = buildBranchName("normalize", "extract");
const tx = await createTransaction(projectRoot, branchName, { workflowOverride: "review" });
const sourceMap = [];
const modelsCreated = [];
const modelsUpdated = [];
let entriesWritten = 0;
try {
await tx.write(async (wt) => {
for (const ext of extractions) {
const existing = await readModel(wt, ext.model);
if (existing) {
if (ext.fields) {
const merged = {
...existing.fields,
...ext.fields
};
if (Object.keys(ext.fields).filter((k) => !(k in (existing.fields ?? {}))).length > 0) {
existing.fields = merged;
await writeModel(wt, existing);
modelsUpdated.push(ext.model);
}
}
} else {
await writeModel(wt, {
id: ext.model,
name: ext.model.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "),
kind: ext.kind,
domain: ext.domain,
i18n: ext.i18n ?? true,
fields: ext.fields
});
modelsCreated.push(ext.model);
}
const model = await readModel(wt, ext.model);
const entries = ext.entries.map((e) => ({
locale: e.locale,
slug: e.slug,
data: e.data
}));
await writeContent(wt, model, entries, await readConfig(wt) ?? config);
entriesWritten += entries.length;
for (const entry of ext.entries) if (ext.kind === "dictionary" && entry.sources) for (const s of entry.sources) sourceMap.push({
model: ext.model,
locale: entry.locale ?? config.locales.default,
value: s.value,
file: s.file,
line: s.line
});
else if (entry.source) sourceMap.push({
model: ext.model,
locale: entry.locale ?? config.locales.default,
value: entry.source.value,
file: entry.source.file,
line: entry.source.line
});
}
if (sourceMap.length > 0) {
const sourcesByModel = {};
for (const s of sourceMap) {
if (!sourcesByModel[s.model]) sourcesByModel[s.model] = {
source_files: [],
entry_count: 0
};
const modelEntry = sourcesByModel[s.model];
if (!modelEntry.source_files.includes(s.file)) modelEntry.source_files.push(s.file);
modelEntry.entry_count++;
}
const sourcesJson = JSON.stringify({
version: 1,
created_at: (/* @__PURE__ */ new Date()).toISOString(),
models: sourcesByModel
}, null, 2) + "\n";
await writeText(join(wt, ".contentrain", "normalize-sources.json"), sourcesJson);
}
await writeContext(wt, {
tool: "contentrain_apply",
model: extractions.map((e) => e.model).join(","),
locale: config.locales.default,
entries: extractions.flatMap((e) => e.entries.map((en) => en.slug ?? "entry"))
});
});
const commitMsg = `[contentrain] normalize: extract ${entriesWritten} entries to ${extractions.length} models`;
await tx.commit(commitMsg);
const gitResult = {
branch: branchName,
action: "pending-review",
commit: ""
};
try {
const completed = await tx.complete();
gitResult.action = completed.action;
gitResult.commit = completed.commit;
} catch {
gitResult.action = "pending-review";
} finally {
await tx.cleanup();
}
return {
dry_run: false,
results: {
models_created: modelsCreated,
models_updated: modelsUpdated,
entries_written: entriesWritten,
source_map: sourceMap
},
git: gitResult,
context_updated: true,
next_steps: [
"Run contentrain_validate to check the extracted content",
"Run contentrain_submit to push the branch for review",
"For browser-based review: ensure `contentrain serve` is running, direct user to http://localhost:3333/normalize",
"For terminal workflow: use contentrain_merge to merge the branch locally",
"After merge, run `npx contentrain generate` to update SDK client",
"After review, proceed with mode:reuse to patch source files"
]
};
} catch (error) {
await tx.cleanup();
throw error;
}
}
async function applyReuse(projectRoot, input) {
const config = await readConfig(projectRoot);
if (!config) throw new Error("Project not initialized. Run contentrain_init first.");
const { scope, patches, dry_run } = input;
if (!scope.model && !scope.domain) throw new Error("Scope required: provide model or domain. Whole-project patching is not allowed.");
if (patches.length > MAX_PATCHES) throw new Error(`Too many patches (${patches.length}). Maximum ${MAX_PATCHES} per operation. Split into multiple calls.`);
if (scope.model) {
if (!await readModel(projectRoot, scope.model)) throw new Error(`Model "${scope.model}" not found. Run extract phase first.`);
}
const scopeWarnings = [];
for (const patch of patches) {
const pathError = validatePatchPath(patch.file);
if (pathError) throw new Error(`Invalid patch path: ${pathError}`);
}
if (scope.model || scope.domain) {
const models = await listModels(projectRoot);
const scopeModels = scope.model ? models.filter((m) => m.id === scope.model) : scope.domain ? models.filter((m) => m.domain === scope.domain) : models;
if (scopeModels.length === 0) throw new Error(`No models found for scope ${scope.model ? `model="${scope.model}"` : `domain="${scope.domain}"`}`);
const { autoDetectSourceDirs } = await import("./core/scan-config.mjs");
const sourceDirs = await autoDetectSourceDirs(projectRoot);
const allowedPrefixes = sourceDirs.map((d) => d === "." ? "" : d + "/");
for (const patch of patches) {
const normalizedPath = patch.file.replace(/\\/g, "/");
if (normalizedPath.startsWith(".contentrain/") || normalizedPath.includes("/.contentrain/")) throw new Error(`Cannot patch content/config files directly: "${patch.file}". Reuse patches source files only.`);
if (sourceDirs.length > 0 && sourceDirs[0] !== ".") {
if (!allowedPrefixes.some((prefix) => prefix === "" || normalizedPath.startsWith(prefix))) throw new Error(`Patch file "${patch.file}" is outside detected source directories (${sourceDirs.join(", ")}). Reuse patches must target source files within the project's source tree.`);
}
}
if (scope.model || scope.domain) {
const sourcesRaw = await readText(join(projectRoot, ".contentrain", "normalize-sources.json"));
if (!sourcesRaw) if (scopeModels.find((m) => m.id === scope.model)?.kind === "dictionary") scopeWarnings.push("normalize-sources.json not found. Dictionary models do not generate per-file source maps. Scope enforcement is based on source-tree locality only.");
else if (dry_run !== false) scopeWarnings.push("normalize-sources.json not found. Semantic scope enforcement is unavailable. Merge the extract branch first, then reuse will have full scope protection.");
else throw new Error("Cannot execute reuse: normalize-sources.json not found on base branch. The extract branch must be merged before reuse can execute. This ensures semantic scope enforcement protects against out-of-scope patching.");
else {
const sourcesData = JSON.parse(sourcesRaw);
let allowedSourceFiles = [];
let scopeLabel = "";
if (scope.model) {
const modelSources = sourcesData.models?.[scope.model]?.source_files;
if (modelSources) allowedSourceFiles = modelSources;
scopeLabel = `model "${scope.model}"`;
} else if (scope.domain) {
const domainModelIds = scopeModels.map((m) => m.id);
for (const modelId of domainModelIds) {
const modelSources = sourcesData.models?.[modelId]?.source_files;
if (modelSources) allowedSourceFiles.push(...modelSources);
}
allowedSourceFiles = [...new Set(allowedSourceFiles)];
scopeLabel = `domain "${scope.domain}"`;
}
if (allowedSourceFiles.length > 0) {
const outOfScopePatches = [];
for (const patch of patches) {
const normalizedPath = patch.file.replace(/\\/g, "/");
if (!allowedSourceFiles.includes(normalizedPath)) outOfScopePatches.push(patch.file);
}
if (outOfScopePatches.length > 0) throw new Error(`Scope enforcement: ${outOfScopePatches.length} patch file(s) are not associated with ${scopeLabel}. Out-of-scope files: ${outOfScopePatches.join(", ")}. Known source files: ${allowedSourceFiles.join(", ")}.`);
}
}
}
}
const patchesByFile = /* @__PURE__ */ new Map();
for (const patch of patches) {
if (!patchesByFile.has(patch.file)) patchesByFile.set(patch.file, []);
patchesByFile.get(patch.file).push(patch);
}
const filesToModify = [...patchesByFile.keys()];
const importsToAdd = patches.filter((p) => p.import_statement).length;
if (dry_run !== false) return {
dry_run: true,
preview: {
files_to_modify: filesToModify,
patches_count: patches.length,
imports_to_add: importsToAdd
},
...scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {},
next_steps: [
...scopeWarnings.length > 0 ? [`WARNING: ${scopeWarnings.length} patch file(s) not in extract source map — verify intent`] : [],
"Review the files and patches above",
"Call contentrain_apply with mode:reuse and dry_run:false to execute"
]
};
const reuseHealth = await checkBranchHealth(projectRoot);
if (reuseHealth.blocked) return {
dry_run: false,
error: `Branch blocked: ${reuseHealth.message}`,
next_steps: ["Merge or delete old contentrain/* branches before executing reuse."]
};
const scopeTarget = scope.model ?? scope.domain;
const branchName = buildBranchName("normalize/reuse", scopeTarget);
const tx = await createTransaction(projectRoot, branchName, { workflowOverride: "review" });
const filesModified = [];
let patchesApplied = 0;
let importsAdded = 0;
const patchesSkipped = [];
const frameworkWarnings = [];
const syntaxErrors = [];
try {
await tx.write(async (wt) => {
for (const [relFile, filePatches] of patchesByFile) {
const absPath = join(wt, relFile);
if (!await pathExists(absPath)) {
for (const p of filePatches) patchesSkipped.push({
file: relFile,
line: p.line,
reason: "file not found"
});
continue;
}
const content = await readText(absPath);
if (content === null) {
for (const p of filePatches) patchesSkipped.push({
file: relFile,
line: p.line,
reason: "file unreadable"
});
continue;
}
const sorted = [...filePatches].toSorted((a, b) => b.line - a.line);
const lines = content.split("\n");
let fileModified = false;
for (const patch of sorted) {
const isTagTextContext = (lines[patch.line - 1] ?? "").includes(`>${patch.old_value}<`);
const replacementContext = isTagTextContext ? "tag_text" : "other";
if (isTagTextContext) {
const fwWarning = validateFrameworkExpression(relFile, patch.new_expression, replacementContext);
if (fwWarning) frameworkWarnings.push({
file: relFile,
warning: fwWarning
});
}
if (applyPatchToLines(lines, patch)) {
patchesApplied++;
fileModified = true;
} else patchesSkipped.push({
file: relFile,
line: patch.line,
reason: "old_value not found at or near specified line"
});
}
const importStatements = new Set(filePatches.filter((p) => p.import_statement).map((p) => p.import_statement));
if (importStatements.size > 0) {
const added = addImportsToLines(lines, importStatements);
importsAdded += added;
if (added > 0) fileModified = true;
}
if (fileModified) {
const newContent = lines.join("\n");
await writeText(absPath, newContent);
filesModified.push(relFile);
const syntaxError = checkSyntax(relFile, newContent);
if (syntaxError) syntaxErrors.push({
file: relFile,
error: syntaxError
});
}
}
await writeContext(wt, {
tool: "contentrain_apply",
model: scopeTarget,
locale: config.locales.default
});
});
if (filesModified.length === 0) {
await tx.cleanup();
return {
dry_run: false,
results: {
files_modified: [],
patches_applied: 0,
patches_skipped: patchesSkipped,
imports_added: 0,
framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : void 0
},
next_steps: ["No files were modified. Check patch definitions and try again."]
};
}
const commitMsg = `[contentrain] normalize: reuse ${scopeTarget} — patch ${filesModified.length} files (${patchesApplied} replacements)`;
await tx.commit(commitMsg);
const gitResult = {
branch: branchName,
action: "pending-review",
commit: ""
};
try {
const completed = await tx.complete();
gitResult.action = completed.action;
gitResult.commit = completed.commit;
} catch {
gitResult.action = "pending-review";
} finally {
await tx.cleanup();
}
return {
dry_run: false,
results: {
files_modified: filesModified,
patches_applied: patchesApplied,
patches_skipped: patchesSkipped,
imports_added: importsAdded,
framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : void 0,
syntax_errors: syntaxErrors.length > 0 ? syntaxErrors : void 0
},
...scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {},
git: gitResult,
next_steps: [
"Run contentrain_validate to verify the patched files",
patchesSkipped.length > 0 ? `${patchesSkipped.length} patches were skipped — review and retry if needed` : "",
syntaxErrors.length > 0 ? `WARNING: ${syntaxErrors.length} file(s) may have syntax errors after patching — review manually` : "",
scopeWarnings.length > 0 ? `NOTE: ${scopeWarnings.length} patch file(s) not in extract source map` : "",
"Run contentrain_submit to push the branch for review",
"For review: direct user to http://localhost:3333/branches or use contentrain_merge",
"After all reuse phases complete, run `npx contentrain generate` to update SDK types"
].filter(Boolean)
};
} catch (error) {
await tx.cleanup();
throw error;
}
}
/**
* Apply a single patch to a lines array. Mutates lines in place.
* Uses line hint for proximity matching — searches ±10 lines from hint.
*/
function applyPatchToLines(lines, patch) {
const { line, old_value, new_expression } = patch;
const lineIdx = line - 1;
const searchStart = Math.max(0, lineIdx - 10);
const searchEnd = Math.min(lines.length, lineIdx + 11);
if (lineIdx >= 0 && lineIdx < lines.length) {
const replaced = replaceInLine(lines[lineIdx], old_value, new_expression);
if (replaced !== null) {
lines[lineIdx] = replaced;
return true;
}
}
for (let i = searchStart; i < searchEnd; i++) {
if (i === lineIdx) continue;
const replaced = replaceInLine(lines[i], old_value, new_expression);
if (replaced !== null) {
lines[i] = replaced;
return true;
}
}
return false;
}
/**
* Replace old_value with new_expression in a single line.
* Matches the string literal (quoted or unquoted tag text).
* Returns the modified line, or null if not found.
*
* Guardrail #4: Safer patch matching — word boundary awareness and
* ambiguity rejection for plain text fallback.
*/
function replaceInLine(line, oldValue, newExpression) {
for (const quote of [
"\"",
"'",
"`"
]) {
const quoted = `${quote}${oldValue}${quote}`;
if (line.includes(quoted)) return line.replace(quoted, newExpression);
}
if (line.includes(`>${oldValue}<`)) return line.replace(`>${oldValue}<`, `>${newExpression}<`);
if (line.includes(oldValue)) {
if (countOccurrences(line, oldValue) > 1) return null;
const idx = line.indexOf(oldValue);
const charBefore = idx > 0 ? line[idx - 1] : "";
const charAfter = idx + oldValue.length < line.length ? line[idx + oldValue.length] : "";
const oldStartsWithWord = oldValue.length > 0 && isWordChar(oldValue[0]);
const oldEndsWithWord = oldValue.length > 0 && isWordChar(oldValue[oldValue.length - 1]);
if (oldStartsWithWord && isWordChar(charBefore)) return null;
if (oldEndsWithWord && isWordChar(charAfter)) return null;
return line.replace(oldValue, newExpression);
}
return null;
}
/** Count non-overlapping occurrences of a substring */
function countOccurrences(str, sub) {
let count = 0;
let pos = 0;
while (pos <= str.length - sub.length) {
const idx = str.indexOf(sub, pos);
if (idx === -1) break;
count++;
pos = idx + sub.length;
}
return count;
}
/** Check if a character is a word character (letter, digit, underscore) */
function isWordChar(ch) {
return /\w/.test(ch);
}
/**
* Add import statements to the top of a file (after existing imports).
* Deduplicates — won't add if the import already exists.
* Returns number of imports actually added.
*/
function addImportsToLines(lines, imports) {
let added = 0;
const existingContent = lines.join("\n");
let lastImportIdx = -1;
let inMultiLineImport = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (inMultiLineImport) {
lastImportIdx = i;
if (trimmed.includes("}")) inMultiLineImport = false;
continue;
}
if (trimmed.startsWith("import ") || trimmed.startsWith("import{")) {
lastImportIdx = i;
if (trimmed.includes("{") && !trimmed.includes("}")) inMultiLineImport = true;
continue;
}
if (lastImportIdx >= 0 && trimmed.length > 0 && !trimmed.startsWith("//") && !trimmed.startsWith("/*") && !trimmed.startsWith("*")) break;
}
let insertAt;
if (lastImportIdx >= 0) insertAt = lastImportIdx + 1;
else {
insertAt = 0;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (i === 0 && trimmed.startsWith("#!")) {
insertAt = i + 1;
continue;
}
if (trimmed === "'use client'" || trimmed === "\"use client\"" || trimmed === "'use server'" || trimmed === "\"use server\"" || trimmed === "'use client';" || trimmed === "\"use client\";" || trimmed === "'use server';" || trimmed === "\"use server\";") {
insertAt = i + 1;
continue;
}
if (insertAt > 0 && trimmed.length > 0) break;
if (insertAt === 0 && trimmed.length > 0) break;
}
}
const toInsert = [];
for (const imp of imports) if (!existingContent.includes(imp)) {
toInsert.push(imp);
added++;
}
if (toInsert.length > 0) lines.splice(insertAt, 0, ...toInsert);
return added;
}
//#endregion
export { detectFileFramework as a, validatePatchPath as c, checkSyntax as i, applyExtract as n, replaceInLine as o, applyReuse as r, validateFrameworkExpression as s, PATCHABLE_EXTENSIONS as t };
//# sourceMappingURL=apply-manager-BnPbPXFG.mjs.map
{"version":3,"file":"apply-manager-BnPbPXFG.mjs","names":[],"sources":["../src/core/apply-manager.ts"],"sourcesContent":["import type { ModelDefinition, FieldDef, FileFramework } from '@contentrain/types'\nimport { join, extname } from 'node:path'\nimport { readText, writeText, pathExists } from '../util/fs.js'\nimport { readModel, writeModel, listModels, validateModelDefinition } from './model-manager.js'\nimport { writeContent, resolveContentDir, resolveJsonFilePath, resolveMdFilePath, type ContentEntry } from './content-manager.js'\nimport { readConfig } from './config.js'\nimport { writeContext } from './context.js'\nimport { createTransaction, buildBranchName } from '../git/transaction.js'\nimport { checkBranchHealth } from '../git/branch-lifecycle.js'\n\n// ─── Types ───\n\nexport interface ExtractionEntry {\n model: string\n kind: 'singleton' | 'collection' | 'dictionary' | 'document'\n domain: string\n i18n?: boolean\n fields?: Record<string, FieldDef>\n entries: Array<{\n locale?: string\n slug?: string\n data: Record<string, unknown>\n source?: { file: string; line: number; value: string }\n sources?: Array<{ file: string; line: number; key: string; value: string }>\n }>\n}\n\nexport interface ExtractionInput {\n extractions: ExtractionEntry[]\n dry_run?: boolean\n}\n\nexport interface ExtractionPreview {\n models_to_create: string[]\n models_to_update: string[]\n total_entries: number\n content_files: string[]\n}\n\nexport interface ExtractionResult {\n dry_run: boolean\n preview?: ExtractionPreview\n error?: string\n validation_errors?: string[]\n results?: {\n models_created: string[]\n models_updated: string[]\n entries_written: number\n source_map: Array<{ model: string; locale: string; value: string; file: string; line: number }>\n }\n git?: { branch: string; action: string; commit: string }\n context_updated?: boolean\n next_steps: string[]\n}\n\nexport interface PatchEntry {\n file: string\n line: number\n old_value: string\n new_expression: string\n import_statement?: string\n}\n\nexport interface ReuseInput {\n scope: { model?: string; domain?: string }\n patches: PatchEntry[]\n dry_run?: boolean\n}\n\nexport interface SyntaxError {\n file: string\n error: string\n}\n\nexport interface ReuseResult {\n dry_run: boolean\n error?: string\n scope_warnings?: string[]\n preview?: {\n files_to_modify: string[]\n patches_count: number\n imports_to_add: number\n }\n results?: {\n files_modified: string[]\n patches_applied: number\n patches_skipped: Array<{ file: string; line: number; reason: string }>\n imports_added: number\n framework_warnings?: Array<{ file: string; warning: string }>\n syntax_errors?: SyntaxError[]\n }\n git?: { branch: string; action: string; commit: string }\n next_steps: string[]\n}\n\n// ─── Constants ───\n\nconst MAX_PATCHES = 100\n\n/** File extensions allowed for patching — scannable source files only */\nexport const PATCHABLE_EXTENSIONS = new Set([\n '.vue', '.tsx', '.jsx', '.ts', '.js', '.mjs', '.astro', '.svelte',\n])\n\n/** Directories that must never be patched */\nconst FORBIDDEN_PATH_SEGMENTS = new Set([\n '.contentrain', 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt',\n])\n\n// ─── Framework Detection (Guardrail #2) ───\n\nexport type { FileFramework } from '@contentrain/types'\n\nexport function detectFileFramework(filePath: string): FileFramework {\n const ext = extname(filePath).toLowerCase()\n switch (ext) {\n case '.vue': return 'vue'\n case '.svelte': return 'svelte'\n case '.tsx':\n case '.jsx': return 'jsx'\n case '.astro': return 'astro'\n case '.ts':\n case '.js':\n case '.mjs': return 'script'\n default: return 'script'\n }\n}\n\n/**\n * Validate that a replacement expression uses the correct template syntax\n * for the target file's framework. Returns a warning string or null.\n */\nexport function validateFrameworkExpression(\n filePath: string,\n newExpression: string,\n context: 'tag_text' | 'other',\n): string | null {\n if (context !== 'tag_text') return null\n\n const framework = detectFileFramework(filePath)\n\n switch (framework) {\n case 'vue':\n if (!newExpression.includes('{{')) {\n return `Vue file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{{\" — expected Vue template syntax like {{ $t('key') }}`\n }\n break\n case 'jsx':\n if (!newExpression.includes('{')) {\n return `JSX file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{\" — expected JSX syntax like {t('key')}`\n }\n break\n case 'svelte':\n if (!newExpression.includes('{')) {\n return `Svelte file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{\" — expected Svelte syntax like {$t('key')}`\n }\n break\n case 'astro':\n if (!newExpression.includes('{')) {\n return `Astro file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{\" — expected Astro syntax like {t('key')}`\n }\n break\n case 'script':\n // Script files don't have template interpolation — warn if attempting tag text replacement\n return `Script file \"${filePath}\": tag text replacement not applicable for .ts/.js files`\n }\n\n return null\n}\n\n// ─── Scope Validation (Guardrail #1) ───\n\n/**\n * Validate that a patch file path is safe and within allowed scope.\n * Returns an error message or null if valid.\n */\nexport function validatePatchPath(filePath: string): string | null {\n const normalizedPath = filePath.replace(/\\\\/g, '/')\n\n // Reject path traversal\n if (normalizedPath.includes('..')) {\n return `Path traversal detected: \"${filePath}\"`\n }\n\n // Reject absolute paths\n if (normalizedPath.startsWith('/')) {\n return `Absolute path not allowed: \"${filePath}\"`\n }\n\n // Reject forbidden directories\n const segments = normalizedPath.split('/')\n for (const seg of segments) {\n if (FORBIDDEN_PATH_SEGMENTS.has(seg)) {\n return `Patching files inside \"${seg}/\" is not allowed: \"${filePath}\"`\n }\n }\n\n // Reject non-scannable extensions\n const ext = extname(filePath).toLowerCase()\n if (!PATCHABLE_EXTENSIONS.has(ext)) {\n return `File extension \"${ext}\" is not patchable. Allowed: ${[...PATCHABLE_EXTENSIONS].join(', ')}. Path: \"${filePath}\"`\n }\n\n return null\n}\n\n// ─── Syntax Check (Guardrail #5) ───\n\n/**\n * Perform a basic syntax check on a patched file.\n * Returns an error message or null if syntax appears valid.\n */\nexport function checkSyntax(filePath: string, content: string): string | null {\n const ext = extname(filePath).toLowerCase()\n\n switch (ext) {\n case '.ts':\n case '.tsx':\n case '.js':\n case '.jsx':\n case '.mjs':\n return checkJsSyntax(content)\n case '.vue':\n return checkVueSyntax(content)\n case '.svelte':\n case '.astro':\n return checkTagBalance(content)\n default:\n return null\n }\n}\n\n/**\n * Basic JS/TS syntax check: bracket/paren/brace balance + string literal closure.\n * This is intentionally conservative — it catches obvious breakage without\n * requiring a full parser.\n */\nfunction checkJsSyntax(content: string): string | null {\n const stack: string[] = []\n const pairs: Record<string, string> = { ')': '(', ']': '[', '}': '{' }\n let inString: string | null = null\n let escaped = false\n let inLineComment = false\n let inBlockComment = false\n\n for (let i = 0; i < content.length; i++) {\n const ch = content[i]!\n const next = content[i + 1]\n\n // Handle escape sequences inside strings\n if (escaped) {\n escaped = false\n continue\n }\n\n if (ch === '\\\\' && inString !== null) {\n escaped = true\n continue\n }\n\n // Line comment\n if (!inString && !inBlockComment && ch === '/' && next === '/') {\n inLineComment = true\n continue\n }\n if (inLineComment) {\n if (ch === '\\n') inLineComment = false\n continue\n }\n\n // Block comment\n if (!inString && !inBlockComment && ch === '/' && next === '*') {\n inBlockComment = true\n i++ // skip *\n continue\n }\n if (inBlockComment) {\n if (ch === '*' && next === '/') {\n inBlockComment = false\n i++ // skip /\n }\n continue\n }\n\n // String handling\n if (inString !== null) {\n if (ch === inString) {\n // Template literal allows multi-line, others don't\n inString = null\n } else if (inString !== '`' && ch === '\\n') {\n return `Unterminated string literal near offset ${i}`\n }\n continue\n }\n\n if (ch === '\"' || ch === \"'\" || ch === '`') {\n inString = ch\n continue\n }\n\n // Bracket matching\n if (ch === '(' || ch === '[' || ch === '{') {\n stack.push(ch)\n } else if (ch === ')' || ch === ']' || ch === '}') {\n const expected = pairs[ch]!\n if (stack.length === 0) {\n return `Unmatched closing \"${ch}\" near offset ${i}`\n }\n const top = stack.pop()!\n if (top !== expected) {\n return `Mismatched bracket: expected closing for \"${top}\" but found \"${ch}\" near offset ${i}`\n }\n }\n }\n\n if (inString !== null) {\n return `Unterminated string literal (opened with ${inString})`\n }\n\n if (stack.length > 0) {\n return `Unclosed bracket \"${stack[stack.length - 1]}\" — ${stack.length} unclosed bracket(s)`\n }\n\n return null\n}\n\n/**\n * Vue SFC syntax check: ensure <template>, <script>, <style> tags are balanced.\n */\nfunction checkVueSyntax(content: string): string | null {\n const tagBalance = checkTagBalance(content)\n if (tagBalance) return tagBalance\n\n // Vue-specific: check that SFC root tags are present and balanced\n for (const tag of ['template', 'script']) {\n const openRe = new RegExp(`<${tag}[\\\\s>]`, 'g')\n const closeRe = new RegExp(`</${tag}>`, 'g')\n const opens = content.match(openRe)?.length ?? 0\n const closes = content.match(closeRe)?.length ?? 0\n if (opens !== closes) {\n return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`\n }\n }\n\n return null\n}\n\n/**\n * Basic tag balance check for HTML-like files.\n * Checks that self-closing tags are handled and major structural tags are balanced.\n */\nfunction checkTagBalance(content: string): string | null {\n // Check for common structural tags balance\n const structuralTags = ['div', 'section', 'main', 'header', 'footer', 'nav', 'article', 'aside', 'ul', 'ol', 'table']\n\n for (const tag of structuralTags) {\n const openRe = new RegExp(`<${tag}[\\\\s>]`, 'g')\n const closeRe = new RegExp(`</${tag}>`, 'g')\n const opens = content.match(openRe)?.length ?? 0\n const closes = content.match(closeRe)?.length ?? 0\n if (opens !== closes) {\n return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`\n }\n }\n\n return null\n}\n\n// ─── Extract Mode ───\n\nexport async function applyExtract(\n projectRoot: string,\n input: ExtractionInput,\n): Promise<ExtractionResult> {\n const config = await readConfig(projectRoot)\n if (!config) throw new Error('Project not initialized. Run contentrain_init first.')\n\n const { extractions, dry_run } = input\n\n // Analyze what will happen\n const existingModels = await listModels(projectRoot)\n const existingIds = new Set(existingModels.map(m => m.id))\n\n const modelsToCreate: string[] = []\n const modelsToUpdate: string[] = []\n const contentFiles: string[] = []\n let totalEntries = 0\n\n const validationErrors: string[] = []\n\n for (const ext of extractions) {\n // Validate model definition with same rules as model_save\n const modelErrors = validateModelDefinition({\n id: ext.model,\n kind: ext.kind,\n fields: ext.fields as Record<string, unknown> | undefined,\n })\n if (modelErrors.length > 0) {\n validationErrors.push(...modelErrors.map(e => `[${ext.model}] ${e}`))\n }\n\n for (const entry of ext.entries) {\n if (ext.kind === 'dictionary') {\n if (entry.data['id'] !== undefined || entry.data['slug'] !== undefined) {\n validationErrors.push(`[${ext.model}] Dictionary entries should not have id or slug`)\n }\n for (const [key, val] of Object.entries(entry.data)) {\n if (typeof val !== 'string') {\n validationErrors.push(`[${ext.model}] Dictionary entry value for key \"${key}\" must be a string, got ${typeof val}`)\n }\n }\n } else if (ext.kind === 'document') {\n if (!entry.slug && !entry.data['slug']) {\n validationErrors.push(`[${ext.model}] Document entries must have a slug`)\n }\n } else if (ext.kind === 'collection') {\n if (entry.slug !== undefined || entry.data['slug'] !== undefined) {\n validationErrors.push(`[${ext.model}] Collection entries should not have slug`)\n }\n } else if (ext.kind === 'singleton') {\n if (entry.data['id'] !== undefined || entry.slug !== undefined || entry.data['slug'] !== undefined) {\n validationErrors.push(`[${ext.model}] Singleton entries should not have id or slug`)\n }\n }\n }\n\n if (existingIds.has(ext.model)) {\n modelsToUpdate.push(ext.model)\n } else {\n modelsToCreate.push(ext.model)\n }\n totalEntries += ext.entries.length\n\n // Guardrail #3: Preview-Execute Parity — use real model metadata if it exists\n let previewModel: ModelDefinition\n if (existingIds.has(ext.model)) {\n const real = await readModel(projectRoot, ext.model)\n if (real) {\n previewModel = real\n } else {\n previewModel = { id: ext.model, kind: ext.kind, domain: ext.domain, i18n: ext.i18n ?? true } as ModelDefinition\n }\n } else {\n previewModel = { id: ext.model, kind: ext.kind, domain: ext.domain, i18n: ext.i18n ?? true } as ModelDefinition\n }\n\n const cDir = resolveContentDir(projectRoot, previewModel)\n for (const entry of ext.entries) {\n const locale = entry.locale ?? config.locales.default\n if (ext.kind === 'document' && entry.slug) {\n contentFiles.push(resolveMdFilePath(cDir, previewModel, locale, entry.slug))\n } else {\n contentFiles.push(resolveJsonFilePath(cDir, previewModel, locale))\n }\n }\n }\n\n const preview: ExtractionPreview = {\n models_to_create: modelsToCreate,\n models_to_update: modelsToUpdate,\n total_entries: totalEntries,\n content_files: [...new Set(contentFiles)],\n }\n\n // Dry run — return preview only (include validation errors if any)\n if (dry_run !== false) {\n return {\n dry_run: true,\n preview,\n ...(validationErrors.length > 0 ? { validation_errors: validationErrors } : {}),\n next_steps: [\n ...(validationErrors.length > 0\n ? [`WARNING: ${validationErrors.length} validation error(s) found — fix before executing`]\n : []),\n 'Review the preview above',\n 'Call contentrain_apply with mode:extract and dry_run:false to execute',\n ],\n }\n }\n\n // Block execute if validation errors exist\n if (validationErrors.length > 0) {\n return {\n dry_run: false,\n error: 'Model validation failed — cannot execute extract with invalid model definitions',\n validation_errors: validationErrors,\n next_steps: ['Fix the validation errors and retry'],\n }\n }\n\n // Branch health gate\n const health = await checkBranchHealth(projectRoot)\n if (health.blocked) {\n return {\n error: health.message,\n action: 'blocked' as const,\n hint: 'Merge or delete old contentrain/* branches before executing normalize.',\n } as unknown as ExtractionResult\n }\n\n // Execute — git transaction (always review mode for normalize)\n const branchName = buildBranchName('normalize', 'extract')\n const tx = await createTransaction(projectRoot, branchName, { workflowOverride: 'review' })\n const sourceMap: Array<{ model: string; locale: string; value: string; file: string; line: number }> = []\n const modelsCreated: string[] = []\n const modelsUpdated: string[] = []\n let entriesWritten = 0\n\n try {\n await tx.write(async (wt) => {\n for (const ext of extractions) {\n // Create or merge model\n const existing = await readModel(wt, ext.model)\n if (existing) {\n // Merge fields: add new, keep existing\n if (ext.fields) {\n const merged = { ...existing.fields, ...ext.fields }\n // Only overwrite if new fields were actually added\n const newFieldNames = Object.keys(ext.fields).filter(k => !(k in (existing.fields ?? {})))\n if (newFieldNames.length > 0) {\n existing.fields = merged\n await writeModel(wt, existing)\n modelsUpdated.push(ext.model)\n }\n }\n } else {\n // Create new model\n const newModel: ModelDefinition = {\n id: ext.model,\n name: ext.model.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),\n kind: ext.kind,\n domain: ext.domain,\n i18n: ext.i18n ?? true,\n fields: ext.fields,\n }\n await writeModel(wt, newModel)\n modelsCreated.push(ext.model)\n }\n\n // Write content entries\n const model = (await readModel(wt, ext.model))!\n const entries: ContentEntry[] = ext.entries.map(e => ({\n locale: e.locale,\n slug: e.slug,\n data: e.data,\n }))\n\n const wtConfig = await readConfig(wt) ?? config\n await writeContent(wt, model, entries, wtConfig)\n entriesWritten += entries.length\n\n // Track source map\n for (const entry of ext.entries) {\n // For dictionary entries with per-key source tracking\n if (ext.kind === 'dictionary' && entry.sources) {\n for (const s of entry.sources) {\n sourceMap.push({\n model: ext.model,\n locale: entry.locale ?? config.locales.default,\n value: s.value,\n file: s.file,\n line: s.line,\n })\n }\n } else if (entry.source) {\n sourceMap.push({\n model: ext.model,\n locale: entry.locale ?? config.locales.default,\n value: entry.source.value,\n file: entry.source.file,\n line: entry.source.line,\n })\n }\n }\n }\n\n // Write source map for reuse scope enforcement\n if (sourceMap.length > 0) {\n const sourcesByModel: Record<string, { source_files: string[]; entry_count: number }> = {}\n for (const s of sourceMap) {\n if (!sourcesByModel[s.model]) {\n sourcesByModel[s.model] = { source_files: [], entry_count: 0 }\n }\n const modelEntry = sourcesByModel[s.model]!\n if (!modelEntry.source_files.includes(s.file)) {\n modelEntry.source_files.push(s.file)\n }\n modelEntry.entry_count++\n }\n const sourcesJson = JSON.stringify({\n version: 1,\n created_at: new Date().toISOString(),\n models: sourcesByModel,\n }, null, 2) + '\\n'\n // Write to worktree only — merge brings it to main\n // Phase 2 (reuse) must wait for extract branch to be merged first\n await writeText(join(wt, '.contentrain', 'normalize-sources.json'), sourcesJson)\n }\n\n // Update context\n await writeContext(wt, {\n tool: 'contentrain_apply',\n model: extractions.map(e => e.model).join(','),\n locale: config.locales.default,\n entries: extractions.flatMap(e => e.entries.map(en => en.slug ?? 'entry')),\n })\n })\n\n const commitMsg = `[contentrain] normalize: extract ${entriesWritten} entries to ${extractions.length} models`\n await tx.commit(commitMsg)\n\n const gitResult = { branch: branchName, action: 'pending-review', commit: '' }\n try {\n const completed = await tx.complete()\n gitResult.action = completed.action\n gitResult.commit = completed.commit\n } catch {\n gitResult.action = 'pending-review'\n } finally {\n await tx.cleanup()\n }\n\n return {\n dry_run: false,\n results: {\n models_created: modelsCreated,\n models_updated: modelsUpdated,\n entries_written: entriesWritten,\n source_map: sourceMap,\n },\n git: gitResult,\n context_updated: true,\n next_steps: [\n 'Run contentrain_validate to check the extracted content',\n 'Run contentrain_submit to push the branch for review',\n 'For browser-based review: ensure `contentrain serve` is running, direct user to http://localhost:3333/normalize',\n 'For terminal workflow: use contentrain_merge to merge the branch locally',\n 'After merge, run `npx contentrain generate` to update SDK client',\n 'After review, proceed with mode:reuse to patch source files',\n ],\n }\n } catch (error) {\n await tx.cleanup()\n throw error\n }\n}\n\n// ─── Reuse Mode ───\n\nexport async function applyReuse(\n projectRoot: string,\n input: ReuseInput,\n): Promise<ReuseResult> {\n const config = await readConfig(projectRoot)\n if (!config) throw new Error('Project not initialized. Run contentrain_init first.')\n\n const { scope, patches, dry_run } = input\n\n // Validate scope\n if (!scope.model && !scope.domain) {\n throw new Error('Scope required: provide model or domain. Whole-project patching is not allowed.')\n }\n\n // Validate patch count\n if (patches.length > MAX_PATCHES) {\n throw new Error(`Too many patches (${patches.length}). Maximum ${MAX_PATCHES} per operation. Split into multiple calls.`)\n }\n\n // Check content exists for scope (soft warning)\n if (scope.model) {\n const model = await readModel(projectRoot, scope.model)\n if (!model) {\n throw new Error(`Model \"${scope.model}\" not found. Run extract phase first.`)\n }\n }\n\n const scopeWarnings: string[] = []\n\n // Guardrail #1: Scope Real Enforcement\n // Step 1: Path safety — every patch must target a valid, patchable source file\n for (const patch of patches) {\n const pathError = validatePatchPath(patch.file)\n if (pathError) {\n throw new Error(`Invalid patch path: ${pathError}`)\n }\n }\n\n // Step 2: Semantic scope — verify scope model/domain exists and cross-check patch files\n if (scope.model || scope.domain) {\n const models = await listModels(projectRoot)\n const scopeModels = scope.model\n ? models.filter(m => m.id === scope.model)\n : scope.domain\n ? models.filter(m => m.domain === scope.domain)\n : models\n\n if (scopeModels.length === 0) {\n throw new Error(`No models found for scope ${scope.model ? `model=\"${scope.model}\"` : `domain=\"${scope.domain}\"`}`)\n }\n\n // Step 3: Verify patch files are source files (not content/config/meta files)\n // and belong to detectable source directories (not random locations)\n const { autoDetectSourceDirs } = await import('./scan-config.js')\n const sourceDirs = await autoDetectSourceDirs(projectRoot)\n\n // Build allowed file prefixes from source dirs\n const allowedPrefixes = sourceDirs.map(d => d === '.' ? '' : d + '/')\n\n for (const patch of patches) {\n const normalizedPath = patch.file.replace(/\\\\/g, '/')\n\n // Reject patches targeting .contentrain/ directory (content files should never be patched by reuse)\n if (normalizedPath.startsWith('.contentrain/') || normalizedPath.includes('/.contentrain/')) {\n throw new Error(`Cannot patch content/config files directly: \"${patch.file}\". Reuse patches source files only.`)\n }\n\n // If source dirs were detected (not just \".\"), verify patch files are within them\n if (sourceDirs.length > 0 && sourceDirs[0] !== '.') {\n const inSourceDir = allowedPrefixes.some(prefix =>\n prefix === '' || normalizedPath.startsWith(prefix),\n )\n if (!inSourceDir) {\n throw new Error(\n `Patch file \"${patch.file}\" is outside detected source directories (${sourceDirs.join(', ')}). ` +\n `Reuse patches must target source files within the project's source tree.`,\n )\n }\n }\n }\n\n // Step 4: Semantic source→model cross-check via normalize-sources.json\n // Source map is written by extract into the review branch worktree.\n // It only exists on base after extract branch is merged.\n // If missing: dry_run proceeds with warning, execute is blocked.\n if (scope.model || scope.domain) {\n const sourcesPath = join(projectRoot, '.contentrain', 'normalize-sources.json')\n const sourcesRaw = await readText(sourcesPath)\n\n if (!sourcesRaw) {\n // Source map not found — check if scoped model is a dictionary\n // Dictionaries store all keys in one entry, so per-file source tracking\n // is not available. Allow reuse with a warning instead of blocking.\n const scopedModel = scopeModels.find(m => m.id === scope.model)\n const isDictionary = scopedModel?.kind === 'dictionary'\n\n if (isDictionary) {\n scopeWarnings.push(\n 'normalize-sources.json not found. Dictionary models do not generate per-file source maps. ' +\n 'Scope enforcement is based on source-tree locality only.',\n )\n } else if (dry_run !== false) {\n scopeWarnings.push(\n 'normalize-sources.json not found. Semantic scope enforcement is unavailable. ' +\n 'Merge the extract branch first, then reuse will have full scope protection.',\n )\n } else {\n throw new Error(\n 'Cannot execute reuse: normalize-sources.json not found on base branch. ' +\n 'The extract branch must be merged before reuse can execute. ' +\n 'This ensures semantic scope enforcement protects against out-of-scope patching.',\n )\n }\n } else {\n const sourcesData = JSON.parse(sourcesRaw) as {\n models?: Record<string, { source_files?: string[] }>\n }\n\n // Collect all allowed source files for the scope\n let allowedSourceFiles: string[] = []\n let scopeLabel = ''\n\n if (scope.model) {\n const modelSources = sourcesData.models?.[scope.model]?.source_files\n if (modelSources) allowedSourceFiles = modelSources\n scopeLabel = `model \"${scope.model}\"`\n } else if (scope.domain) {\n const domainModelIds = scopeModels.map(m => m.id)\n for (const modelId of domainModelIds) {\n const modelSources = sourcesData.models?.[modelId]?.source_files\n if (modelSources) allowedSourceFiles.push(...modelSources)\n }\n allowedSourceFiles = [...new Set(allowedSourceFiles)]\n scopeLabel = `domain \"${scope.domain}\"`\n }\n\n if (allowedSourceFiles.length > 0) {\n const outOfScopePatches: string[] = []\n for (const patch of patches) {\n const normalizedPath = patch.file.replace(/\\\\/g, '/')\n if (!allowedSourceFiles.includes(normalizedPath)) {\n outOfScopePatches.push(patch.file)\n }\n }\n if (outOfScopePatches.length > 0) {\n throw new Error(\n `Scope enforcement: ${outOfScopePatches.length} patch file(s) are not associated with ${scopeLabel}. ` +\n `Out-of-scope files: ${outOfScopePatches.join(', ')}. ` +\n `Known source files: ${allowedSourceFiles.join(', ')}.`,\n )\n }\n }\n }\n }\n }\n\n // Group patches by file\n const patchesByFile = new Map<string, PatchEntry[]>()\n for (const patch of patches) {\n if (!patchesByFile.has(patch.file)) {\n patchesByFile.set(patch.file, [])\n }\n patchesByFile.get(patch.file)!.push(patch)\n }\n\n const filesToModify = [...patchesByFile.keys()]\n const importsToAdd = patches.filter(p => p.import_statement).length\n\n // Dry run — return preview only\n if (dry_run !== false) {\n return {\n dry_run: true,\n preview: {\n files_to_modify: filesToModify,\n patches_count: patches.length,\n imports_to_add: importsToAdd,\n },\n ...(scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {}),\n next_steps: [\n ...(scopeWarnings.length > 0 ? [`WARNING: ${scopeWarnings.length} patch file(s) not in extract source map — verify intent`] : []),\n 'Review the files and patches above',\n 'Call contentrain_apply with mode:reuse and dry_run:false to execute',\n ],\n }\n }\n\n // Branch health gate\n const reuseHealth = await checkBranchHealth(projectRoot)\n if (reuseHealth.blocked) {\n return {\n dry_run: false,\n error: `Branch blocked: ${reuseHealth.message}`,\n next_steps: ['Merge or delete old contentrain/* branches before executing reuse.'],\n }\n }\n\n // Execute — git transaction\n const scopeTarget = scope.model ?? scope.domain!\n const branchName = buildBranchName('normalize/reuse', scopeTarget)\n const tx = await createTransaction(projectRoot, branchName, { workflowOverride: 'review' })\n\n const filesModified: string[] = []\n let patchesApplied = 0\n let importsAdded = 0\n const patchesSkipped: Array<{ file: string; line: number; reason: string }> = []\n const frameworkWarnings: Array<{ file: string; warning: string }> = []\n const syntaxErrors: SyntaxError[] = []\n\n try {\n await tx.write(async (wt) => {\n for (const [relFile, filePatches] of patchesByFile) {\n const absPath = join(wt, relFile)\n\n if (!(await pathExists(absPath))) {\n for (const p of filePatches) {\n patchesSkipped.push({ file: relFile, line: p.line, reason: 'file not found' })\n }\n continue\n }\n\n const content = await readText(absPath)\n if (content === null) {\n for (const p of filePatches) {\n patchesSkipped.push({ file: relFile, line: p.line, reason: 'file unreadable' })\n }\n continue\n }\n\n // Sort patches by line DESC (bottom-up to avoid line shifts)\n const sorted = [...filePatches].toSorted((a, b) => b.line - a.line)\n\n const lines = content.split('\\n')\n let fileModified = false\n\n for (const patch of sorted) {\n // Determine replacement context before applying\n const targetLine = lines[patch.line - 1] ?? ''\n const isTagTextContext = targetLine.includes(`>${patch.old_value}<`)\n const replacementContext = isTagTextContext ? 'tag_text' as const : 'other' as const\n\n // Guardrail #2: Framework-aware validation — only warn for tag text replacements\n if (isTagTextContext) {\n const fwWarning = validateFrameworkExpression(relFile, patch.new_expression, replacementContext)\n if (fwWarning) {\n frameworkWarnings.push({ file: relFile, warning: fwWarning })\n }\n }\n\n const applied = applyPatchToLines(lines, patch)\n if (applied) {\n patchesApplied++\n fileModified = true\n } else {\n patchesSkipped.push({ file: relFile, line: patch.line, reason: 'old_value not found at or near specified line' })\n }\n }\n\n // Add imports (deduplicate)\n const importStatements = new Set(\n filePatches\n .filter(p => p.import_statement)\n .map(p => p.import_statement!),\n )\n\n if (importStatements.size > 0) {\n const added = addImportsToLines(lines, importStatements)\n importsAdded += added\n if (added > 0) fileModified = true\n }\n\n if (fileModified) {\n const newContent = lines.join('\\n')\n await writeText(absPath, newContent)\n filesModified.push(relFile)\n\n // Guardrail #5: Syntax check after patching\n const syntaxError = checkSyntax(relFile, newContent)\n if (syntaxError) {\n syntaxErrors.push({ file: relFile, error: syntaxError })\n }\n }\n }\n\n // Update context\n await writeContext(wt, {\n tool: 'contentrain_apply',\n model: scopeTarget,\n locale: config.locales.default,\n })\n })\n\n if (filesModified.length === 0) {\n await tx.cleanup()\n return {\n dry_run: false,\n results: {\n files_modified: [],\n patches_applied: 0,\n patches_skipped: patchesSkipped,\n imports_added: 0,\n framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : undefined,\n },\n next_steps: ['No files were modified. Check patch definitions and try again.'],\n }\n }\n\n const commitMsg = `[contentrain] normalize: reuse ${scopeTarget} — patch ${filesModified.length} files (${patchesApplied} replacements)`\n await tx.commit(commitMsg)\n\n const gitResult = { branch: branchName, action: 'pending-review', commit: '' }\n try {\n const completed = await tx.complete()\n gitResult.action = completed.action\n gitResult.commit = completed.commit\n } catch {\n gitResult.action = 'pending-review'\n } finally {\n await tx.cleanup()\n }\n\n return {\n dry_run: false,\n results: {\n files_modified: filesModified,\n patches_applied: patchesApplied,\n patches_skipped: patchesSkipped,\n imports_added: importsAdded,\n framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : undefined,\n syntax_errors: syntaxErrors.length > 0 ? syntaxErrors : undefined,\n },\n ...(scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {}),\n git: gitResult,\n next_steps: [\n 'Run contentrain_validate to verify the patched files',\n patchesSkipped.length > 0 ? `${patchesSkipped.length} patches were skipped — review and retry if needed` : '',\n syntaxErrors.length > 0 ? `WARNING: ${syntaxErrors.length} file(s) may have syntax errors after patching — review manually` : '',\n scopeWarnings.length > 0 ? `NOTE: ${scopeWarnings.length} patch file(s) not in extract source map` : '',\n 'Run contentrain_submit to push the branch for review',\n 'For review: direct user to http://localhost:3333/branches or use contentrain_merge',\n 'After all reuse phases complete, run `npx contentrain generate` to update SDK types',\n ].filter(Boolean),\n }\n } catch (error) {\n await tx.cleanup()\n throw error\n }\n}\n\n// ─── Patch Helpers ───\n\n/**\n * Apply a single patch to a lines array. Mutates lines in place.\n * Uses line hint for proximity matching — searches ±10 lines from hint.\n */\nfunction applyPatchToLines(lines: string[], patch: PatchEntry): boolean {\n const { line, old_value, new_expression } = patch\n const lineIdx = line - 1 // 0-based\n\n // Search range: ±10 lines from hint\n const searchStart = Math.max(0, lineIdx - 10)\n const searchEnd = Math.min(lines.length, lineIdx + 11)\n\n // First pass: exact line match\n if (lineIdx >= 0 && lineIdx < lines.length) {\n const replaced = replaceInLine(lines[lineIdx]!, old_value, new_expression)\n if (replaced !== null) {\n lines[lineIdx] = replaced\n return true\n }\n }\n\n // Second pass: proximity search\n for (let i = searchStart; i < searchEnd; i++) {\n if (i === lineIdx) continue // already tried\n const replaced = replaceInLine(lines[i]!, old_value, new_expression)\n if (replaced !== null) {\n lines[i] = replaced\n return true\n }\n }\n\n return false\n}\n\n/**\n * Replace old_value with new_expression in a single line.\n * Matches the string literal (quoted or unquoted tag text).\n * Returns the modified line, or null if not found.\n *\n * Guardrail #4: Safer patch matching — word boundary awareness and\n * ambiguity rejection for plain text fallback.\n */\nexport function replaceInLine(line: string, oldValue: string, newExpression: string): string | null {\n // Try exact match of the value in quoted strings\n // Match: \"old_value\", 'old_value', `old_value`\n for (const quote of ['\"', \"'\", '`']) {\n const quoted = `${quote}${oldValue}${quote}`\n if (line.includes(quoted)) {\n return line.replace(quoted, newExpression)\n }\n }\n\n // Try unquoted tag text match: >old_value<\n // The agent provides the complete new_expression with correct framework syntax:\n // JSX: {t('key')}\n // Vue: {{ $t('key') }}\n // Svelte: {$t('key')}\n // So we insert the expression as-is between > and <, without wrapping in braces.\n if (line.includes(`>${oldValue}<`)) {\n return line.replace(`>${oldValue}<`, `>${newExpression}<`)\n }\n\n // Guardrail #4: Safer plain text fallback\n // Only match if old_value appears at a word boundary and is unambiguous\n if (line.includes(oldValue)) {\n // Count occurrences — if multiple, it's ambiguous\n const occurrences = countOccurrences(line, oldValue)\n if (occurrences > 1) {\n return null // ambiguous — let proximity search handle it\n }\n\n // Word boundary check: don't replace \"Submit\" inside \"SubmitButton\"\n // Reject if the old_value is a substring of a larger word — check if\n // adjacent characters are word characters that extend the token.\n const idx = line.indexOf(oldValue)\n const charBefore = idx > 0 ? line[idx - 1]! : ''\n const charAfter = idx + oldValue.length < line.length ? line[idx + oldValue.length]! : ''\n\n const oldStartsWithWord = oldValue.length > 0 && isWordChar(oldValue[0]!)\n const oldEndsWithWord = oldValue.length > 0 && isWordChar(oldValue[oldValue.length - 1]!)\n\n // If old_value starts with a word char and char before is also word char, it's a partial match\n if (oldStartsWithWord && isWordChar(charBefore)) {\n return null\n }\n // If old_value ends with a word char and char after is also word char, it's a partial match\n if (oldEndsWithWord && isWordChar(charAfter)) {\n return null\n }\n\n return line.replace(oldValue, newExpression)\n }\n\n return null\n}\n\n/** Count non-overlapping occurrences of a substring */\nfunction countOccurrences(str: string, sub: string): number {\n let count = 0\n let pos = 0\n while (pos <= str.length - sub.length) {\n const idx = str.indexOf(sub, pos)\n if (idx === -1) break\n count++\n pos = idx + sub.length\n }\n return count\n}\n\n/** Check if a character is a word character (letter, digit, underscore) */\nfunction isWordChar(ch: string): boolean {\n return /\\w/.test(ch)\n}\n\n/**\n * Add import statements to the top of a file (after existing imports).\n * Deduplicates — won't add if the import already exists.\n * Returns number of imports actually added.\n */\nfunction addImportsToLines(lines: string[], imports: Set<string>): number {\n let added = 0\n const existingContent = lines.join('\\n')\n\n // Find the last import line position (handles multi-line imports)\n let lastImportIdx = -1\n let inMultiLineImport = false\n for (let i = 0; i < lines.length; i++) {\n const trimmed = lines[i]!.trim()\n if (inMultiLineImport) {\n lastImportIdx = i\n if (trimmed.includes('}')) inMultiLineImport = false\n continue\n }\n if (trimmed.startsWith('import ') || trimmed.startsWith('import{')) {\n lastImportIdx = i\n // Check if this is a multi-line import (has { but no closing })\n if (trimmed.includes('{') && !trimmed.includes('}')) {\n inMultiLineImport = true\n }\n continue\n }\n // Stop searching after a non-import, non-empty, non-comment line following imports\n if (lastImportIdx >= 0 && trimmed.length > 0 && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*')) {\n break\n }\n }\n\n let insertAt: number\n if (lastImportIdx >= 0) {\n insertAt = lastImportIdx + 1\n } else {\n // No existing imports found — insert after shebang and directive lines\n insertAt = 0\n for (let i = 0; i < lines.length; i++) {\n const trimmed = lines[i]!.trim()\n if (i === 0 && trimmed.startsWith('#!')) {\n insertAt = i + 1\n continue\n }\n if (trimmed === \"'use client'\" || trimmed === '\"use client\"'\n || trimmed === \"'use server'\" || trimmed === '\"use server\"'\n || trimmed === \"'use client';\" || trimmed === '\"use client\";'\n || trimmed === \"'use server';\" || trimmed === '\"use server\";') {\n insertAt = i + 1\n continue\n }\n if (insertAt > 0 && trimmed.length > 0) break\n if (insertAt === 0 && trimmed.length > 0) break\n }\n }\n const toInsert: string[] = []\n\n for (const imp of imports) {\n // Check if this import already exists (by checking the from clause)\n if (!existingContent.includes(imp)) {\n toInsert.push(imp)\n added++\n }\n }\n\n if (toInsert.length > 0) {\n lines.splice(insertAt, 0, ...toInsert)\n }\n\n return added\n}\n"],"mappings":"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"}
import { t as readConfig } from "./config-DlFcTxFG.mjs";
import { CONTENTRAIN_BRANCH } from "@contentrain/types";
import { simpleGit } from "simple-git";
//#region src/git/branch-lifecycle.ts
/**
* Lists all local contentrain/* branches, deletes those already merged
* into the base branch, and returns the count of remaining unmerged ones.
*/
async function cleanupMergedBranches(projectRoot) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
const baseBranch = config?.repository?.default_branch ?? process.env["CONTENTRAIN_BRANCH"] ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
const contentrainBranches = (await git.branchLocal()).all.filter((b) => b.startsWith("cr/")).filter((b) => b !== CONTENTRAIN_BRANCH);
if (contentrainBranches.length === 0) return {
deleted: 0,
remaining: 0,
deletedBranches: []
};
let mergedSet;
try {
mergedSet = await classifyMergedBranches(projectRoot, contentrainBranches, CONTENTRAIN_BRANCH);
} catch {
try {
mergedSet = await classifyMergedBranches(projectRoot, contentrainBranches, baseBranch);
} catch {
return {
deleted: 0,
remaining: contentrainBranches.length,
deletedBranches: []
};
}
}
const mergedContentrain = contentrainBranches.filter((b) => mergedSet.has(b));
const deletedBranches = [];
const retentionMs = (config?.branchRetention ?? 30) * 24 * 60 * 60 * 1e3;
const now = Date.now();
for (const branch of mergedContentrain) try {
const timestampRaw = (await git.raw([
"log",
"-1",
"--format=%ct",
branch
])).trim();
if (now - Number(timestampRaw) * 1e3 < retentionMs) continue;
await git.raw([
"branch",
"-D",
branch
]);
deletedBranches.push(branch);
} catch {}
const remaining = contentrainBranches.length - deletedBranches.length;
return {
deleted: deletedBranches.length,
remaining,
deletedBranches
};
}
/**
* Check branch health: count contentrain/* branches and return warning/blocked status.
* - 50+ branches: warning
* - 80+ branches: blocked
*/
async function checkBranchHealth(projectRoot) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
const baseBranch = config?.repository?.default_branch ?? process.env["CONTENTRAIN_BRANCH"] ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
const contentrainBranches = (await git.branchLocal()).all.filter((b) => b.startsWith("cr/")).filter((b) => b !== CONTENTRAIN_BRANCH);
const total = contentrainBranches.length;
const warnLimit = config?.branchWarnLimit ?? 50;
const blockLimit = config?.branchBlockLimit ?? 80;
let mergedCount = 0;
const classifyOpts = { fallbackThreshold: warnLimit };
try {
mergedCount = (await classifyMergedBranches(projectRoot, contentrainBranches, CONTENTRAIN_BRANCH, classifyOpts)).size;
} catch {
try {
mergedCount = (await classifyMergedBranches(projectRoot, contentrainBranches, baseBranch, classifyOpts)).size;
} catch {}
}
const unmerged = total - mergedCount;
const warning = unmerged >= warnLimit;
const blocked = unmerged >= blockLimit;
let message;
if (blocked) message = `BLOCKED: ${unmerged} active contentrain branches (limit: ${blockLimit}). Run cleanup or merge/delete old branches before creating new ones.`;
else if (warning) message = `WARNING: ${unmerged} active contentrain branches. Consider merging or deleting old branches (warning at ${warnLimit}, blocked at ${blockLimit}).`;
return {
total,
merged: mergedCount,
unmerged,
warning,
blocked,
message
};
}
/**
* Compute the diff between a feature branch and its base.
*
* Defaults `base` to `CONTENTRAIN_BRANCH` — the singleton content-
* tracking branch every feature branch forks from. Passing the repo's
* default branch (e.g. `main`) is almost always a bug: when
* `contentrain` is ahead of `main`, the diff picks up unrelated
* historical content changes that the feature branch did not produce.
*
* Used by `contentrain serve` (branch detail view), the `contentrain
* diff` CLI command, and any Studio-side driver that needs to preview
* a feature branch before approving it.
*/
async function branchDiff(projectRoot, opts) {
const git = simpleGit(projectRoot);
const base = opts.base ?? CONTENTRAIN_BRANCH;
const range = `${base}...${opts.branch}`;
const [stat, patch, summary] = await Promise.all([
git.diff([range, "--stat"]),
git.diff([range]),
git.diffSummary([range])
]);
return {
branch: opts.branch,
base,
stat,
patch,
filesChanged: summary.changed
};
}
/**
* Merged-verdict caches.
*
* - `pairVerdictCache` — keyed by `(tipSha, intoTipSha, cap)`: the
* relationship between two FIXED commits never changes, so entries are
* permanently valid.
* - `mergedTipCache` — keyed by `(tipSha, into NAME)`: once a tip is merged
* into a branch, it stays merged as that branch advances (merged-ness is
* monotonic), so positives survive `contentrain` moving forward. This is
* what keeps the per-write branch-health gate cheap in long-lived
* processes (MCP server, `contentrain serve`).
*/
const pairVerdictCache = /* @__PURE__ */ new Map();
const mergedTipCache = /* @__PURE__ */ new Map();
const MERGED_CACHE_LIMIT = 1e4;
const DEFAULT_MAX_CHERRY_COMMITS = 200;
/** Concurrent `git cherry` subprocesses during classification. */
const CHERRY_CONCURRENCY = 8;
async function isTipMerged(git, tip, intoTip, maxCherryCommits, intoName) {
const tipKey = intoName ? `${tip}→${intoName}` : void 0;
if (tipKey && mergedTipCache.has(tipKey)) return true;
const pairKey = `${tip}:${intoTip}:${maxCherryCommits}`;
const cached = pairVerdictCache.get(pairKey);
if (cached !== void 0) return cached;
let merged = false;
try {
const lines = (await git.raw([
"cherry",
intoTip,
tip
])).split("\n").map((line) => line.trim()).filter(Boolean);
if (lines.length === 0) merged = true;
else if (lines.length <= maxCherryCommits) merged = !lines.some((line) => line.startsWith("+"));
} catch {
merged = false;
}
if (pairVerdictCache.size >= MERGED_CACHE_LIMIT) pairVerdictCache.clear();
pairVerdictCache.set(pairKey, merged);
if (merged && tipKey) {
if (mergedTipCache.size >= MERGED_CACHE_LIMIT) mergedTipCache.clear();
mergedTipCache.set(tipKey, true);
}
return merged;
}
/**
* Robust merged check for a single ref: ancestry fast-path, then a bounded
* `git cherry` (patch-id) fallback that survives base-history rewrites.
* Returns false when either ref cannot be resolved.
*/
async function isRefMerged(git, ref, into, opts) {
let tip;
let intoTip;
try {
const lines = (await git.raw([
"rev-parse",
ref,
into
])).trim().split("\n");
tip = lines[0]?.trim();
intoTip = lines[1]?.trim();
} catch {
return false;
}
if (!tip || !intoTip) return false;
return isTipMerged(git, tip, intoTip, opts?.maxCherryCommits ?? DEFAULT_MAX_CHERRY_COMMITS, into);
}
/**
* Classify which of the given local branches are merged into `into`
* (default: the contentrain branch). One `git branch --merged` call covers
* the ancestry-merged majority; only the remainder pays the patch-id
* fallback (bounded concurrency, verdicts cached).
*
* `opts.fallbackThreshold` skips the patch-id fallback entirely when fewer
* than that many branches are ancestry-unmerged — the fallback can only
* LOWER the unmerged count, so callers that merely compare the count
* against a limit (the hot pre-write gate) pay nothing in the normal case.
*
* Throws when `into` does not resolve — callers use this to fall back to
* the base branch (mirrors the previous `branch --merged` semantics).
*/
async function classifyMergedBranches(projectRoot, branches, into = CONTENTRAIN_BRANCH, opts) {
const git = simpleGit(projectRoot);
if (branches.length === 0) {
await git.raw([
"branch",
"--merged",
into
]);
return /* @__PURE__ */ new Set();
}
const mergedRaw = await git.raw([
"branch",
"--merged",
into
]);
const ancestryMerged = new Set(mergedRaw.split("\n").map((b) => b.replace(/^\*?\s+/, "").trim()).filter(Boolean));
const merged = /* @__PURE__ */ new Set();
const rest = [];
for (const branch of branches) if (ancestryMerged.has(branch)) merged.add(branch);
else rest.push(branch);
if (rest.length === 0 || rest.length < (opts?.fallbackThreshold ?? 0)) return merged;
let tips;
try {
tips = (await git.raw([
"rev-parse",
into,
...rest
])).trim().split("\n").map((s) => s.trim());
} catch {
return merged;
}
const intoTip = tips[0];
if (!intoTip) return merged;
for (let i = 0; i < rest.length; i += CHERRY_CONCURRENCY) {
const chunk = rest.slice(i, i + CHERRY_CONCURRENCY);
const verdicts = await Promise.all(chunk.map((branch, j) => {
const tip = tips[i + j + 1];
return tip ? isTipMerged(simpleGit(projectRoot), tip, intoTip, DEFAULT_MAX_CHERRY_COMMITS, into) : Promise.resolve(false);
}));
for (const [j, verdict] of verdicts.entries()) if (verdict) merged.add(chunk[j]);
}
return merged;
}
const REMOTE_PUSH_TIMEOUT_MS = 1e4;
const REMOTE_LIST_TIMEOUT_MS = 5e3;
function contentrainRemoteName() {
return process.env["CONTENTRAIN_REMOTE"] ?? "origin";
}
/**
* simple-git instance hardened for network operations: `timeout.block` kills
* the child after N ms without output (hung SSH passphrase prompts), and
* `GIT_TERMINAL_PROMPT=0` refuses interactive HTTPS credential prompts.
*/
function networkGit(projectRoot, timeoutMs) {
return simpleGit({
baseDir: projectRoot,
timeout: { block: timeoutMs }
}).env({
...process.env,
GIT_TERMINAL_PROMPT: "0"
});
}
async function resolveRemote(git) {
try {
const remotes = await git.getRemotes();
const name = contentrainRemoteName();
return remotes.some((r) => r.name === name) ? name : null;
} catch {
return null;
}
}
/**
* Best-effort delete of a cr/* branch on the configured remote. Never
* throws: expected conditions land in `skipped`, real failures in
* `warning`. Gated by `config.remoteBranchCleanup` (default: on).
*
* Pass `opts.config` when the caller already read it (avoids a re-read);
* `null` means "no config" and applies the default gate.
*/
async function deleteRemoteBranch(projectRoot, branch, opts) {
if (!branch.startsWith("cr/") || branch === CONTENTRAIN_BRANCH) return {
deleted: false,
skipped: "protected"
};
if (!((opts?.config === void 0 ? await readConfig(projectRoot) : opts.config)?.remoteBranchCleanup ?? true)) return {
deleted: false,
skipped: "disabled"
};
const git = networkGit(projectRoot, opts?.timeoutMs ?? REMOTE_PUSH_TIMEOUT_MS);
const remote = await resolveRemote(git);
if (!remote) return {
deleted: false,
skipped: "no-remote"
};
try {
await git.push([
remote,
"--delete",
branch
]);
return { deleted: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/remote ref does not exist|couldn't find remote ref/i.test(message)) return {
deleted: false,
skipped: "not-found"
};
return {
deleted: false,
warning: `Could not delete "${branch}" on ${remote}: ${message}`
};
}
}
/**
* Authoritative list of cr/* branches on the configured remote via
* `ls-remote --heads` (no fetch, no stale remote-tracking refs). Returns
* null when no remote is configured. Never throws.
*/
async function listRemoteCrBranches(projectRoot, opts) {
const git = networkGit(projectRoot, opts?.timeoutMs ?? REMOTE_LIST_TIMEOUT_MS);
const remote = await resolveRemote(git);
if (!remote) return null;
try {
return {
remote,
branches: (await git.raw([
"ls-remote",
"--heads",
remote,
"refs/heads/cr/*"
])).split("\n").map((line) => line.trim()).filter(Boolean).map((line) => {
const [sha, ref] = line.split(" ");
return {
sha: sha?.trim() ?? "",
name: ref?.trim().replace(/^refs\/heads\//, "") ?? ""
};
}).filter((b) => b.name.startsWith("cr/") && b.name !== CONTENTRAIN_BRANCH)
};
} catch (error) {
return {
remote,
branches: [],
error: error instanceof Error ? error.message : String(error)
};
}
}
const PRUNE_PUSH_CHUNK = 50;
/**
* Delete already-merged cr/* branches on the remote in batches. Merged-state
* uses the same ancestry + patch-id classification as the local cleanup, so
* branches leaked before a base-history rewrite are still recognised.
* Ignores `branchRetention` — a merged remote copy only produces phantom
* reviews. Never throws; gated by `config.remoteBranchCleanup`.
*/
async function pruneMergedRemoteBranches(projectRoot, opts) {
if (!((opts?.config === void 0 ? await readConfig(projectRoot) : opts.config)?.remoteBranchCleanup ?? true)) return {
deleted: [],
kept: [],
errors: [],
skipped: "disabled"
};
const listed = await listRemoteCrBranches(projectRoot, { timeoutMs: opts?.timeoutMs });
if (!listed) return {
deleted: [],
kept: [],
errors: [],
skipped: "no-remote"
};
if (listed.error) return {
deleted: [],
kept: [],
errors: [listed.error],
skipped: "offline"
};
if (listed.branches.length === 0) return {
deleted: [],
kept: [],
errors: []
};
const git = networkGit(projectRoot, opts?.timeoutMs ?? REMOTE_PUSH_TIMEOUT_MS);
if ((await Promise.all(listed.branches.map((b) => git.raw([
"cat-file",
"-t",
b.sha
]).then(() => true).catch(() => false)))).some((present) => !present)) try {
await git.fetch(listed.remote, `+refs/heads/cr/*:refs/remotes/${listed.remote}/cr/*`);
} catch {}
const kept = [];
const candidates = [];
const verdicts = await Promise.all(listed.branches.map((b) => isRefMerged(git, b.sha, CONTENTRAIN_BRANCH)));
for (const [i, merged] of verdicts.entries()) {
const name = listed.branches[i].name;
if (merged) candidates.push(name);
else kept.push(name);
}
const limit = opts?.max ?? Number.POSITIVE_INFINITY;
const toDelete = candidates.slice(0, limit);
kept.push(...candidates.slice(toDelete.length));
if (opts?.dryRun) return {
deleted: toDelete,
kept,
errors: []
};
const deleted = [];
const errors = [];
for (let i = 0; i < toDelete.length; i += PRUNE_PUSH_CHUNK) {
const chunk = toDelete.slice(i, i + PRUNE_PUSH_CHUNK);
try {
await git.push([
listed.remote,
"--delete",
...chunk
]);
deleted.push(...chunk);
} catch {
for (const branch of chunk) try {
await git.push([
listed.remote,
"--delete",
branch
]);
deleted.push(branch);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/remote ref does not exist|couldn't find remote ref/i.test(message)) deleted.push(branch);
else errors.push(`${branch}: ${message}`);
}
}
}
return {
deleted,
kept,
errors
};
}
//#endregion
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 };
//# sourceMappingURL=branch-lifecycle-D7tXOE4f.mjs.map
{"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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"}
import { a as readJson, i as readDir, o as readText, r as pathExists, t as contentrainDir } from "./fs-DLbVB-Ek.mjs";
import { t as readConfig } from "./config-DlFcTxFG.mjs";
import { g as resolveLocaleStrategy, h as resolveJsonFilePath, m as resolveContentDir, o as listModels, s as readModel } from "./model-manager-BhLsUgaB.mjs";
import { n as checkBranchHealth, s as listRemoteCrBranches } from "./branch-lifecycle-D7tXOE4f.mjs";
import { i as autoDetectSourceDirs, o as discoverFiles } from "./scan-config-BGUflS8t.mjs";
import { join } from "node:path";
import { stat } from "node:fs/promises";
import { simpleGit } from "simple-git";
//#region src/core/doctor.ts
async function runDoctor(projectRoot, options = {}) {
const checks = [];
try {
const version = await simpleGit(projectRoot).version();
checks.push({
name: "Git",
pass: true,
detail: `v${version.major}.${version.minor}.${version.patch}`
});
} catch {
checks.push({
name: "Git",
pass: false,
detail: "Not installed or not in PATH",
severity: "error"
});
}
const hasGit = await pathExists(join(projectRoot, ".git"));
checks.push({
name: "Git repository",
pass: hasGit,
detail: hasGit ? projectRoot : "No .git directory found",
severity: hasGit ? void 0 : "error"
});
const nodeVersion = process.versions.node;
const [major] = nodeVersion.split(".").map(Number);
const nodePass = (major ?? 0) >= 22;
checks.push({
name: "Node.js",
pass: nodePass,
detail: `v${nodeVersion}${nodePass ? "" : " (requires ≥22)"}`,
severity: nodePass ? void 0 : "error"
});
const crDir = contentrainDir(projectRoot);
const hasCrDir = await pathExists(crDir);
const hasConfig = await pathExists(join(crDir, "config.json"));
const hasModels = await pathExists(join(crDir, "models"));
const hasContent = await pathExists(join(crDir, "content"));
const structurePass = hasCrDir && hasConfig && hasModels && hasContent;
checks.push({
name: ".contentrain/ structure",
pass: structurePass,
detail: !hasCrDir ? "Not initialized — run `contentrain init`" : [
hasConfig ? null : "missing config.json",
hasModels ? null : "missing models/",
hasContent ? null : "missing content/"
].filter(Boolean).join(", ") || "OK",
severity: structurePass ? void 0 : "error"
});
let config = null;
if (hasConfig) {
config = await readConfig(projectRoot);
checks.push({
name: "Config",
pass: config !== null,
detail: config ? `stack: ${config.stack}, locales: ${config.locales.supported.join(", ")}` : "Failed to parse config.json",
severity: config ? void 0 : "error"
});
}
if (hasCrDir) try {
const models = await listModels(projectRoot);
const allParseable = (await Promise.all(models.map((m) => readModel(projectRoot, m.id)))).every((r) => r !== null);
checks.push({
name: "Models",
pass: allParseable,
detail: `${models.length} model(s)${allParseable ? ", all valid" : ", some failed to parse"}`,
severity: allParseable ? void 0 : "error"
});
} catch {
checks.push({
name: "Models",
pass: false,
detail: "Failed to read models",
severity: "error"
});
}
if (hasCrDir) {
const orphans = await findOrphanContent(projectRoot);
checks.push({
name: "Orphan content",
pass: orphans.length === 0,
detail: orphans.length === 0 ? "None" : `Found: ${orphans.join(", ")}`,
severity: orphans.length === 0 ? void 0 : "warning"
});
}
if (hasGit) {
try {
const health = await checkBranchHealth(projectRoot);
checks.push({
name: "Pending branches",
pass: !health.blocked && !health.warning,
detail: health.message ?? (health.unmerged === 0 ? "None" : `${health.unmerged} active cr/* branch(es)`),
severity: health.blocked ? "error" : health.warning ? "warning" : void 0
});
} catch {
checks.push({
name: "Pending branches",
pass: true,
detail: "Could not check"
});
}
const remoteList = await listRemoteCrBranches(projectRoot, { timeoutMs: 5e3 });
if (remoteList) if (remoteList.error) checks.push({
name: "Remote branches",
pass: true,
detail: `Could not check ${remoteList.remote} (offline?)`,
severity: "info"
});
else {
const count = remoteList.branches.length;
const warn = count >= (config?.branchWarnLimit ?? 50);
checks.push({
name: "Remote branches",
pass: !warn,
detail: count === 0 ? `None on ${remoteList.remote}` : `${count} cr/* branch(es) on ${remoteList.remote}${warn ? " — run `contentrain prune` to remove merged leftovers" : ""}`,
severity: warn ? "warning" : void 0
});
}
}
const clientDir = join(crDir, "client");
const modelsDir = join(crDir, "models");
if (await pathExists(clientDir) && await pathExists(modelsDir)) try {
const [clientStat, modelsStat] = await Promise.all([stat(clientDir), stat(modelsDir)]);
const fresh = clientStat.mtimeMs >= modelsStat.mtimeMs;
checks.push({
name: "SDK client",
pass: fresh,
detail: fresh ? "Up to date" : "Stale — run `contentrain generate`",
severity: fresh ? void 0 : "warning"
});
} catch {
checks.push({
name: "SDK client",
pass: true,
detail: "Could not check"
});
}
let usage;
if (options.usage && hasCrDir && config) {
const [unusedKeys, duplicateValues, missingLocaleKeys] = await Promise.all([
analyzeUnusedKeys(projectRoot, config),
analyzeDuplicateValues(projectRoot, config),
analyzeMissingLocaleKeys(projectRoot, config)
]);
usage = {
unusedKeys,
duplicateValues,
missingLocaleKeys
};
checks.push({
name: "Unused content keys",
pass: unusedKeys.length === 0,
detail: unusedKeys.length === 0 ? "All keys referenced in source" : `${unusedKeys.length} key(s) not referenced in source code`,
severity: unusedKeys.length === 0 ? void 0 : "warning"
});
checks.push({
name: "Duplicate dictionary values",
pass: duplicateValues.length === 0,
detail: duplicateValues.length === 0 ? "No duplicate values" : `${duplicateValues.length} value(s) mapped to multiple keys`,
severity: duplicateValues.length === 0 ? void 0 : "warning"
});
checks.push({
name: "Locale key coverage",
pass: missingLocaleKeys.length === 0,
detail: missingLocaleKeys.length === 0 ? "All locales have matching keys" : `${missingLocaleKeys.length} key(s) missing in some locales`,
severity: missingLocaleKeys.length === 0 ? void 0 : "warning"
});
}
const passed = checks.filter((c) => c.pass).length;
const failed = checks.length - passed;
const warnings = checks.filter((c) => !c.pass && c.severity === "warning").length;
const report = {
checks,
summary: {
total: checks.length,
passed,
failed,
warnings
}
};
if (usage) report.usage = usage;
return report;
}
async function findOrphanContent(projectRoot) {
const crDir = contentrainDir(projectRoot);
const models = await listModels(projectRoot);
const orphans = [];
const knownContentDirs = /* @__PURE__ */ new Set();
for (const m of models) {
const full = await readModel(projectRoot, m.id);
const modelForPath = full ? {
...full,
content_path: full.content_path ?? m.content_path
} : {
id: m.id,
name: m.id,
kind: m.kind,
domain: m.domain,
i18n: m.i18n,
fields: {},
content_path: m.content_path
};
knownContentDirs.add(resolveContentDir(projectRoot, modelForPath));
}
const contentDir = join(crDir, "content");
if (await pathExists(contentDir)) {
const domains = await readDir(contentDir);
for (const domain of domains) {
const domainDir = join(contentDir, domain);
const entries = await readDir(domainDir);
for (const entry of entries) {
if (entry === ".gitkeep") continue;
const entryDir = join(domainDir, entry);
if (!knownContentDirs.has(entryDir)) orphans.push(`${domain}/${entry}`);
}
}
}
for (const dir of knownContentDirs) {
if (dir.startsWith(contentDir)) continue;
if (!await pathExists(dir)) {
orphans.push(`(missing custom path) ${dir}`);
continue;
}
await readDir(dir);
}
return orphans;
}
async function analyzeUnusedKeys(projectRoot, config) {
const files = await discoverFiles(projectRoot, { paths: await autoDetectSourceDirs(projectRoot) });
if (files.length === 0) return [];
const allSource = (await Promise.all(files.map(async (relPath) => {
return await readText(join(projectRoot, relPath)) ?? "";
}))).join("\n");
const models = await listModels(projectRoot);
const defaultLocale = config.locales.default;
const unused = [];
for (const m of models) {
const fullModel = await readModel(projectRoot, m.id);
if (!fullModel) continue;
const keys = await extractContentKeys(projectRoot, fullModel, defaultLocale);
for (const key of keys) if (!allSource.includes(key)) unused.push({
model: m.id,
kind: m.kind,
key,
locale: defaultLocale
});
}
return unused;
}
async function extractContentKeys(projectRoot, model, locale) {
const cDir = resolveContentDir(projectRoot, model);
if (!await pathExists(cDir)) return [];
switch (model.kind) {
case "dictionary": {
const data = await readJson(resolveJsonFilePath(cDir, model, locale));
return data ? Object.keys(data) : [];
}
case "collection": {
const data = await readJson(resolveJsonFilePath(cDir, model, locale));
return data ? Object.keys(data) : [];
}
case "document": {
const strategy = resolveLocaleStrategy(model);
const slugs = [];
if (!model.i18n) {
const files = await readDir(cDir);
for (const f of files) if (f.endsWith(".md")) slugs.push(f.replace(".md", ""));
} else if (strategy === "file") {
const dirs = await readDir(cDir);
for (const d of dirs) if (!d.startsWith(".")) slugs.push(d);
} else if (strategy === "suffix") {
const files = await readDir(cDir);
const suffix = `.${locale}.md`;
for (const f of files) if (f.endsWith(suffix)) slugs.push(f.slice(0, -suffix.length));
} else if (strategy === "directory") {
const localeDir = join(cDir, locale);
if (await pathExists(localeDir)) {
const files = await readDir(localeDir);
for (const f of files) if (f.endsWith(".md")) slugs.push(f.replace(".md", ""));
}
} else {
const files = await readDir(cDir);
for (const f of files) if (f.endsWith(".md")) slugs.push(f.replace(".md", ""));
}
return slugs;
}
case "singleton": return [];
default: return [];
}
}
async function analyzeDuplicateValues(projectRoot, config) {
const models = await listModels(projectRoot);
const result = [];
for (const m of models) {
if (m.kind !== "dictionary") continue;
const fullModel = await readModel(projectRoot, m.id);
if (!fullModel) continue;
const cDir = resolveContentDir(projectRoot, fullModel);
for (const locale of config.locales.supported) {
const data = await readJson(resolveJsonFilePath(cDir, fullModel, locale));
if (!data) continue;
const valueToKeys = /* @__PURE__ */ new Map();
for (const [key, value] of Object.entries(data)) {
const arr = valueToKeys.get(value);
if (arr) arr.push(key);
else valueToKeys.set(value, [key]);
}
for (const [value, keys] of valueToKeys) if (keys.length > 1) result.push({
model: m.id,
locale,
value,
keys
});
}
}
return result;
}
async function analyzeMissingLocaleKeys(projectRoot, config) {
if (config.locales.supported.length < 2) return [];
const models = await listModels(projectRoot);
const result = [];
const defaultLocale = config.locales.default;
const otherLocales = config.locales.supported.filter((l) => l !== defaultLocale);
for (const m of models) {
if (m.kind !== "dictionary" && m.kind !== "collection") continue;
if (!m.i18n) continue;
const fullModel = await readModel(projectRoot, m.id);
if (!fullModel) continue;
const cDir = resolveContentDir(projectRoot, fullModel);
const defaultData = await readJson(resolveJsonFilePath(cDir, fullModel, defaultLocale));
if (!defaultData) continue;
const defaultKeys = new Set(Object.keys(defaultData));
for (const locale of otherLocales) {
const localeData = await readJson(resolveJsonFilePath(cDir, fullModel, locale));
const localeKeys = localeData ? new Set(Object.keys(localeData)) : /* @__PURE__ */ new Set();
for (const key of defaultKeys) if (!localeKeys.has(key)) result.push({
model: m.id,
key,
missingIn: locale
});
}
}
return result;
}
//#endregion
export { runDoctor as t };
//# sourceMappingURL=doctor-BTToHt0y.mjs.map
{"version":3,"file":"doctor-BTToHt0y.mjs","names":[],"sources":["../src/core/doctor.ts"],"sourcesContent":["import { join } from 'node:path'\nimport { stat } from 'node:fs/promises'\nimport { simpleGit } from 'simple-git'\nimport type { ContentrainConfig, ModelDefinition } from '@contentrain/types'\nimport { readConfig } from './config.js'\nimport { listModels, readModel } from './model-manager.js'\nimport { resolveContentDir, resolveJsonFilePath, resolveLocaleStrategy } from './content-manager.js'\nimport { autoDetectSourceDirs, discoverFiles } from './scan-config.js'\nimport { checkBranchHealth, listRemoteCrBranches } from '../git/branch-lifecycle.js'\nimport { contentrainDir, pathExists, readDir, readJson, readText } from '../util/fs.js'\n\n/**\n * Doctor — project health report.\n *\n * The public entry point is `runDoctor(projectRoot, { usage? })`. It is\n * inherently local-filesystem work (Node version, git install, file\n * mtimes, orphan directory detection), so the MCP tool surface gates\n * it behind the `localWorktree` capability — same pattern as\n * `contentrain_setup` and normalize.\n *\n * The report is structured JSON so three consumers can share it:\n *\n * - The `contentrain doctor` CLI command pretty-prints the checks.\n * - The Serve UI `/api/doctor` route returns the report to the\n * Dashboard's Doctor panel.\n * - Automation (CI, Studio) gets a deterministic JSON shape it can\n * assert against.\n *\n * Usage analysis (`--usage`) is a heavier, opt-in branch — it scans\n * every source file in the repo for content-key references. Kept\n * behind the flag so the default doctor run stays fast.\n */\n\nexport type CheckSeverity = 'error' | 'warning' | 'info'\n\nexport interface DoctorCheck {\n name: string\n pass: boolean\n detail: string\n /**\n * `error` — default for failing checks. Blocks a clean bill of health.\n * `warning` — failing-but-not-blocking (e.g. pending branches above\n * threshold, stale SDK client).\n * `info` — passed check; pure informational.\n */\n severity?: CheckSeverity\n}\n\nexport interface UnusedKeyEntry {\n model: string\n kind: string\n key: string\n locale: string\n}\n\nexport interface DuplicateValueEntry {\n model: string\n locale: string\n value: string\n keys: string[]\n}\n\nexport interface MissingLocaleEntry {\n model: string\n key: string\n missingIn: string\n}\n\nexport interface DoctorUsageAnalysis {\n unusedKeys: UnusedKeyEntry[]\n duplicateValues: DuplicateValueEntry[]\n missingLocaleKeys: MissingLocaleEntry[]\n}\n\nexport interface DoctorReport {\n checks: DoctorCheck[]\n summary: {\n total: number\n passed: number\n failed: number\n warnings: number\n }\n /** Present only when `options.usage === true`. */\n usage?: DoctorUsageAnalysis\n}\n\nexport interface RunDoctorOptions {\n /** Run heavier `--usage` analysis (unused keys, duplicates, locale gaps). */\n usage?: boolean\n}\n\nexport async function runDoctor(\n projectRoot: string,\n options: RunDoctorOptions = {},\n): Promise<DoctorReport> {\n const checks: DoctorCheck[] = []\n\n // ─── 1. Git installed ───\n try {\n const git = simpleGit(projectRoot)\n const version = await git.version()\n checks.push({\n name: 'Git',\n pass: true,\n detail: `v${version.major}.${version.minor}.${version.patch}`,\n })\n } catch {\n checks.push({ name: 'Git', pass: false, detail: 'Not installed or not in PATH', severity: 'error' })\n }\n\n // ─── 2. Git repo initialized ───\n const hasGit = await pathExists(join(projectRoot, '.git'))\n checks.push({\n name: 'Git repository',\n pass: hasGit,\n detail: hasGit ? projectRoot : 'No .git directory found',\n severity: hasGit ? undefined : 'error',\n })\n\n // ─── 3. Node version ───\n const nodeVersion = process.versions.node\n const [major] = nodeVersion.split('.').map(Number)\n const nodePass = (major ?? 0) >= 22\n checks.push({\n name: 'Node.js',\n pass: nodePass,\n detail: `v${nodeVersion}${nodePass ? '' : ' (requires ≥22)'}`,\n severity: nodePass ? undefined : 'error',\n })\n\n // ─── 4. .contentrain/ structure ───\n const crDir = contentrainDir(projectRoot)\n const hasCrDir = await pathExists(crDir)\n const hasConfig = await pathExists(join(crDir, 'config.json'))\n const hasModels = await pathExists(join(crDir, 'models'))\n const hasContent = await pathExists(join(crDir, 'content'))\n const structurePass = hasCrDir && hasConfig && hasModels && hasContent\n\n checks.push({\n name: '.contentrain/ structure',\n pass: structurePass,\n detail: !hasCrDir\n ? 'Not initialized — run `contentrain init`'\n : [\n hasConfig ? null : 'missing config.json',\n hasModels ? null : 'missing models/',\n hasContent ? null : 'missing content/',\n ].filter(Boolean).join(', ') || 'OK',\n severity: structurePass ? undefined : 'error',\n })\n\n // ─── 5. Config parseable ───\n let config: ContentrainConfig | null = null\n if (hasConfig) {\n config = await readConfig(projectRoot)\n checks.push({\n name: 'Config',\n pass: config !== null,\n detail: config\n ? `stack: ${config.stack}, locales: ${config.locales.supported.join(', ')}`\n : 'Failed to parse config.json',\n severity: config ? undefined : 'error',\n })\n }\n\n // ─── 6. Models all parseable ───\n if (hasCrDir) {\n try {\n const models = await listModels(projectRoot)\n const parseResults = await Promise.all(models.map(m => readModel(projectRoot, m.id)))\n const allParseable = parseResults.every(r => r !== null)\n checks.push({\n name: 'Models',\n pass: allParseable,\n detail: `${models.length} model(s)${allParseable ? ', all valid' : ', some failed to parse'}`,\n severity: allParseable ? undefined : 'error',\n })\n } catch {\n checks.push({ name: 'Models', pass: false, detail: 'Failed to read models', severity: 'error' })\n }\n }\n\n // ─── 7. Orphan content ───\n if (hasCrDir) {\n const orphans = await findOrphanContent(projectRoot)\n checks.push({\n name: 'Orphan content',\n pass: orphans.length === 0,\n detail: orphans.length === 0 ? 'None' : `Found: ${orphans.join(', ')}`,\n severity: orphans.length === 0 ? undefined : 'warning',\n })\n }\n\n // ─── 8. Stale contentrain branches ───\n if (hasGit) {\n try {\n const health = await checkBranchHealth(projectRoot)\n checks.push({\n name: 'Pending branches',\n pass: !health.blocked && !health.warning,\n detail: health.message\n ?? (health.unmerged === 0 ? 'None' : `${health.unmerged} active cr/* branch(es)`),\n severity: health.blocked ? 'error' : health.warning ? 'warning' : undefined,\n })\n } catch {\n checks.push({ name: 'Pending branches', pass: true, detail: 'Could not check' })\n }\n\n // ─── 8b. Remote cr/* branches ───\n // Authoritative ls-remote count (local branch pressure cannot see the\n // remote pile). Best-effort: skipped entirely without a remote, and an\n // unreachable remote is informational — doctor never fails offline.\n const remoteList = await listRemoteCrBranches(projectRoot, { timeoutMs: 5000 })\n if (remoteList) {\n if (remoteList.error) {\n checks.push({\n name: 'Remote branches',\n pass: true,\n detail: `Could not check ${remoteList.remote} (offline?)`,\n severity: 'info',\n })\n } else {\n const count = remoteList.branches.length\n const warnLimit = config?.branchWarnLimit ?? 50\n const warn = count >= warnLimit\n checks.push({\n name: 'Remote branches',\n pass: !warn,\n detail: count === 0\n ? `None on ${remoteList.remote}`\n : `${count} cr/* branch(es) on ${remoteList.remote}${warn ? ' — run `contentrain prune` to remove merged leftovers' : ''}`,\n severity: warn ? 'warning' : undefined,\n })\n }\n }\n }\n\n // ─── 9. SDK client freshness ───\n const clientDir = join(crDir, 'client')\n const modelsDir = join(crDir, 'models')\n if (await pathExists(clientDir) && await pathExists(modelsDir)) {\n try {\n const [clientStat, modelsStat] = await Promise.all([stat(clientDir), stat(modelsDir)])\n const fresh = clientStat.mtimeMs >= modelsStat.mtimeMs\n checks.push({\n name: 'SDK client',\n pass: fresh,\n detail: fresh ? 'Up to date' : 'Stale — run `contentrain generate`',\n severity: fresh ? undefined : 'warning',\n })\n } catch {\n checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' })\n }\n }\n\n // ─── 10–12. Usage analysis (optional) ───\n let usage: DoctorUsageAnalysis | undefined\n if (options.usage && hasCrDir && config) {\n const [unusedKeys, duplicateValues, missingLocaleKeys] = await Promise.all([\n analyzeUnusedKeys(projectRoot, config),\n analyzeDuplicateValues(projectRoot, config),\n analyzeMissingLocaleKeys(projectRoot, config),\n ])\n usage = { unusedKeys, duplicateValues, missingLocaleKeys }\n\n checks.push({\n name: 'Unused content keys',\n pass: unusedKeys.length === 0,\n detail: unusedKeys.length === 0\n ? 'All keys referenced in source'\n : `${unusedKeys.length} key(s) not referenced in source code`,\n severity: unusedKeys.length === 0 ? undefined : 'warning',\n })\n\n checks.push({\n name: 'Duplicate dictionary values',\n pass: duplicateValues.length === 0,\n detail: duplicateValues.length === 0\n ? 'No duplicate values'\n : `${duplicateValues.length} value(s) mapped to multiple keys`,\n severity: duplicateValues.length === 0 ? undefined : 'warning',\n })\n\n checks.push({\n name: 'Locale key coverage',\n pass: missingLocaleKeys.length === 0,\n detail: missingLocaleKeys.length === 0\n ? 'All locales have matching keys'\n : `${missingLocaleKeys.length} key(s) missing in some locales`,\n severity: missingLocaleKeys.length === 0 ? undefined : 'warning',\n })\n }\n\n const passed = checks.filter(c => c.pass).length\n const failed = checks.length - passed\n const warnings = checks.filter(c => !c.pass && c.severity === 'warning').length\n\n const report: DoctorReport = {\n checks,\n summary: { total: checks.length, passed, failed, warnings },\n }\n if (usage) report.usage = usage\n return report\n}\n\nasync function findOrphanContent(projectRoot: string): Promise<string[]> {\n const crDir = contentrainDir(projectRoot)\n const models = await listModels(projectRoot)\n const orphans: string[] = []\n\n const knownContentDirs = new Set<string>()\n for (const m of models) {\n const full = await readModel(projectRoot, m.id)\n const modelForPath = full\n ? {\n ...full,\n content_path: full.content_path ?? (m as { content_path?: string }).content_path,\n }\n : {\n id: m.id,\n name: m.id,\n kind: m.kind,\n domain: m.domain,\n i18n: m.i18n,\n fields: {},\n content_path: (m as { content_path?: string }).content_path,\n }\n knownContentDirs.add(resolveContentDir(projectRoot, modelForPath))\n }\n\n const contentDir = join(crDir, 'content')\n if (await pathExists(contentDir)) {\n const domains = await readDir(contentDir)\n for (const domain of domains) {\n const domainDir = join(contentDir, domain)\n const entries = await readDir(domainDir)\n for (const entry of entries) {\n if (entry === '.gitkeep') continue\n const entryDir = join(domainDir, entry)\n if (!knownContentDirs.has(entryDir)) {\n orphans.push(`${domain}/${entry}`)\n }\n }\n }\n }\n\n for (const dir of knownContentDirs) {\n if (dir.startsWith(contentDir)) continue\n if (!await pathExists(dir)) {\n orphans.push(`(missing custom path) ${dir}`)\n continue\n }\n await readDir(dir)\n }\n\n return orphans\n}\n\nasync function analyzeUnusedKeys(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<UnusedKeyEntry[]> {\n const sourceDirs = await autoDetectSourceDirs(projectRoot)\n const files = await discoverFiles(projectRoot, { paths: sourceDirs })\n if (files.length === 0) return []\n\n const chunks = await Promise.all(\n files.map(async (relPath) => {\n const content = await readText(join(projectRoot, relPath))\n return content ?? ''\n }),\n )\n const allSource = chunks.join('\\n')\n\n const models = await listModels(projectRoot)\n const defaultLocale = config.locales.default\n const unused: UnusedKeyEntry[] = []\n\n for (const m of models) {\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const keys = await extractContentKeys(projectRoot, fullModel, defaultLocale)\n for (const key of keys) {\n if (!allSource.includes(key)) {\n unused.push({ model: m.id, kind: m.kind, key, locale: defaultLocale })\n }\n }\n }\n\n return unused\n}\n\nasync function extractContentKeys(\n projectRoot: string,\n model: ModelDefinition,\n locale: string,\n): Promise<string[]> {\n const cDir = resolveContentDir(projectRoot, model)\n if (!await pathExists(cDir)) return []\n\n switch (model.kind) {\n case 'dictionary': {\n const filePath = resolveJsonFilePath(cDir, model, locale)\n const data = await readJson<Record<string, string>>(filePath)\n return data ? Object.keys(data) : []\n }\n case 'collection': {\n const filePath = resolveJsonFilePath(cDir, model, locale)\n const data = await readJson<Record<string, Record<string, unknown>>>(filePath)\n return data ? Object.keys(data) : []\n }\n case 'document': {\n const strategy = resolveLocaleStrategy(model)\n const slugs: string[] = []\n if (!model.i18n) {\n const files = await readDir(cDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n } else if (strategy === 'file') {\n const dirs = await readDir(cDir)\n for (const d of dirs) if (!d.startsWith('.')) slugs.push(d)\n } else if (strategy === 'suffix') {\n const files = await readDir(cDir)\n const suffix = `.${locale}.md`\n for (const f of files) if (f.endsWith(suffix)) slugs.push(f.slice(0, -suffix.length))\n } else if (strategy === 'directory') {\n const localeDir = join(cDir, locale)\n if (await pathExists(localeDir)) {\n const files = await readDir(localeDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n }\n } else {\n const files = await readDir(cDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n }\n return slugs\n }\n case 'singleton':\n return []\n default:\n return []\n }\n}\n\nasync function analyzeDuplicateValues(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<DuplicateValueEntry[]> {\n const models = await listModels(projectRoot)\n const result: DuplicateValueEntry[] = []\n\n for (const m of models) {\n if (m.kind !== 'dictionary') continue\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const cDir = resolveContentDir(projectRoot, fullModel)\n for (const locale of config.locales.supported) {\n const filePath = resolveJsonFilePath(cDir, fullModel, locale)\n const data = await readJson<Record<string, string>>(filePath)\n if (!data) continue\n\n const valueToKeys = new Map<string, string[]>()\n for (const [key, value] of Object.entries(data)) {\n const arr = valueToKeys.get(value)\n if (arr) arr.push(key)\n else valueToKeys.set(value, [key])\n }\n\n for (const [value, keys] of valueToKeys) {\n if (keys.length > 1) {\n result.push({ model: m.id, locale, value, keys })\n }\n }\n }\n }\n\n return result\n}\n\nasync function analyzeMissingLocaleKeys(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<MissingLocaleEntry[]> {\n if (config.locales.supported.length < 2) return []\n\n const models = await listModels(projectRoot)\n const result: MissingLocaleEntry[] = []\n const defaultLocale = config.locales.default\n const otherLocales = config.locales.supported.filter(l => l !== defaultLocale)\n\n for (const m of models) {\n if (m.kind !== 'dictionary' && m.kind !== 'collection') continue\n if (!m.i18n) continue\n\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const cDir = resolveContentDir(projectRoot, fullModel)\n const defaultPath = resolveJsonFilePath(cDir, fullModel, defaultLocale)\n const defaultData = await readJson<Record<string, unknown>>(defaultPath)\n if (!defaultData) continue\n const defaultKeys = new Set(Object.keys(defaultData))\n\n for (const locale of otherLocales) {\n const localePath = resolveJsonFilePath(cDir, fullModel, locale)\n const localeData = await readJson<Record<string, unknown>>(localePath)\n const localeKeys = localeData ? new Set(Object.keys(localeData)) : new Set<string>()\n\n for (const key of defaultKeys) {\n if (!localeKeys.has(key)) {\n result.push({ model: m.id, key, missingIn: locale })\n }\n }\n }\n }\n\n return result\n}\n"],"mappings":"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"}
import { t as LOCAL_CAPABILITIES } from "./contracts-DfL0BfrD.mjs";
import { a as applyChangesToWorktree } from "./ops-Dh2T3u53.mjs";
import { t as readConfig } from "./config-DlFcTxFG.mjs";
import { C as LocalReader } from "./model-manager-BhLsUgaB.mjs";
import { a as deleteRemoteBranch, r as classifyMergedBranches } from "./branch-lifecycle-D7tXOE4f.mjs";
import { i as mergeBranch$1, n as createTransaction } from "./transaction-qliKvBRL.mjs";
import { CONTENTRAIN_BRANCH } from "@contentrain/types";
import { simpleGit } from "simple-git";
//#region src/providers/local/branch-ops.ts
/**
* Branch/merge/diff helpers backed by the local simple-git worktree flow.
*
* Pure functions composed into `LocalProvider` — mirroring the shape of
* `providers/github/branch-ops.ts` so the two providers share the same
* surface at the `RepoProvider` boundary.
*/
async function getDefaultBranch(projectRoot) {
const config = await readConfig(projectRoot);
if (config?.repository?.default_branch) return config.repository.default_branch;
const envBranch = process.env["CONTENTRAIN_BRANCH"];
if (envBranch) return envBranch;
return (await simpleGit(projectRoot).raw(["branch", "--show-current"])).trim() || "main";
}
async function listBranches(projectRoot, prefix) {
const summary = await simpleGit(projectRoot).branchLocal();
const names = prefix ? summary.all.filter((n) => n.startsWith(prefix)) : summary.all;
const branches = [];
for (const name of names) {
const info = summary.branches[name];
branches.push({
name,
sha: info?.commit ?? ""
});
}
return branches;
}
async function createBranch(projectRoot, name, fromRef) {
await simpleGit(projectRoot).raw([
"branch",
name,
fromRef
]);
}
async function deleteBranch(projectRoot, name) {
await simpleGit(projectRoot).deleteLocalBranch(name, true);
await deleteRemoteBranch(projectRoot, name);
}
async function getBranchDiff(projectRoot, branch, base) {
const raw = await simpleGit(projectRoot).raw([
"diff",
"--name-status",
`${base}...${branch}`
]);
const diffs = [];
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
const [code, ...pathParts] = line.split(" ");
const path = pathParts[pathParts.length - 1];
if (!code || !path) continue;
const status = code.startsWith("A") ? "added" : code.startsWith("D") ? "removed" : "modified";
diffs.push({
path,
status,
before: null,
after: null
});
}
return diffs;
}
async function mergeBranch(projectRoot, branch, into) {
if (into !== CONTENTRAIN_BRANCH) throw Object.assign(/* @__PURE__ */ new Error(`LocalProvider.mergeBranch only supports merging into "${CONTENTRAIN_BRANCH}" (got "${into}"). The local flow merges feature branches into the content-tracking branch and fast-forwards the base branch via update-ref.`), {
code: "UNSUPPORTED_MERGE_TARGET",
agent_hint: `Pass "${CONTENTRAIN_BRANCH}" as the merge target, or use a non-local provider that supports arbitrary targets.`,
developer_action: `Merge "${branch}" into "${CONTENTRAIN_BRANCH}" instead.`
});
const result = await mergeBranch$1(projectRoot, branch);
return {
merged: true,
sha: result.commit,
pullRequestUrl: null,
sync: result.sync,
...result.remote ? { remote: result.remote } : {}
};
}
async function isMerged(projectRoot, branch, into) {
try {
return (await classifyMergedBranches(projectRoot, [branch], into)).has(branch);
} catch {
return false;
}
}
//#endregion
//#region src/providers/local/provider.ts
const DEFAULT_AUTHOR_NAME = "Contentrain";
const DEFAULT_AUTHOR_EMAIL = "ai@contentrain.io";
/**
* LocalProvider — the local-filesystem, worktree-backed content provider.
*
* Implements the full `RepoProvider` surface:
* - Reader methods delegate to `LocalReader`.
* - `applyPlan` wraps `createTransaction` and returns `LocalApplyResult`
* (a superset of `Commit` carrying workflow action + selective sync).
* - Branch ops mirror `GitHubProvider` — thin wrappers over the local
* simple-git helpers in `./branch-ops.ts`.
*
* `mergeBranch` only supports merging into the singleton
* `CONTENTRAIN_BRANCH`; the local flow advances the base branch via
* `update-ref` in `transaction.mergeBranch`, so arbitrary merge targets
* would bypass that invariant.
*/
var LocalProvider = class {
capabilities = LOCAL_CAPABILITIES;
reader;
constructor(projectRoot) {
this.projectRoot = projectRoot;
this.reader = new LocalReader(projectRoot);
}
readFile(path, ref) {
return this.reader.readFile(path, ref);
}
listDirectory(path, ref) {
return this.reader.listDirectory(path, ref);
}
fileExists(path, ref) {
return this.reader.fileExists(path, ref);
}
async applyPlan(input) {
const tx = await createTransaction(this.projectRoot, input.branch, { workflowOverride: input.workflowOverride });
try {
await tx.write(async (wt) => {
await applyChangesToWorktree(wt, input.changes);
});
await tx.commit(input.message, input.context);
const gitResult = await tx.complete();
return {
sha: gitResult.commit,
message: input.message,
author: {
name: process.env["CONTENTRAIN_AUTHOR_NAME"] ?? DEFAULT_AUTHOR_NAME,
email: process.env["CONTENTRAIN_AUTHOR_EMAIL"] ?? DEFAULT_AUTHOR_EMAIL
},
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
workflowAction: gitResult.action,
sync: gitResult.sync,
warning: gitResult.warning
};
} finally {
await tx.cleanup();
}
}
listBranches(prefix) {
return listBranches(this.projectRoot, prefix);
}
async createBranch(name, fromRef) {
const resolved = fromRef ?? CONTENTRAIN_BRANCH;
await createBranch(this.projectRoot, name, resolved);
}
deleteBranch(name) {
return deleteBranch(this.projectRoot, name);
}
getBranchDiff(branch, base) {
const resolved = base ?? CONTENTRAIN_BRANCH;
return getBranchDiff(this.projectRoot, branch, resolved);
}
mergeBranch(branch, into) {
return mergeBranch(this.projectRoot, branch, into);
}
isMerged(branch, into) {
const resolved = into ?? CONTENTRAIN_BRANCH;
return isMerged(this.projectRoot, branch, resolved);
}
getDefaultBranch() {
return getDefaultBranch(this.projectRoot);
}
};
//#endregion
export { isMerged as n, LocalProvider as t };
//# sourceMappingURL=local-BZEUWBoC.mjs.map
{"version":3,"file":"local-BZEUWBoC.mjs","names":["mergeBranchOp","listBranchesOp","createBranchOp","deleteBranchOp","getBranchDiffOp","mergeBranchOp","isMergedOp","getDefaultBranchOp"],"sources":["../src/providers/local/branch-ops.ts","../src/providers/local/provider.ts"],"sourcesContent":["import { simpleGit } from 'simple-git'\nimport { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js'\nimport { readConfig } from '../../core/config.js'\nimport { classifyMergedBranches, deleteRemoteBranch } from '../../git/branch-lifecycle.js'\nimport { mergeBranch as mergeBranchOp } from '../../git/transaction.js'\n\n/**\n * Branch/merge/diff helpers backed by the local simple-git worktree flow.\n *\n * Pure functions composed into `LocalProvider` — mirroring the shape of\n * `providers/github/branch-ops.ts` so the two providers share the same\n * surface at the `RepoProvider` boundary.\n */\n\nexport async function getDefaultBranch(projectRoot: string): Promise<string> {\n const config = await readConfig(projectRoot)\n if (config?.repository?.default_branch) return config.repository.default_branch\n const envBranch = process.env['CONTENTRAIN_BRANCH']\n if (envBranch) return envBranch\n const git = simpleGit(projectRoot)\n const current = (await git.raw(['branch', '--show-current'])).trim()\n return current || 'main'\n}\n\nexport async function listBranches(\n projectRoot: string,\n prefix?: string,\n): Promise<Branch[]> {\n const git = simpleGit(projectRoot)\n const summary = await git.branchLocal()\n const names = prefix\n ? summary.all.filter(n => n.startsWith(prefix))\n : summary.all\n const branches: Branch[] = []\n for (const name of names) {\n const info = summary.branches[name]\n branches.push({ name, sha: info?.commit ?? '' })\n }\n return branches\n}\n\nexport async function createBranch(\n projectRoot: string,\n name: string,\n fromRef: string,\n): Promise<void> {\n const git = simpleGit(projectRoot)\n await git.raw(['branch', name, fromRef])\n}\n\nexport async function deleteBranch(\n projectRoot: string,\n name: string,\n): Promise<void> {\n const git = simpleGit(projectRoot)\n await git.deleteLocalBranch(name, true)\n // Parity with the remote-API providers, whose deleteBranch removes the\n // remote ref: best-effort, config-gated inside the helper, never throws.\n await deleteRemoteBranch(projectRoot, name)\n}\n\nexport async function getBranchDiff(\n projectRoot: string,\n branch: string,\n base: string,\n): Promise<FileDiff[]> {\n const git = simpleGit(projectRoot)\n const raw = await git.raw(['diff', '--name-status', `${base}...${branch}`])\n const diffs: FileDiff[] = []\n for (const line of raw.split('\\n')) {\n if (!line.trim()) continue\n const [code, ...pathParts] = line.split('\\t')\n const path = pathParts[pathParts.length - 1]\n if (!code || !path) continue\n const status: FileDiff['status'] = code.startsWith('A')\n ? 'added'\n : code.startsWith('D')\n ? 'removed'\n : 'modified'\n diffs.push({ path, status, before: null, after: null })\n }\n return diffs\n}\n\nexport async function mergeBranch(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<MergeResult> {\n if (into !== CONTENTRAIN_BRANCH) {\n throw Object.assign(new Error(\n `LocalProvider.mergeBranch only supports merging into \"${CONTENTRAIN_BRANCH}\" (got \"${into}\"). `\n + `The local flow merges feature branches into the content-tracking branch and fast-forwards the base branch via update-ref.`,\n ), {\n code: 'UNSUPPORTED_MERGE_TARGET',\n agent_hint: `Pass \"${CONTENTRAIN_BRANCH}\" as the merge target, or use a non-local provider that supports arbitrary targets.`,\n developer_action: `Merge \"${branch}\" into \"${CONTENTRAIN_BRANCH}\" instead.`,\n })\n }\n const result = await mergeBranchOp(projectRoot, branch)\n return {\n merged: true,\n sha: result.commit,\n pullRequestUrl: null,\n sync: result.sync,\n ...(result.remote ? { remote: result.remote } : {}),\n }\n}\n\nexport async function isMerged(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<boolean> {\n try {\n const merged = await classifyMergedBranches(projectRoot, [branch], into)\n return merged.has(branch)\n } catch {\n return false\n }\n}\n","import { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type {\n Branch,\n FileDiff,\n MergeResult,\n ProviderCapabilities,\n RepoProvider,\n} from '../../core/contracts/index.js'\nimport { LOCAL_CAPABILITIES } from '../../core/contracts/index.js'\nimport { applyChangesToWorktree } from '../../core/ops/index.js'\nimport { createTransaction } from '../../git/transaction.js'\nimport {\n createBranch as createBranchOp,\n deleteBranch as deleteBranchOp,\n getBranchDiff as getBranchDiffOp,\n getDefaultBranch as getDefaultBranchOp,\n isMerged as isMergedOp,\n listBranches as listBranchesOp,\n mergeBranch as mergeBranchOp,\n} from './branch-ops.js'\nimport { LocalReader } from './reader.js'\nimport type { LocalApplyPlanInput, LocalApplyResult } from './types.js'\n\nconst DEFAULT_AUTHOR_NAME = 'Contentrain'\nconst DEFAULT_AUTHOR_EMAIL = 'ai@contentrain.io'\n\n/**\n * LocalProvider — the local-filesystem, worktree-backed content provider.\n *\n * Implements the full `RepoProvider` surface:\n * - Reader methods delegate to `LocalReader`.\n * - `applyPlan` wraps `createTransaction` and returns `LocalApplyResult`\n * (a superset of `Commit` carrying workflow action + selective sync).\n * - Branch ops mirror `GitHubProvider` — thin wrappers over the local\n * simple-git helpers in `./branch-ops.ts`.\n *\n * `mergeBranch` only supports merging into the singleton\n * `CONTENTRAIN_BRANCH`; the local flow advances the base branch via\n * `update-ref` in `transaction.mergeBranch`, so arbitrary merge targets\n * would bypass that invariant.\n */\nexport class LocalProvider implements RepoProvider {\n readonly capabilities: ProviderCapabilities = LOCAL_CAPABILITIES\n private readonly reader: LocalReader\n\n constructor(public readonly projectRoot: string) {\n this.reader = new LocalReader(projectRoot)\n }\n\n readFile(path: string, ref?: string): Promise<string> {\n return this.reader.readFile(path, ref)\n }\n\n listDirectory(path: string, ref?: string): Promise<string[]> {\n return this.reader.listDirectory(path, ref)\n }\n\n fileExists(path: string, ref?: string): Promise<boolean> {\n return this.reader.fileExists(path, ref)\n }\n\n async applyPlan(input: LocalApplyPlanInput): Promise<LocalApplyResult> {\n const tx = await createTransaction(this.projectRoot, input.branch, {\n workflowOverride: input.workflowOverride,\n })\n try {\n await tx.write(async (wt) => {\n await applyChangesToWorktree(wt, input.changes)\n })\n await tx.commit(input.message, input.context)\n const gitResult = await tx.complete()\n return {\n sha: gitResult.commit,\n message: input.message,\n author: {\n name: process.env['CONTENTRAIN_AUTHOR_NAME'] ?? DEFAULT_AUTHOR_NAME,\n email: process.env['CONTENTRAIN_AUTHOR_EMAIL'] ?? DEFAULT_AUTHOR_EMAIL,\n },\n timestamp: new Date().toISOString(),\n workflowAction: gitResult.action,\n sync: gitResult.sync,\n warning: gitResult.warning,\n }\n } finally {\n await tx.cleanup()\n }\n }\n\n listBranches(prefix?: string): Promise<Branch[]> {\n return listBranchesOp(this.projectRoot, prefix)\n }\n\n async createBranch(name: string, fromRef?: string): Promise<void> {\n const resolved = fromRef ?? CONTENTRAIN_BRANCH\n await createBranchOp(this.projectRoot, name, resolved)\n }\n\n deleteBranch(name: string): Promise<void> {\n return deleteBranchOp(this.projectRoot, name)\n }\n\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]> {\n const resolved = base ?? CONTENTRAIN_BRANCH\n return getBranchDiffOp(this.projectRoot, branch, resolved)\n }\n\n mergeBranch(branch: string, into: string): Promise<MergeResult> {\n return mergeBranchOp(this.projectRoot, branch, into)\n }\n\n isMerged(branch: string, into?: string): Promise<boolean> {\n const resolved = into ?? CONTENTRAIN_BRANCH\n return isMergedOp(this.projectRoot, branch, resolved)\n }\n\n getDefaultBranch(): Promise<string> {\n return getDefaultBranchOp(this.projectRoot)\n }\n}\n"],"mappings":"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"}

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

import { t as readConfig } from "./config-DlFcTxFG.mjs";
import { r as writeContext } from "./context-CYN__o3Q.mjs";
import { a as deleteRemoteBranch } from "./branch-lifecycle-D7tXOE4f.mjs";
import { t as branchTimestamp } from "./id-DV_T9Ic8.mjs";
import { join } from "node:path";
import { CONTENTRAIN_BRANCH } from "@contentrain/types";
import { rm } from "node:fs/promises";
import { simpleGit } from "simple-git";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
//#region src/providers/local/migration.ts
/**
* Migration: the first MCP release used `contentrain/*` feature branches.
* Once we introduced the singleton `contentrain` branch (tracking the
* committed content state), those old feature branches became a ref-
* namespace conflict — git cannot hold both `contentrain` (a leaf ref)
* and `contentrain/foo` (implying a directory) simultaneously.
*
* `migrateLegacyBranches` removes the old-prefix branches so the
* singleton `contentrain` ref can be created. It is idempotent and
* safe to call before every `ensureContentBranch` run.
*
* Strategy:
* 1. Delete merged `contentrain/*` branches first (`-d`). Their commits
* are already on the base branch via the old auto-merge flow.
* 2. Force-delete whatever remains (`-D`). Any unmerged leftover is
* from an abandoned or partially-committed legacy branch — content
* on `main` always wins, and the singleton `contentrain` branch is
* about to be created from `main`/`baseBranch` anyway.
*
* Returns the number of branches that were deleted. Callers may log it;
* the git transaction layer does not need the count for correctness.
*/
async function migrateLegacyBranches(git, baseBranch) {
if ((await git.branchLocal()).all.filter((b) => b.startsWith("contentrain/")).length === 0) return 0;
let deleted = 0;
let mergedLegacy = [];
try {
mergedLegacy = (await git.raw([
"branch",
"--merged",
baseBranch
])).split("\n").map((b) => b.trim().replace(/^\*\s*/, "")).filter((b) => b.startsWith("contentrain/"));
} catch {}
for (const b of mergedLegacy) try {
await git.raw([
"branch",
"-d",
b
]);
deleted++;
} catch {}
const remaining = (await git.branchLocal()).all.filter((b) => b.startsWith("contentrain/"));
for (const b of remaining) try {
await git.raw([
"branch",
"-D",
b
]);
deleted++;
} catch {}
return deleted;
}
//#endregion
//#region src/git/transaction.ts
async function ensureContentBranch(projectRoot) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
if ((await git.branchLocal()).all.includes(CONTENTRAIN_BRANCH)) return;
const baseBranch = config?.repository?.default_branch || (await git.raw(["branch", "--show-current"])).trim() || "main";
await migrateLegacyBranches(git, baseBranch);
await git.branch([CONTENTRAIN_BRANCH, baseBranch]);
const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
try {
if ((await git.getRemotes()).some((r) => r.name === remoteName)) await git.push([
"-u",
remoteName,
CONTENTRAIN_BRANCH
]);
} catch {}
}
/**
* Commit identity for worktree operations, supplied via environment instead
* of two `git config` spawns per transaction. Git honors GIT_AUTHOR_* /
* GIT_COMMITTER_* for both the sync merges and the feature-branch commit, so
* a worktree git built with this env needs no `git config user.*` calls.
* `process.env` is spread so PATH/HOME and any GIT_* already set survive.
*/
function authorEnv() {
const name = process.env["CONTENTRAIN_AUTHOR_NAME"] ?? "Contentrain";
const email = process.env["CONTENTRAIN_AUTHOR_EMAIL"] ?? "ai@contentrain.io";
return {
...process.env,
GIT_AUTHOR_NAME: name,
GIT_AUTHOR_EMAIL: email,
GIT_COMMITTER_NAME: name,
GIT_COMMITTER_EMAIL: email
};
}
async function selectiveSync(projectRoot, _worktreePath, contentrainTip, _previousBaseRef, dirtyFilesBeforeUpdate) {
const git = simpleGit(projectRoot);
const synced = [];
const skipped = [];
const compareRef = _previousBaseRef ?? contentrainTip;
let changedFiles = [];
try {
changedFiles = (await git.raw([
"diff-tree",
"--name-only",
"-r",
"--no-commit-id",
compareRef,
contentrainTip
])).split("\n").filter((f) => f.trim().length > 0);
} catch {
try {
changedFiles = (await git.raw([
"ls-tree",
"-r",
"--name-only",
contentrainTip,
".contentrain/"
])).split("\n").filter((f) => f.trim().length > 0);
} catch {
return {
synced,
skipped
};
}
}
if (changedFiles.length === 0) return {
synced,
skipped
};
const dirtyFiles = dirtyFilesBeforeUpdate ?? /* @__PURE__ */ new Set();
const filesInTip = /* @__PURE__ */ new Set();
try {
const lsOutput = await git.raw([
"ls-tree",
"-r",
"--name-only",
contentrainTip,
"--",
...changedFiles
]);
for (const f of lsOutput.split("\n")) {
const trimmed = f.trim();
if (trimmed) filesInTip.add(trimmed);
}
} catch {
for (const file of changedFiles) try {
await git.raw([
"cat-file",
"-e",
`${contentrainTip}:${file}`
]);
filesInTip.add(file);
} catch {}
}
const toCheckout = [];
const toRemove = [];
for (const file of changedFiles) if (dirtyFiles.has(file)) skipped.push(file);
else if (filesInTip.has(file)) toCheckout.push(file);
else toRemove.push(file);
if (toCheckout.length > 0) try {
await git.checkout([
"HEAD",
"--",
...toCheckout
]);
synced.push(...toCheckout);
} catch {
for (const file of toCheckout) try {
await git.checkout([
"HEAD",
"--",
file
]);
synced.push(file);
} catch {
skipped.push(file);
}
}
await Promise.all(toRemove.map(async (file) => {
try {
await rm(join(projectRoot, file), { force: true });
synced.push(file);
} catch {
skipped.push(file);
}
}));
return {
synced,
skipped,
warning: skipped.length > 0 ? `${skipped.length} file(s) skipped due to local changes: ${skipped.join(", ")}. Commit your changes, then run: git checkout HEAD -- ${skipped.join(" ")}` : void 0
};
}
async function createTransaction(projectRoot, branchName, options) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
const workflow = options?.workflowOverride ?? config?.workflow ?? "auto-merge";
const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
let baseBranch = process.env["CONTENTRAIN_BRANCH"] ?? config?.repository?.default_branch ?? "";
let currentBranch = "";
let hasRemote = false;
const [branchResult, remotes] = await Promise.all([git.raw(["branch", "--show-current"]).catch(() => ""), git.getRemotes().catch(() => [])]);
currentBranch = branchResult.trim();
if (!baseBranch) baseBranch = currentBranch || "main";
hasRemote = remotes.some((r) => r.name === remoteName);
if (currentBranch === CONTENTRAIN_BRANCH) throw Object.assign(/* @__PURE__ */ new Error(`The '${CONTENTRAIN_BRANCH}' branch is checked out in your working directory. Contentrain manages this branch automatically. Switch to your working branch and retry.`), {
code: "CONTENT_BRANCH_CHECKED_OUT",
agent_hint: "Ask the developer to switch to their working branch (e.g., main or a feature branch), then retry the operation.",
developer_action: `git checkout ${baseBranch}`
});
await ensureContentBranch(projectRoot);
if (hasRemote) await Promise.all([git.fetch(remoteName, baseBranch).catch(() => {}), git.fetch(remoteName, CONTENTRAIN_BRANCH).catch(() => {})]);
const worktreePath = join(tmpdir(), `cr-${randomUUID()}`);
const branch = branchName;
await git.raw([
"worktree",
"add",
worktreePath,
CONTENTRAIN_BRANCH
]);
const wtGit = simpleGit(worktreePath).env(authorEnv());
try {
await wtGit.merge([baseBranch, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
if (hasRemote) try {
await wtGit.merge([`${remoteName}/${baseBranch}`, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
}
}
if (hasRemote) try {
await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
}
await wtGit.checkout(["-b", branch]);
let commitHash = "";
let pendingReview = false;
let savedContextUpdate;
return {
worktree: worktreePath,
branch,
async write(callback) {
await callback(worktreePath);
},
async commit(message, contextUpdate) {
savedContextUpdate = contextUpdate;
await wtGit.add(".");
commitHash = (await wtGit.commit(message, {
"--allow-empty": null,
"--no-verify": null
})).commit || "";
return commitHash;
},
async complete() {
if (workflow === "review") {
if (hasRemote) await git.push(remoteName, branch);
pendingReview = true;
return {
action: "pending-review",
commit: commitHash
};
}
await wtGit.checkout(CONTENTRAIN_BRANCH);
try {
await wtGit.merge([branch, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
throw Object.assign(/* @__PURE__ */ new Error(`Merge conflict when merging branch "${branch}" into "${CONTENTRAIN_BRANCH}". The branch still exists with your changes intact. Resolve the conflict manually, or delete the branch and retry.`), {
code: "CONTENT_BRANCH_MERGE_CONFLICT",
agent_hint: "The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.",
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branch}`
});
}
if (savedContextUpdate) await regenerateContextOnContentrain(wtGit, worktreePath, savedContextUpdate);
const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([
wtGit.raw(["rev-parse", "HEAD"]).then((s) => s.trim()),
git.raw(["rev-parse", baseBranch]).then((s) => s.trim()),
git.status()
]);
const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map((f) => f.path));
if (!await isAncestor(git, previousBaseRef, contentrainTip)) throw Object.assign(/* @__PURE__ */ new Error(`Cannot fast-forward "${baseBranch}" to contentrain tip. The base branch has diverged. Merge "${baseBranch}" into "${CONTENTRAIN_BRANCH}" first.`), {
code: "BASE_UPDATE_FAILED",
agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`
});
await git.raw([
"update-ref",
`refs/heads/${baseBranch}`,
contentrainTip
]);
try {
await git.raw(["read-tree", "HEAD"]);
} catch {
try {
await git.raw(["reset", "HEAD"]);
} catch {}
}
const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate);
if (hasRemote) {
try {
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {
try {
await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH);
await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, "--no-edit"]);
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {}
}
try {
await git.push(remoteName, baseBranch);
} catch {}
}
return {
action: "auto-merged",
commit: commitHash,
sync,
...sync.warning ? { warning: sync.warning } : {}
};
},
async cleanup() {
try {
await git.raw([
"worktree",
"remove",
worktreePath,
"--force"
]);
} catch {}
if (!pendingReview) await safeDeleteBranch(git, branch);
}
};
}
async function mergeBranch(projectRoot, branchName) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
const baseBranch = process.env["CONTENTRAIN_BRANCH"] ?? config?.repository?.default_branch ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
await ensureContentBranch(projectRoot);
let hasRemote = false;
try {
hasRemote = (await git.getRemotes()).some((r) => r.name === remoteName);
} catch {
hasRemote = false;
}
const worktreePath = join(tmpdir(), `cr-merge-${randomUUID()}`);
await git.raw([
"worktree",
"add",
worktreePath,
CONTENTRAIN_BRANCH
]);
const wtGit = simpleGit(worktreePath).env(authorEnv());
try {
try {
await wtGit.merge([branchName, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
throw Object.assign(/* @__PURE__ */ new Error(`Merge conflict when merging branch "${branchName}" into "${CONTENTRAIN_BRANCH}". The branch still exists with your changes intact. Resolve the conflict manually, or delete the branch and retry.`), {
code: "CONTENT_BRANCH_MERGE_CONFLICT",
agent_hint: "The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.",
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branchName}`
});
}
await regenerateContextOnContentrain(wtGit, worktreePath, {
tool: "contentrain_merge",
model: "*"
});
const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([
wtGit.raw(["rev-parse", "HEAD"]).then((s) => s.trim()),
git.raw(["rev-parse", baseBranch]).then((s) => s.trim()),
git.status()
]);
const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map((f) => f.path));
if (!await isAncestor(git, previousBaseRef, contentrainTip)) throw Object.assign(/* @__PURE__ */ new Error(`Cannot fast-forward "${baseBranch}" to contentrain tip. The base branch has diverged. Merge "${baseBranch}" into "${CONTENTRAIN_BRANCH}" first.`), {
code: "BASE_UPDATE_FAILED",
agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`
});
await git.raw([
"update-ref",
`refs/heads/${baseBranch}`,
contentrainTip
]);
try {
await git.raw(["read-tree", "HEAD"]);
} catch {
try {
await git.raw(["reset", "HEAD"]);
} catch {}
}
const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate);
if (hasRemote) {
try {
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {
try {
await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH);
await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, "--no-edit"]);
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {}
}
try {
await git.push(remoteName, baseBranch);
} catch {}
}
await safeDeleteBranch(git, branchName);
let remote;
if (hasRemote) remote = await deleteRemoteBranch(projectRoot, branchName, { config });
return {
action: "merged",
commit: contentrainTip,
sync,
...remote ? { remote } : {}
};
} finally {
try {
await git.raw([
"worktree",
"remove",
worktreePath,
"--force"
]);
} catch {}
}
}
function buildBranchName(scope, target, locale) {
const ts = branchTimestamp();
const parts = [
"cr",
scope,
target
];
if (locale) parts.push(locale);
parts.push(ts);
return parts.join("/");
}
/**
* True when `ancestor` is an ancestor of (or equal to) `descendant`.
* Implemented with `rev-list --count` because `merge-base --is-ancestor`
* signals via exit code with empty stderr — simple-git reports that as
* success, so it cannot express a negative verdict.
*/
async function isAncestor(git, ancestor, descendant) {
try {
return Number((await git.raw([
"rev-list",
"--count",
ancestor,
`^${descendant}`
])).trim()) === 0;
} catch {
return false;
}
}
/**
* Force-delete a local branch, swallowing all errors. Never deletes the
* singleton `contentrain` branch. Used to prune feature branches after they
* are merged (auto-merge / contentrain_merge) or when a transaction fails
* before completing — so failed/merged `cr/*` refs do not accumulate.
*/
async function safeDeleteBranch(git, branch) {
if (!branch || branch === CONTENTRAIN_BRANCH) return;
try {
await git.raw([
"branch",
"-D",
branch
]);
} catch {}
}
/**
* Regenerate `.contentrain/context.json` deterministically inside a worktree
* that is currently on the `contentrain` branch, then commit it (hooks
* bypassed). Called AFTER a feature branch is merged so context.json is only
* ever written on `contentrain`, single-threaded — eliminating the per-branch
* merge conflicts that came from committing it on every feature branch.
*/
async function regenerateContextOnContentrain(wtGit, worktreePath, contextUpdate) {
await writeContext(worktreePath, contextUpdate);
await wtGit.add(".contentrain/context.json");
try {
await wtGit.commit("[contentrain] context: update", { "--no-verify": null });
} catch {}
}
//#endregion
export { mergeBranch as i, createTransaction as n, ensureContentBranch as r, buildBranchName as t };
//# sourceMappingURL=transaction-qliKvBRL.mjs.map
{"version":3,"file":"transaction-qliKvBRL.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 { 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\n/**\n * Commit identity for worktree operations, supplied via environment instead\n * of two `git config` spawns per transaction. Git honors GIT_AUTHOR_* /\n * GIT_COMMITTER_* for both the sync merges and the feature-branch commit, so\n * a worktree git built with this env needs no `git config user.*` calls.\n * `process.env` is spread so PATH/HOME and any GIT_* already set survive.\n */\nfunction authorEnv(): Record<string, string | undefined> {\n const name = process.env['CONTENTRAIN_AUTHOR_NAME'] ?? 'Contentrain'\n const email = process.env['CONTENTRAIN_AUTHOR_EMAIL'] ?? 'ai@contentrain.io'\n return {\n ...process.env,\n GIT_AUTHOR_NAME: name,\n GIT_AUTHOR_EMAIL: email,\n GIT_COMMITTER_NAME: name,\n GIT_COMMITTER_EMAIL: email,\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 the environment (see authorEnv) — no\n // `git config user.*` spawns.\n const wtGit = simpleGit(worktreePath).env(authorEnv())\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 the environment (see authorEnv) — no config spawns.\n const wtGit = simpleGit(worktreePath).env(authorEnv())\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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"}