Reconcile: divergence becomes a product state with an exit (R0–R5) - #118
Merged
Conversation
BaseAdvance ('advanced' | 'blocked_diverged') and RemotePush name what
happens to the base branch and the remote after a write — shared words
with Studio's approve contract, where a PR is an attachment, never a
third state. Commit carries them as optional fields the same way it
already carries workflowAction/sync/warning.
ConflictItem/ConflictCode/ConflictResolution define the surface of the
content-aware three-way reconcile: code is a CLOSED union (consumers key
localized editor questions on it — new value = minor + changelog line),
and id hashes position AND values (conflictId over stableHash, a
dependency-free FNV-1a 64), so a resolution made against a stale dry-run
stops matching the moment any side changes — compare-and-set for free.
RepoProvider gains two OPTIONAL members, getMergeBase and
createMergeCommit, plus the optional capabilities.mergeCommit flag —
optional precisely so external 'implements RepoProvider' code keeps
compiling and this stays a minor. LOCAL_CAPABILITIES declares
mergeCommit: true.
Tests: stableHash determinism/unicode; conflictId stability, the
compare-and-set property, key-order normalization, position sensitivity.
Field report (collabers, 14 Aug): with contentrain and main diverged, every approve died in a 409 loop while the content sat safely on contentrain — the product reported failure for work that had landed. The local flow had the same shape in four places, all fixed here. transaction.ts: the fast-forward guard no longer throws. By the time it runs, the feature branch IS merged into contentrain; only the base advance is impossible. complete() and mergeBranch() now return base_advance: 'advanced' | 'blocked_diverged' (shared vocabulary with Studio's approve contract) plus remote_push: 'pushed' | 'rejected' | 'no-remote' — the four silent push-failure sites previously swallowed a non-fast-forward rejection while the tool reported auto-merged. mergeBranch also gains the fetch + base-sync pre-step createTransaction already had: without it, one clean base commit made every subsequent approve fail permanently. selectiveSync runs only after a real advance — syncing files when the developer's HEAD did not move would desync their working tree. Fetch/push now go through networkGit (GIT_TERMINAL_PROMPT=0 + block timeout), so a hung credential prompt cannot hang an MCP call. The truth travels the whole chain: LocalApplyResult and CommitThroughProviderResult carry base_advance/remote_push/warning (the warning was previously dropped in commitThroughProvider — review-mode push failures never reached any tool response), a shared gitReport() builds the git object in all nine write tools instead of nine hand-rolled literals, and divergenceNextSteps() names the exit path. mergeBranch now prunes the cr/* branch on a blocked advance too — the phantom 'pending review' rows in serve UI came from that skipped delete. serve: approve endpoints return the new fields and use normalizeOperationError, so real failures reach the UI with code and agent_hint instead of a bare message (git/errors is now an exported subpath). The UI shows the partial-success warning and renders the server's error text instead of a generic string. status (CLI + MCP): contentBranchRelation counts BOTH directions. The one-directional 'ahead' count printed 'in sync' precisely in the diverged state that blocks every advance. JSON gains behind/relation; human output distinguishes diverged / content_ahead / base_ahead. Tests: diverged-base partial success for complete() and mergeBranch (content on contentrain, base untouched, branch pruned), the clean base-advance self-heal that used to kill approves, and the three remote_push outcomes against a bare remote.
The reconcile planner reads three trees side by side — merge-base, contentrain, base branch — through plain RepoReaders that are pinned to one ref each, so the planner never sees a ref at all. GitRefReader serves a RepoReader over any local ref with no checkout (git show / ls-tree / cat-file -e); the per-call ref? parameter is deliberately ignored — a bound reader answering for another ref would be a lie, bind another instance. bindRef() is the ~15-line adapter for readers that DO honour the per-call ref (GitHub, GitLab, memory). MemoryProvider records each branch's fork snapshot — the information a commit DAG encodes in parent pointers and a tree-as-Map cannot — which makes getMergeBase answerable in memory: the fork tree is registered as a synthetic membase/<branch> ref that readers bind to like any other. createMergeCommit applies changes on ours' tree and records parents: [ours, theirs]. Both mirror the new optional RepoProvider members, so the whole reconcile flow can run with zero subprocesses. Tests: reads/listings/existence against two refs while the working tree stays on main; bindRef pinning against MemoryProvider.
core/ops/reconcile/: pure planning over three ref-bound RepoReaders (merge-base / contentrain / main). Everything the policy table can decide is merged mechanically; what it cannot becomes ConflictItem[], and the plan is only applied once those carry resolutions. The three-way rule reduces every policy row to one primitive (mergeLeaf3): an unchanged side has no vote, identical changes converge, a lone change wins, two different changes are a question — with delete-vs-edit split out because it asks 'keep or drop?' instead of 'which value?'. On top of that: collections merge entry-then-field-wise, dictionaries key-wise (the three-way generalization of 3.0.0's inline upsert — with no author present, two values are a question, not a pick), vocabulary at term+locale granularity (why the collabers scenario needs zero human input), documents merge frontmatter key-wise with the body as a single never-text-merged leaf and a round-trip guard that falls back to whole-file resolution when nested frontmatter would not survive a rewrite, models key-wise with suggested: 'theirs' carried as advice, and meta arbitrates updated_at/updated_by mechanically while a two-sided status change stays a human question. Structure over guessing: each side's files are enumerated with THAT side's model definition (content_path/locale_strategy may differ across sides), the output lands at the winning definition's path, and file shapes are never sniffed — dispatch always comes from the winning kind. A model deleted on one side while the other edited its definition or content collapses into ONE model-level question instead of a conflict per file; a clean deletion cascades mechanically. Structural key conflicts (kind/i18n/locale_strategy/content_path) block that model's content phase outright. N1 lands as ResolutionIndex: conflict ids hash position AND values, so a decision made against a stale dry-run stops matching, is dropped with an advisory, and the conflict reappears with a fresh id. Unrecognized files under the scanned roots are never authored by the planner: theirs-only changes are carried (so tree-building executors reproduce what git's merge machinery would), both-sided changes become value-free file_conflicts resolvable only by choosing a side. Changes are emitted only when the merged value differs from ours parsed (reconcile is not a formatter), context.json is regenerated through the existing buildContextChange whenever the plan is not a no-op, and the merged state is validated through OverlayReader as advisories that never block. Tests: 38 cases in tests/core/ops/reconcile/ — every policy row, the collabers golden scenario (models theirs-only + content ours-only + vocabulary split by locale ⇒ zero conflicts), determinism, the total no-op, and the N1 stale-resolution drop — all on hand-rolled readers with zero subprocesses.
…ent commit git/reconcile.ts: reconcileBranches plans with three GitRefReaders (no checkout, developer tree untouched), then executes a REAL git merge --no-commit in a temp worktree and writes the ENTIRE plan output over it before committing. That ordering is the N2 contract: for content-owned paths the planner is authoritative — git's line-level auto-merge can produce non-canonical or editorially wrong JSON without ever surfacing as a conflict — while git still resolves everything the planner does not own (source files) exactly as a manual merge would. Paths left unmerged after the overwrite are by construction outside the planner's scope and abort with RECONCILE_SOURCE_CONFLICT rather than guessing. The commit joins both parents, so the base branch is an ancestor again and the ordinary fast-forward advance (update-ref + selectiveSync + push, all reused from the transaction layer) just works. A merely-behind base fast-forwards without inventing a merge; unrelated histories refuse with RECONCILE_NO_MERGE_BASE. Dry-run is the default and touches nothing. Provider parity for orchestrators (Studio consumes the core op with its own provider): LocalProvider and GitHubProvider implement getMergeBase + createMergeCommit — GitHub via compare API + createCommit with parents: [ours, theirs] on a tree layered over ours (the caller composes the complete tree delta; theirs-only source changes must ride along or the merge tree would silently revert them — said out loud in the JSDoc). Both verify ours is still the branch tip (RECONCILE_STALE_OURS), the executor-level twin of N1's compare-and-set. GitLab gets getMergeBase only: its Commits API cannot express two parents, so mergeCommit stays absent and callers fall back to a merge-request flow. Tests: real-git divergence modeled on the collabers incident (content edits on contentrain × model migration + source change on main) — dry-run leaves every ref alone, apply produces a two-parent merge commit with editor entries + migrated model + intact source, second run reports in_sync, an undecidable edit conflicts without writing and applies cleanly with a choose resolution, and a merely-behind base fast-forwards.
The exit path the guard's refusal never offered. contentrain_reconcile is preview-first (dry_run defaults true, mirroring contentrain_apply): the preview reports the plan summary, the changes, and the conflicts; dry_run:false executes as a two-parent merge commit and fast-forwards the base branch. Conflicts come back as a valid result, never isError — each one is a content decision the agent takes to the editor and answers via resolutions. Local-only (projectRoot + localWorktree in availability.ts); Studio consumes planReconcile directly instead. contentrain reconcile (CLI) is a presenter over the tool through the in-memory MCP session: dry-run → plan summary → an ours/theirs select per conflict (with the policy suggestion marked) → execute with the collected resolutions. --yes executes a CLEAN plan without prompting — conflicts still stop it, a content decision is never defaulted. --json mirrors the dry-run verbatim. A 'decide later' choice aborts without writing. Every blocked_diverged surface now names the exit: divergenceNextSteps, both transaction warnings, and contentrain merge's new partial-success warnings point at contentrain_reconcile / npx contentrain reconcile. The merge command also stops discarding structured error fields — code, agent_hint and developer_action now reach the terminal. Parity chain: rules MCP_TOOLS (27 tools: 22 core + 5 media) + count test, essentials gains the tool row and the dual-domain divergence bullets, skills mcp-tools.md gains the full tool section (and the merge row documents base_advance/remote_push), SKILL.md catalog + package table updated, plugin payload regenerated. Tests: tool surface e2e on a real inited project (in_sync, preview, execute joining both histories); CLI command unit tests (in_sync stop, --yes clean execute, interactive resolutions, skip aborts, --json).
… workflow Divergence had no page anywhere — the word did not appear in the docs site while it was the state that killed every approve. Now it does, as a product state with an exit: - concepts.md gains a Divergence section under Git Workflow (why the invariant breaks — the dual-domain migration — what blocked_diverged means, and how reconcile restores the fast-forward); the tool grouping and counts move to 27 (22 core + 5 media) - reference/providers.md documents the optional getMergeBase / createMergeCommit pair and the mergeCommit capability in all four places the contract is restated (interface block, meanings table, provider matrix, LOCAL_CAPABILITIES copy) - packages/mcp.md: tool catalog rows, the partial-success paragraph in Git-Native Workflow, and the core-exports list (planReconcile/bindRef, git/errors, git/reconcile) - packages/types.md: BaseAdvance/RemotePush/ConflictItem/ConflictCode/ ConflictResolution rows and the updated provider contract summary - packages/cli.md + cli/README.md: the reconcile command (dry-run plan, interactive decisions, --yes never defaults a conflict) in the command table, flag matrix and review-workflow prose; merge documents its blocked_diverged partial success - root README: stale '24 tools' diagram fixed, quick reference gains reconcile; getting-started's stale 'All 24' fixed - rules/shared/workflow-rules.md and the contentrain skill's workflow reference gain §4.4 — the divergence rules and the official dual-domain migration recipe (isolated branch → PR to base → reconcile right after) - two stale legacy 'contentrain/*' branch references in skills corrected to cr/*; plugin payload regenerated .internal handoff gains Appendix C — Studio's consumption contract for planReconcile (ref-bound readers, the full-tree-delta requirement for createMergeCommit, the two CAS layers, the closed ConflictCode set, and the one open policy question: config.json awaits Studio's ack). Changeset: one entry, three minors (types / mcp / cli), telling the whole story release-side.
✅ Deploy Preview for contentrain-ai ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the approved
AI-REPO-RECONCILEdesign (analysis in.internal/refactor/tasks/AI-REPO-RECONCILE-YANIT.md, Studio approval with change requests N1/N2/N3). Field origin: the collabers incident — a divergedcontentrain↔mainpair put every Studio approve into a 409/500 loop while the content sat safely oncontentrain.One branch instead of the three PRs sketched in the plan: the phases build on each other (R0's
BaseAdvancevocabulary is R2's output contract), so they ship as seven sequential commits that each pass the full gates, reviewable commit-by-commit.What changed, per phase
feat(types)— divergence + reconcile vocabulary:BaseAdvance/RemotePush(N3: values identical to Studio's approve contract; a PR is an attachment, never a third state),ConflictItemwith a CLOSEDcodeunion and value-hashingconflictId(N1 compare-and-set), optionalgetMergeBase?/createMergeCommit?/mergeCommit?provider members — optional precisely so externalimplementscode keeps compiling and types stays a minor.fix(mcp,cli)— a diverged base is a partial success, not an error: the guard no longer throws after the content already landed oncontentrain; writes and merges reportbase_advance/remote_push(four silent push-rejection sites now speak);mergeBranchgains the fetch + base-sync pre-step so one clean base commit stops killing every approve;statuscounts both directions instead of reporting the diverged state as "in sync"; serve approve + UI carry the truth; transaction network calls hardened.feat(mcp)×3 — ref-bound readers (GitRefReader,bindRef, MemoryProvider fork points);planReconcile, the content-aware three-way planner (entry-, key-, term+locale-level mechanics; one-model-level question for delete-vs-edit; bodies never text-merged; 38 in-memory tests including the collabers golden scenario resolving with zero conflicts); the executors — local worktree merge that writes the ENTIRE plan over git's textual result (N2: the planner is authoritative for content paths) and commits with two parents, GitHubcreateMergeCommitvia the Git Data API, GitLab capability-absent with MR fallback.feat(mcp,cli)— the product doors:contentrain_reconcile(dry_run-first; conflicts are a valid result, neverisError) andcontentrain reconcile(interactive ours/theirs;--yesnever defaults a conflict). Tool count 27 (22 core + 5 media) through the whole parity chain.docs— divergence reaches every surface that documents the workflow (it previously appeared nowhere); §4.4 dual-domain migration recipe in rules + skills; Studio consumption contract in the internal handoff; one changeset, three minors.Verification
pnpm lint— 0 warnings, 0 errors;tsc --noEmitclean in every package (+ serve-uivue-tsc)pnpm build && pnpm test— full suite green in one run (mcp 38 planner + 5 executor + 2 tool tests new; cli 171 incl. 5 new reconcile command tests; rules/skills parity suites enforce the new tool everywhere)in_syncOpen question (for the Studio session)
config.jsonis not in the approved policy table, so a both-sides change reports asfile_conflict. Proposal: a top-level key-based row in the next minor — detailed in02-studio-handoff.mdAppendix C, awaiting ack.🤖 Generated with Claude Code