Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .contentrain/content/system/ui-strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 17 additions & 4 deletions app/composables/useBranches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,21 +68,34 @@ export function useBranches() {
}

async function mergeBranch(workspaceId: string, projectId: string, branch: string): Promise<boolean> {
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
}
Expand Down
104 changes: 91 additions & 13 deletions server/utils/content-engine/branch-ops.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -58,15 +58,43 @@
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(

Check warning on line 70 in server/utils/content-engine/branch-ops.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected console statement
`[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.
}
}

ensured = true
}
}

/**
* 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).
*/
Expand Down Expand Up @@ -112,7 +140,7 @@
export async function finalizeContentrain(
ctx: EngineInternalContext,
mergedBranches: string[],
): Promise<MergeResult> {
): Promise<EngineMergeResult> {
const lastBranch = mergedBranches.at(-1)
if (lastBranch) {
// Regenerate context.json on contentrain now that the content has
Expand All @@ -124,21 +152,49 @@
// 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)

Check warning on line 193 in server/utils/content-engine/branch-ops.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected console statement
}
}

return { merged: true, sha: null, pullRequestUrl, mainAdvance: 'blocked_diverged' }
}
}

Expand All @@ -152,8 +208,30 @@
* 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<MergeResult> {
const step1 = await mergeToContentrain(ctx, branch)
export async function mergeBranch(ctx: EngineInternalContext, branch: string): Promise<EngineMergeResult> {
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 }
}
Expand Down
25 changes: 25 additions & 0 deletions server/utils/content-engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions server/utils/conversation-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
44 changes: 43 additions & 1 deletion tests/nuxt/composables/use-branches.nuxt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')))

Expand Down
Loading
Loading