From 83752e3785032af0c93bc7bbd4f4672976c4d374 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Sat, 15 Aug 2026 00:48:55 +0300 Subject: [PATCH] fix(content): approve tells the truth on a diverged repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live incident (collabers): the editor approved a change and got "Server Error"; retrying got another. The content had landed — every reader reads `contentrain`, and `contentrain` had it — but the advance to main answered 409 because main carried out-of-Studio `.contentrain/` changes (a dependency migration PR), and three places turned that recoverable state into lies: - the `main → contentrain` sync swallowed the conflict under a comment claiming the branches held "different directories" — they share `.contentrain/`, and the divergence became invisible - `finalizeContentrain` converted only protected-branch failures to a PR; a conflict was thrown raw, so a landed save surfaced as a 500 - the retry then died on "Head does not exist", because step 1 had deleted the cr/* branch the first time around The contract, aligned with the MCP-side R0 vocabulary (reconcile feedback, N3): `merged` answers what the caller actually asks — did the content land on `contentrain`. The advance is a separate fact, `mainAdvance: 'advanced' | 'blocked_diverged'`, and a PR is an attachment (`pullRequestUrl`), not a status. - advance conflict → PR, same fallback as protected main. The PR body names the divergence — it is the artifact a developer resolves. A 422 "already exists" is bookkeeping, not a failed merge; any other PR failure is logged, never thrown, because throwing would repeat the exact lie this removes. - protected fallback now also reports `merged: true` — step 1 had landed the content there too; the old `false` was the same lie. - a second approve of an already-landed branch finishes the half that failed (the advance) instead of 500ing on the deleted head. - a real cr-vs-contentrain conflict stays `merged: false` — the one case where "resolve manually" is the honest answer. - the sync conflict is logged loudly with the project id; writes continue (the editor is never blocked by a divergence a developer has to resolve). The UI says it out loud: merged + advanced → success toast; merged + blocked → warning that publishing awaits a developer review; conflict → error. All three from the dictionary — the success path was hardcoded English. The turn-end finalize log stops claiming divergence "self-heals on next merge" — it never did; finalize now turns it into a PR itself, so what reaches that catch is transient. Interim until the ecosystem's planReconcile ships (AI-REPO-RECONCILE): then the conflict path tries a content-aware three-way merge first and opens a PR only for what survives it. --- .../content/system/ui-strings/en.json | 3 + app/composables/useBranches.ts | 21 ++- server/utils/content-engine/branch-ops.ts | 104 +++++++++++-- server/utils/content-engine/types.ts | 25 +++ server/utils/conversation-engine.ts | 15 +- .../composables/use-branches.nuxt.test.ts | 44 +++++- tests/unit/content-engine.test.ts | 142 +++++++++++++++++- 7 files changed, 329 insertions(+), 25 deletions(-) diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index c780ca3a..082bf610 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -132,7 +132,10 @@ "branch.field_changed": "changed", "branch.files_changed": "{count} files changed", "branch.loading": "Loading diff...", + "branch.merge_conflict": "This change conflicts with the content branch — resolve it on GitHub.", "branch.merge_error": "Failed to merge branch. Please try again.", + "branch.merge_publish_pending": "Change merged. Publishing to the main branch awaits a developer review — a pull request is open.", + "branch.merge_success": "Change merged", "branch.modified": "modified", "branch.no_changes": "No changes found", "branch.reject": "Reject", diff --git a/app/composables/useBranches.ts b/app/composables/useBranches.ts index 4032bffd..dbdd6d81 100644 --- a/app/composables/useBranches.ts +++ b/app/composables/useBranches.ts @@ -68,21 +68,34 @@ export function useBranches() { } async function mergeBranch(workspaceId: string, projectId: string, branch: string): Promise { + const { t } = useContent() try { - const result = await $fetch<{ merged: boolean }>( + const result = await $fetch<{ + merged: boolean + mainAdvance?: 'advanced' | 'blocked_diverged' + pullRequestUrl?: string | null + }>( `/api/workspaces/${workspaceId}/projects/${projectId}/branches/${encodeURIComponent(branch)}/merge`, { method: 'POST' }, ) if (result.merged) { - toast.success(`Branch merged: ${branch}`) + // `merged` means the content landed on the branch every reader uses. + // Whether main advanced with it is a separate fact — telling the + // editor "merge failed" for a blocked advance is how an Approve on a + // diverged repo used to read as a lost save. + if (result.mainAdvance === 'blocked_diverged') { + toast.warning(t('branch.merge_publish_pending')) + } + else { + toast.success(t('branch.merge_success')) + } branches.value = branches.value.filter(b => b.name !== branch) return true } - toast.error('Merge conflict — resolve manually on GitHub') + toast.error(t('branch.merge_conflict')) return false } catch (e: unknown) { - const { t } = useContent() toast.error(resolveApiError(e, t('branch.merge_error'))) return false } diff --git a/server/utils/content-engine/branch-ops.ts b/server/utils/content-engine/branch-ops.ts index 621a1ac4..64814d89 100644 --- a/server/utils/content-engine/branch-ops.ts +++ b/server/utils/content-engine/branch-ops.ts @@ -1,6 +1,6 @@ import type { FileChange } from '@contentrain/types' import { buildContextChange } from '@contentrain/mcp/core/context' -import type { Branch, EngineInternalContext, MergeResult } from './types' +import type { Branch, EngineInternalContext, EngineMergeResult } from './types' import { STUDIO_AUTHOR, BRANCH_PREFIX, CONTENT_BRANCH } from './types' import { pinReaderToContentrain } from './helpers' import { buildContextChangeFromBrain } from './context-build' @@ -58,8 +58,22 @@ export function createBranchGuard(ctx: EngineInternalContext) { try { await ctx.git.mergeBranch(defaultBranch, CONTENT_BRANCH) } - catch { - // No-op (already in sync) or conflict (extremely rare — different directories) + catch (e: unknown) { + // A conflict here means the branches have DIVERGED: something touched + // `.contentrain/` on main outside the content pipeline (a dependency + // migration, a hand edit). That is a legitimate, recoverable state — + // writes keep landing on contentrain and the advance opens a PR — but + // it never self-heals, so swallowing it silently (as this catch did, + // under a comment claiming the two branches held "different + // directories") turned a recoverable state into an invisible one. + if (classifyMergeFailure(e) === 'conflict') { + console.warn( + `[contentrain] main → contentrain sync conflict — branches have diverged` + + `${ctx.projectId ? ` (project=${ctx.projectId})` : ''}. ` + + `Content writes continue on contentrain; the next advance will open a PR.`, + ) + } + // Everything else stays best-effort, as before. } } @@ -67,6 +81,20 @@ export function createBranchGuard(ctx: EngineInternalContext) { } } +/** + * Sort a merge failure into the classes the flow can act on. GitHub's merge + * API speaks in status codes and message prefixes; both are checked because + * the error may arrive as a raw Octokit HttpError or wrapped. + */ +function classifyMergeFailure(e: unknown): 'conflict' | 'missing_head' | 'blocked' | 'unknown' { + const status = (e as { status?: number }).status ?? (e as { statusCode?: number }).statusCode + const msg = e instanceof Error ? e.message : String(e) + if (status === 409 || msg.includes('Merge conflict')) return 'conflict' + if (msg.includes('Head does not exist')) return 'missing_head' + if (msg.includes('protected') || msg.includes('403') || msg.includes('not allowed')) return 'blocked' + return 'unknown' +} + /** * List contentrain/* branches (pending changes). */ @@ -112,7 +140,7 @@ export async function mergeToContentrain( export async function finalizeContentrain( ctx: EngineInternalContext, mergedBranches: string[], -): Promise { +): Promise { const lastBranch = mergedBranches.at(-1) if (lastBranch) { // Regenerate context.json on contentrain now that the content has @@ -124,21 +152,49 @@ export async function finalizeContentrain( // Step 2: advance contentrain -> main const defaultBranch = await ctx.git.getDefaultBranch() try { - return await ctx.git.mergeBranch(CONTENT_BRANCH, defaultBranch) + const advanced = await ctx.git.mergeBranch(CONTENT_BRANCH, defaultBranch) + return { ...advanced, mainAdvance: 'advanced' } } catch (e: unknown) { - // If direct merge fails (branch protection), create PR - const msg = e instanceof Error ? e.message : '' - if (msg.includes('protected') || msg.includes('403') || msg.includes('not allowed')) { + // By the time this runs, the content is on `contentrain` — the branch + // every reader (brain, CDN, MCP) uses. So an advance failure is NOT a + // failed merge, and reporting it as one is what made an Approve on a + // diverged repo read as a lost save. Two failure classes fall back to a + // PR — the place a developer resolves either one: + // - blocked: main is protected, the merge API is not allowed to touch it + // - conflict: main has commits contentrain does not (out-of-Studio + // `.contentrain/` changes — a migration PR, a hand edit). This never + // self-heals; the PR is where a human reconciles it. + const failure = classifyMergeFailure(e) + if (failure !== 'blocked' && failure !== 'conflict') throw e + + let pullRequestUrl: string | null = null + try { const pr = await ctx.git.createPR( CONTENT_BRANCH, defaultBranch, `contentrain: advance content to ${defaultBranch}`, - 'Auto-generated by Contentrain Studio.', + failure === 'conflict' + ? `The \`${CONTENT_BRANCH}\` branch and \`${defaultBranch}\` have diverged — ` + + `\`${defaultBranch}\` carries changes made outside the content pipeline. ` + + `Content edits live on the \`${CONTENT_BRANCH}\` side; resolve the conflicts here ` + + `to bring \`${defaultBranch}\` back in step.\n\nOpened by Contentrain Studio.` + : 'Auto-generated by Contentrain Studio.', ) - return { merged: false, sha: null, pullRequestUrl: pr.url } + pullRequestUrl = pr.url } - throw e + catch (prError: unknown) { + // GitHub answers 422 when a PR for this head/base already exists — the + // previous blocked advance opened it. Any other PR failure is logged, + // not thrown: the content landed, and turning a bookkeeping failure + // into a 500 would repeat the exact lie this function stopped telling. + const prMsg = prError instanceof Error ? prError.message : String(prError) + if (!prMsg.includes('already exists')) { + console.warn(`[contentrain] could not open the ${CONTENT_BRANCH} → ${defaultBranch} PR:`, prMsg) + } + } + + return { merged: true, sha: null, pullRequestUrl, mainAdvance: 'blocked_diverged' } } } @@ -152,8 +208,30 @@ export async function finalizeContentrain( * exactly this behavior. The agent tool loop calls the two halves * separately so a multi-save turn finalizes once at turn end. */ -export async function mergeBranch(ctx: EngineInternalContext, branch: string): Promise { - const step1 = await mergeToContentrain(ctx, branch) +export async function mergeBranch(ctx: EngineInternalContext, branch: string): Promise { + let step1: { merged: boolean, sha: string | null } + try { + step1 = await mergeToContentrain(ctx, branch) + } + catch (e: unknown) { + switch (classifyMergeFailure(e)) { + case 'missing_head': + // The branch is gone, and in this flow the only thing that deletes a + // cr/* branch is our own post-merge cleanup. So this is a RETRY after + // an advance failure: step 1 landed last time, the user clicked + // Approve again, and the old behavior answered the second click with + // an unhandled "Head does not exist" 500. Finish the half that + // actually failed instead. + return finalizeContentrain(ctx, [branch]) + case 'conflict': + // A real cr/* vs contentrain conflict — contentrain moved against + // this branch since it forked. The one case where "resolve manually" + // is the honest answer. + return { merged: false, sha: null, pullRequestUrl: null } + default: + throw e + } + } if (!step1.merged) { return { merged: false, sha: null, pullRequestUrl: null } } diff --git a/server/utils/content-engine/types.ts b/server/utils/content-engine/types.ts index 5b63ffe7..df88daf6 100644 --- a/server/utils/content-engine/types.ts +++ b/server/utils/content-engine/types.ts @@ -16,6 +16,31 @@ export interface WriteResult { unchanged?: boolean } +/** + * Outcome of the `contentrain → main` advance. The vocabulary is shared with + * the MCP-side R0 contract (AI-REPO-RECONCILE feedback, N3) so the two + * ecosystems name the same states the same way: + * + * - `advanced` — main now carries the content (includes "already up to date"). + * - `blocked_diverged` — main has commits contentrain does not; the advance + * cannot fast-forward. A PR carries the state instead: it is an attachment + * (`pullRequestUrl`), not a separate status. + */ +export type MainAdvance = 'advanced' | 'blocked_diverged' + +/** + * What a merge actually did, told truthfully. + * + * `merged` answers the question the caller is really asking — did the content + * land on `contentrain`, the SSOT every reader uses. The advance to `main` is + * a separate fact (`mainAdvance`): reporting its failure as "the merge failed" + * is what made an Approve on a diverged repo read as a lost save when nothing + * was lost. + */ +export interface EngineMergeResult extends MergeResult { + mainAdvance?: MainAdvance +} + export interface ContentEngineContext { git: GitProvider contentRoot: string diff --git a/server/utils/conversation-engine.ts b/server/utils/conversation-engine.ts index 1991baf7..3352bf1f 100644 --- a/server/utils/conversation-engine.ts +++ b/server/utils/conversation-engine.ts @@ -242,13 +242,20 @@ export async function* runConversationLoop( if (turnMergeFlushed || turnMerge.pendingFinalize.length === 0) return turnMergeFlushed = true try { - await toolCtx.engine.finalizeContentrain(turnMerge.pendingFinalize) + const finalized = await toolCtx.engine.finalizeContentrain(turnMerge.pendingFinalize) + if (finalized.mainAdvance === 'blocked_diverged') { + // Not an error — the content is on contentrain and a PR now carries + // the advance. Logged because divergence never heals on its own and + // this may be the first place it becomes visible. + // eslint-disable-next-line no-console + console.warn(`[conversation] main advance blocked — contentrain/main diverged; PR: ${finalized.pullRequestUrl ?? 'already open'}`) + } } catch (e) { - // Best-effort, same contract as per-save regen: context.json and - // the main advance self-heal on the next merge. + // finalize turns divergence into a PR itself, so what reaches this + // catch is transient (network, rate limit) — the next merge retries it. // eslint-disable-next-line no-console - console.warn('[conversation] turn-end finalize failed (self-heals on next merge):', e instanceof Error ? e.message : e) + console.warn('[conversation] turn-end finalize failed (transient — retried on next merge):', e instanceof Error ? e.message : e) } } diff --git a/tests/nuxt/composables/use-branches.nuxt.test.ts b/tests/nuxt/composables/use-branches.nuxt.test.ts index 188fc69b..2c8e6114 100644 --- a/tests/nuxt/composables/use-branches.nuxt.test.ts +++ b/tests/nuxt/composables/use-branches.nuxt.test.ts @@ -4,16 +4,19 @@ import { useBranches } from '../../../app/composables/useBranches' const success = vi.fn() const error = vi.fn() +const warning = vi.fn() mockNuxtImport('useToast', () => () => ({ success, error, + warning, })) describe('useBranches', () => { beforeEach(() => { success.mockReset() error.mockReset() + warning.mockReset() useState('branches').value = [] useState('branches-loading').value = false useState('branch-diff').value = null @@ -49,10 +52,49 @@ describe('useBranches', () => { const merged = await store.mergeBranch('workspace-1', 'project-1', 'cr/content/faq/en/1234567890-abcd') expect(merged).toBe(true) - expect(success).toHaveBeenCalledWith('Branch merged: cr/content/faq/en/1234567890-abcd') + expect(success).toHaveBeenCalledWith('Change merged') expect(store.branches.value.map(branch => branch.name)).toEqual(['cr/content/blog/en/1234567890-efgh']) }) + it('tells the truth when the merge landed but main is blocked', async () => { + // The collabers incident, as the editor sees it: the content reached the + // content branch, main could not follow. That is a merged change with a + // pending publish — not a failure, and not silence. + vi.stubGlobal('$fetch', vi.fn().mockResolvedValue({ + merged: true, + mainAdvance: 'blocked_diverged', + pullRequestUrl: 'https://example.com/pr/7', + })) + useState('branches').value = [ + { name: 'cr/content/faq/en/1234567890-abcd', sha: 'sha-1', protected: false }, + ] + + const store = useBranches() + const merged = await store.mergeBranch('workspace-1', 'project-1', 'cr/content/faq/en/1234567890-abcd') + + // Merged from the editor's point of view: the branch leaves the list. + expect(merged).toBe(true) + expect(store.branches.value).toEqual([]) + // But the publish state is said out loud, as a warning — not a success. + expect(warning).toHaveBeenCalledTimes(1) + expect(success).not.toHaveBeenCalled() + }) + + it('reports a real conflict as an error, not a success', async () => { + vi.stubGlobal('$fetch', vi.fn().mockResolvedValue({ merged: false })) + useState('branches').value = [ + { name: 'cr/content/faq/en/1234567890-abcd', sha: 'sha-1', protected: false }, + ] + + const store = useBranches() + const merged = await store.mergeBranch('workspace-1', 'project-1', 'cr/content/faq/en/1234567890-abcd') + + expect(merged).toBe(false) + expect(error).toHaveBeenCalledTimes(1) + // An unmerged branch stays in the list — it still needs resolving. + expect(store.branches.value).toHaveLength(1) + }) + it('returns false and shows an error toast when merge fails', async () => { vi.stubGlobal('$fetch', vi.fn().mockRejectedValue(new Error('Merge failed on server'))) diff --git a/tests/unit/content-engine.test.ts b/tests/unit/content-engine.test.ts index 31f4300f..53c21a0e 100644 --- a/tests/unit/content-engine.test.ts +++ b/tests/unit/content-engine.test.ts @@ -431,6 +431,7 @@ describe('content engine', () => { merged: true, sha: 'merge-sha', pullRequestUrl: null, + mainAdvance: 'advanced', }) }) @@ -483,10 +484,14 @@ describe('content engine', () => { 'contentrain: advance content to main', 'Auto-generated by Contentrain Studio.', ) + // `merged: true` — step 1 landed the content on contentrain before the + // protected advance was attempted; the old `false` reported a landed save + // as a failed one. expect(result).toEqual({ - merged: false, + merged: true, sha: null, pullRequestUrl: 'https://example.com/pr/1', + mainAdvance: 'blocked_diverged', }) }) @@ -894,7 +899,7 @@ describe('mergeBranch split halves (W4)', () => { 'cr/content/posts/en/2222222222-bbbb', ]) - expect(result).toEqual({ merged: true, sha: 'main-sha', pullRequestUrl: null }) + expect(result).toEqual({ merged: true, sha: 'main-sha', pullRequestUrl: null, mainAdvance: 'advanced' }) // Exactly one context.json regen commit, derived from the LAST branch. const contextCommits = applyPlan.mock.calls.filter(([input]) => (input as { changes: Array<{ path: string }> }).changes.some(c => c.path === '.contentrain/context.json')) @@ -915,7 +920,138 @@ describe('mergeBranch split halves (W4)', () => { const result = await engine.finalizeContentrain(['cr/content/faq/en/1234567890-abcd']) - expect(result).toEqual({ merged: false, sha: null, pullRequestUrl: 'https://example.com/pr/9' }) + // `merged: true` — the content reached contentrain before the advance was + // ever attempted. The old shape said `merged: false` here, which reported + // a landed save as a failed one. + expect(result).toEqual({ merged: true, sha: null, pullRequestUrl: 'https://example.com/pr/9', mainAdvance: 'blocked_diverged' }) expect(git.createPR).toHaveBeenCalledWith('contentrain', 'main', 'contentrain: advance content to main', 'Auto-generated by Contentrain Studio.') }) + + it('turns a diverged advance into a PR instead of a thrown 409', async () => { + // The collabers incident: main carried out-of-Studio .contentrain changes, + // every contentrain → main merge answered 409, and the route surfaced it + // as "Server Error" — for a save that had already landed. + const conflict = Object.assign(new Error('Merge conflict - https://docs.github.com/rest'), { status: 409 }) + const git = createGitProvider({ + getDefaultBranch: vi.fn().mockResolvedValue('main'), + mergeBranch: vi.fn().mockRejectedValue(conflict), + applyPlan: vi.fn().mockResolvedValue(defaultCommit), + createPR: vi.fn().mockResolvedValue({ id: 'pr-12', url: 'https://example.com/pr/12' }), + }) + const engine = createContentEngine({ git, contentRoot: '' }) + + const result = await engine.finalizeContentrain(['cr/content/faq/en/1234567890-abcd']) + + expect(result).toEqual({ merged: true, sha: null, pullRequestUrl: 'https://example.com/pr/12', mainAdvance: 'blocked_diverged' }) + // The PR body names the divergence — it is the artifact a developer + // resolves, so it has to say what happened. + const body = (git.createPR as ReturnType).mock.calls[0]?.[3] as string + expect(body).toContain('diverged') + }) + + it('does not fail the merge when the fallback PR already exists', async () => { + // Every approve on a diverged repo reaches the PR fallback; GitHub answers + // 422 for the second one. That is bookkeeping, not a failed merge. + const conflict = Object.assign(new Error('Merge conflict'), { status: 409 }) + const git = createGitProvider({ + getDefaultBranch: vi.fn().mockResolvedValue('main'), + mergeBranch: vi.fn().mockRejectedValue(conflict), + applyPlan: vi.fn().mockResolvedValue(defaultCommit), + createPR: vi.fn().mockRejectedValue(new Error('Validation Failed: A pull request already exists for contentrain.')), + }) + const engine = createContentEngine({ git, contentRoot: '' }) + + const result = await engine.finalizeContentrain(['cr/content/faq/en/1234567890-abcd']) + + expect(result).toEqual({ merged: true, sha: null, pullRequestUrl: null, mainAdvance: 'blocked_diverged' }) + }) + + it('treats a second approve of an already-landed branch as success, not a 500', async () => { + // Retry chain from the incident: step 1 landed and deleted the cr/* + // branch, the advance failed, the user clicked Approve again — and the + // second click died on an unhandled "Head does not exist". + const git = createGitProvider({ + getDefaultBranch: vi.fn().mockResolvedValue('main'), + mergeBranch: vi.fn().mockImplementation(async (from: string) => { + if (from.startsWith('cr/')) throw Object.assign(new Error('Head does not exist'), { status: 404 }) + return { merged: true, sha: 'advance-sha', pullRequestUrl: null } + }), + applyPlan: vi.fn().mockResolvedValue(defaultCommit), + }) + const engine = createContentEngine({ git, contentRoot: '' }) + + const result = await engine.mergeBranch('cr/content/faq/en/1234567890-abcd') + + // The content is already on contentrain; the retry finishes the half that + // actually failed — the advance. + expect(result).toMatchObject({ merged: true, mainAdvance: 'advanced' }) + expect(git.mergeBranch).toHaveBeenCalledWith('contentrain', 'main') + }) + + it('reports a real cr-vs-contentrain conflict as unmerged, without touching main', async () => { + const git = createGitProvider({ + getDefaultBranch: vi.fn().mockResolvedValue('main'), + mergeBranch: vi.fn().mockRejectedValue(Object.assign(new Error('Merge conflict'), { status: 409 })), + applyPlan: vi.fn().mockResolvedValue(defaultCommit), + }) + const engine = createContentEngine({ git, contentRoot: '' }) + + const result = await engine.mergeBranch('cr/content/faq/en/1234567890-abcd') + + expect(result).toEqual({ merged: false, sha: null, pullRequestUrl: null }) + expect(git.mergeBranch).toHaveBeenCalledTimes(1) + expect(git.createPR).not.toHaveBeenCalled() + }) + + it('logs — and survives — a diverged main → contentrain sync instead of swallowing it', async () => { + // The silent catch that made collabers invisible: the sync conflict was + // eaten under a comment claiming the branches held different directories. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // This test drives a real saveContent, so it needs the path resolvers the + // rest of this describe's merge-only tests never touch. + vi.stubGlobal('resolveModelPath', resolveModelPath) + vi.stubGlobal('resolveContentPath', resolveContentPath) + vi.stubGlobal('resolveMetaPath', resolveMetaPath) + vi.stubGlobal('resolveConfigPath', resolveConfigPath) + vi.stubGlobal('resolveVocabularyPath', resolveVocabularyPath) + const model = { + id: 'faq', + kind: 'collection', + i18n: true, + domain: 'marketing', + title_field: 'question', + fields: { question: { type: 'string', required: true } }, + } + const config = { domains: ['marketing'], locales: { default: 'en', supported: ['en'] }, stack: 'astro', version: 1, workflow: 'auto-merge' } + const applyPlan = vi.fn().mockResolvedValue(defaultCommit) + const git = createGitProvider({ + readFile: vi.fn(async (path: string) => { + if (path.includes('/models/faq')) return JSON.stringify(model) + if (path.endsWith('config.json')) return JSON.stringify(config) + throw new Error(`not found: ${path}`) + }), + mergeBranch: vi.fn().mockImplementation(async (from: string, into: string) => { + if (into === 'contentrain') throw Object.assign(new Error('Merge conflict'), { status: 409 }) + return { merged: true, sha: 'x', pullRequestUrl: null } + }), + applyPlan, + }) + const engine = createContentEngine({ git, contentRoot: '', projectId: 'project-x' }) + + const result = await engine.saveContent( + 'faq', + 'en', + { 'faq-1': { question: 'Does a diverged repo block saves?' } }, + 'user@example.com', + { autoPublish: true }, + ) + + // The write proceeds — policy (b): the editor is never blocked by a + // divergence a developer has to resolve. + expect(result.validation.valid).toBe(true) + expect(applyPlan).toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('diverged')) + expect(warn.mock.calls[0]?.[0]).toContain('project-x') + warn.mockRestore() + }) })