From 61dcd1a753ad9c54beaa565467c670fca765f654 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Fri, 12 Jun 2026 13:24:33 +0300 Subject: [PATCH 1/2] fix(mcp): stop committing context.json to remote feature branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commitThroughProvider bundled a freshly built context.json into every remote (GitHub/GitLab) feature-branch commit. The file embeds new Date() timestamps, so two parallel cr/* branches forked from the same contentrain commit always diverged on it: after the first merge regenerated context on contentrain, the second branch's merge hit a permanent context.json conflict and stayed pending forever — silent content loss in auto-merge orchestrations (MCP Cloud). Remote commits now carry only the plan's own changes, mirroring the local transaction flow where feature branches intentionally never include context.json. The orchestrator that owns the merge regenerates context on the contentrain branch post-merge (buildContextChange is exported from @contentrain/mcp/core/context for that). Tests: http transport remote-commit specs now assert context.json is absent from GitHub tree / GitLab actions payloads for content_save, content_delete and model_save. --- .../remote-no-context-on-feature-branch.md | 9 +++++ packages/mcp/README.md | 2 +- packages/mcp/src/tools/commit-plan.ts | 36 +++++++++---------- packages/mcp/tests/server/http.test.ts | 16 +++++---- 4 files changed, 37 insertions(+), 26 deletions(-) create mode 100644 .changeset/remote-no-context-on-feature-branch.md diff --git a/.changeset/remote-no-context-on-feature-branch.md b/.changeset/remote-no-context-on-feature-branch.md new file mode 100644 index 0000000..407acab --- /dev/null +++ b/.changeset/remote-no-context-on-feature-branch.md @@ -0,0 +1,9 @@ +--- +"@contentrain/mcp": patch +--- + +Remote write path no longer commits `.contentrain/context.json` to feature branches + +`commitThroughProvider` (used by content/model save/delete over GitHub and GitLab providers) bundled a freshly built `context.json` into every feature-branch commit. Because the file embeds `new Date()` timestamps, two parallel `cr/*` branches forked from the same `contentrain` commit always diverged on it — after the first branch merged and context was regenerated on `contentrain`, the second branch's merge hit a permanent conflict on `context.json` and stayed pending forever (silent content loss in auto-merge setups). + +Remote commits now carry only the plan's own changes, matching the local transaction flow where feature branches intentionally never include `context.json`. The orchestrator that owns the merge (e.g. Studio) is responsible for regenerating `context.json` on the `contentrain` branch post-merge — `buildContextChange` is exported from `@contentrain/mcp/core/context` for that purpose. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 232e822..780865a 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -64,7 +64,7 @@ All write operations are designed around git-backed safety: - auto-merge: feature merges into `contentrain`, baseBranch advanced via update-ref, `.contentrain/` files selectively synced to developer's working tree - review: feature branch pushed to remote for team review - developer's working tree is never mutated during MCP git operations (no stash, no checkout, no merge) -- context.json is committed together with content changes, not as a separate commit +- context.json never lands on feature branches — it is regenerated on the `contentrain` branch after merge (locally by the transaction layer; in remote flows by the orchestrator that owns the merge) - canonical JSON output — sorted keys, 2-space indent, trailing newline - validation + next-step hints surfaced to the caller diff --git a/packages/mcp/src/tools/commit-plan.ts b/packages/mcp/src/tools/commit-plan.ts index 42deede..b932883 100644 --- a/packages/mcp/src/tools/commit-plan.ts +++ b/packages/mcp/src/tools/commit-plan.ts @@ -1,15 +1,15 @@ import { CONTENTRAIN_BRANCH } from '@contentrain/types' import type { FileChange } from '../core/contracts/index.js' -import { buildContextChange } from '../core/context.js' -import { OverlayReader } from '../core/overlay-reader.js' import { LocalProvider } from '../providers/local/index.js' import type { ToolProvider } from '../server.js' /** - * Context payload written into `.contentrain/context.json` as part of the - * same commit. Kept as a loose object so tool-specific payloads can add - * optional fields (`locale`, `entries`) without churning the helper's - * signature. + * Context payload describing the operation, threaded into the local + * transaction so it can regenerate `.contentrain/context.json` on the + * contentrain branch after merge. Kept as a loose object so tool-specific + * payloads can add optional fields (`locale`, `entries`) without churning + * the helper's signature. Remote providers ignore it — see + * `commitThroughProvider` below. */ export interface CommitContextPayload { tool: string @@ -43,11 +43,17 @@ export interface CommitThroughProviderResult { * the project's configured workflow. Selective-sync result is surfaced * to the caller via `sync`. * - * - **Any other RepoProvider** — the context.json write becomes an extra - * `FileChange` bundled into the plan so the whole commit lands - * atomically through the generic `RepoWriter.applyPlan`. Remote flows + * - **Any other RepoProvider** — only the plan's own changes are + * committed. Feature branches NEVER carry `.contentrain/context.json`: + * the file embeds timestamps, so two parallel cr/* branches forked from + * the same contentrain commit would always conflict on it and the + * second merge would fail permanently. This mirrors the local + * transaction flow, which regenerates context.json on the contentrain + * branch after merge (single-threaded, deterministic). Remote flows * always report `pending-review`; Studio (or whatever orchestrator is - * driving the server) owns the merge. + * driving the server) owns the merge AND the post-merge context + * regeneration on the contentrain branch (`buildContextChange` from + * `@contentrain/mcp/core/context` is exported for exactly that). * * The return shape is deliberately uniform so callers don't have to * branch on provider type again. @@ -72,15 +78,7 @@ export async function commitThroughProvider( } } - // Build context.json against an overlay of the pending FileChanges so - // `stats.entries` / `stats.models` reflect the state *after* this - // commit lands, not the pre-change base branch. Without the overlay - // the committed context.json would be stale — a new entry added in - // this commit would not appear in the entry count until the next - // write. - const overlay = new OverlayReader(provider, changes) - const contextChange = await buildContextChange(overlay, contextPayload) - const allChanges = [...changes, contextChange] + const allChanges = changes .toSorted((a, b) => a.path.localeCompare(b.path)) // Feature branches ALWAYS fork from the `contentrain` branch — that's // the single source of truth the local transaction flow enforces, and diff --git a/packages/mcp/tests/server/http.test.ts b/packages/mcp/tests/server/http.test.ts index e459b25..7a50f42 100644 --- a/packages/mcp/tests/server/http.test.ts +++ b/packages/mcp/tests/server/http.test.ts @@ -263,12 +263,15 @@ describe('startHttpMcpServer', () => { expect(git['commit']).toBe('new-commit-sha') // Commit is addressed to the feature branch, not the base. expect((git['branch'] as string).startsWith('cr/content/blog')).toBe(true) - // The tree payload contains our content blob + meta blob + context.json blob. + // The tree payload contains our content blob + meta blob. Feature + // branches never carry context.json — it embeds timestamps, so + // parallel cr/* branches would permanently conflict on it. The + // orchestrator regenerates it on contentrain after merge. const tree = (capturedTree as { tree: Array<{ path: string }> }).tree const paths = tree.map(t => t.path) expect(paths).toContain('.contentrain/content/marketing/blog/en.json') expect(paths).toContain('.contentrain/meta/blog/en.json') - expect(paths).toContain('.contentrain/context.json') + expect(paths).not.toContain('.contentrain/context.json') // Commit parent resolved from contentrain branch (branchExists: false, // so we end up creating a new ref). expect((capturedCommit as { parents: string[] }).parents).toEqual(['base-sha']) @@ -378,13 +381,14 @@ describe('startHttpMcpServer', () => { expect(git['commit']).toBe('gitlab-commit-sha') expect((git['branch'] as string).startsWith('cr/content/blog')).toBe(true) - // One Commits.create call with content + meta + context.json actions. + // One Commits.create call with content + meta actions — no + // context.json on feature branches. expect(capturedCommitsCall).toBeDefined() const actions = capturedCommitsCall!.actions as Array<{ filePath: string, action: string }> const paths = actions.map(a => a.filePath) expect(paths).toContain('.contentrain/content/marketing/blog/en.json') expect(paths).toContain('.contentrain/meta/blog/en.json') - expect(paths).toContain('.contentrain/context.json') + expect(paths).not.toContain('.contentrain/context.json') // startBranch is used because the feature branch does not exist yet. expect(capturedCommitsCall!.options.startBranch).toBe('contentrain') } finally { @@ -448,7 +452,7 @@ describe('startHttpMcpServer', () => { const tree = fixture.capturedTree()!.tree const paths = tree.map(t => t.path) - expect(paths).toContain('.contentrain/context.json') + expect(paths).not.toContain('.contentrain/context.json') // The content entry is removed from the object-map; the file is // rewritten (not deleted) because other entries may still live // there in real flows. The meta file loses the corresponding @@ -505,7 +509,7 @@ describe('startHttpMcpServer', () => { const tree = fixture.capturedTree()!.tree const paths = tree.map(t => t.path) expect(paths).toContain('.contentrain/models/hero.json') - expect(paths).toContain('.contentrain/context.json') + expect(paths).not.toContain('.contentrain/context.json') } finally { await mcpClient.close() } From 34c9cf12aaebda6b623c7ff7623785a6baa51cea Mon Sep 17 00:00:00 2001 From: Contentrain Date: Fri, 12 Jun 2026 13:24:41 +0300 Subject: [PATCH 2/2] docs(rules,skills): context.json is never committed on feature branches Rules and skills docs still described the pre-1.x behavior ('context.json is committed together with content changes, not as a separate commit'). The transaction flow regenerates context.json on the contentrain branch after merge; feature branches never carry it. Updated workflow-rules, mcp-usage, contentrain-essentials, context-bridge and the contentrain skill references to state the current contract. --- .changeset/docs-context-feature-branch.md | 8 ++++++++ packages/rules/context/context-bridge.md | 4 ++-- packages/rules/essential/contentrain-essentials.md | 2 +- packages/rules/shared/mcp-usage.md | 2 +- packages/rules/shared/workflow-rules.md | 2 +- .../skills/skills/contentrain/references/mcp-pipelines.md | 2 +- packages/skills/skills/contentrain/references/workflow.md | 2 +- 7 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 .changeset/docs-context-feature-branch.md diff --git a/.changeset/docs-context-feature-branch.md b/.changeset/docs-context-feature-branch.md new file mode 100644 index 0000000..11ab0fe --- /dev/null +++ b/.changeset/docs-context-feature-branch.md @@ -0,0 +1,8 @@ +--- +"@contentrain/rules": patch +"@contentrain/skills": patch +--- + +Fix stale context.json documentation: the file is never committed on feature branches + +Rules and skills docs still described the pre-1.x behavior ("context.json is committed together with content changes"). Since the dedicated-branch transaction flow landed, context.json is regenerated on the `contentrain` branch after merge and feature branches never carry it — parallel writes therefore cannot conflict on it. Updated workflow-rules, mcp-usage, contentrain-essentials, context-bridge, and the contentrain skill references to state the current contract. diff --git a/packages/rules/context/context-bridge.md b/packages/rules/context/context-bridge.md index 3322cc6..bd6e9b3 100644 --- a/packages/rules/context/context-bridge.md +++ b/packages/rules/context/context-bridge.md @@ -4,7 +4,7 @@ `context.json` is the project intelligence file. It provides AI agents and Contentrain Studio with structured knowledge about the project: its tech stack, content patterns, conventions, and normalize configuration. -- **Written by:** MCP tools (committed together with content changes in the same branch, not as a separate commit) +- **Written by:** MCP tools (regenerated on the `contentrain` branch after each merge — feature branches never carry it, so parallel writes cannot conflict on it) - **Read by:** AI agents (at the start of every session) and Studio (for UI configuration) - **Location:** `.contentrain/context.json` @@ -206,7 +206,7 @@ When creating `context.json` for the first time, use stack-appropriate defaults. ## Update Behavior -MCP tools update `context.json` as part of each write operation, committed together with the content changes in the same branch (not as a separate commit): +MCP tools update `context.json` after each write operation lands. The file is never committed on `cr/*` feature branches (its timestamps would make parallel branches conflict); it is regenerated deterministically on the `contentrain` branch after merge: - `contentrain_init` creates the initial file - `contentrain_model_save` updates the domains list if a new domain is used - `contentrain_content_save` updates content stats diff --git a/packages/rules/essential/contentrain-essentials.md b/packages/rules/essential/contentrain-essentials.md index be72fed..448a7b6 100644 --- a/packages/rules/essential/contentrain-essentials.md +++ b/packages/rules/essential/contentrain-essentials.md @@ -77,7 +77,7 @@ MCP is **deterministic infrastructure**. The agent (you) is the **intelligence l - Developer's working tree is never mutated during MCP git operations (no stash, no checkout, no merge on the developer's tree) - If the developer manually edits `.contentrain/` files, MCP sync skips dirty files and warns - The `contentrain` branch is protected from deletion -- context.json is committed together with content changes, not as a separate commit +- context.json is never committed on feature branches — it is regenerated on the `contentrain` branch after merge - Never create branches manually, never commit directly to main or the `contentrain` branch - 50+ active `cr/*` branches = warning, 80+ = blocked diff --git a/packages/rules/shared/mcp-usage.md b/packages/rules/shared/mcp-usage.md index f73955a..316ccfe 100644 --- a/packages/rules/shared/mcp-usage.md +++ b/packages/rules/shared/mcp-usage.md @@ -297,7 +297,7 @@ contentrain_apply(mode: "reuse", scope: {model: "model-id"}, dry_run: true) (pre - Branch naming: `cr/{operation}/{model}[/{locale}]/{timestamp}-{suffix}` (locale included when applicable; legacy `contentrain/*` branches are auto-migrated on first init). - You do not create branches manually. MCP handles Git transactions. - Developer's working tree is never mutated during MCP operations (no stash, no checkout, no merge on the developer's tree). -- context.json is committed together with content changes, not as a separate commit. +- context.json is never committed on feature branches — it is regenerated on the `contentrain` branch after merge. - In `auto-merge` mode: feature branch is merged into `contentrain`, then baseBranch is advanced via update-ref (fast-forward), then `.contentrain/` files are selectively synced to the developer's working tree. Dirty files are skipped with a warning. - In `review` mode: feature branch stays local until `contentrain_submit` pushes it to remote. diff --git a/packages/rules/shared/workflow-rules.md b/packages/rules/shared/workflow-rules.md index 0cfd1f1..5d44a36 100644 --- a/packages/rules/shared/workflow-rules.md +++ b/packages/rules/shared/workflow-rules.md @@ -75,7 +75,7 @@ The `contentrain` branch is the **single source of truth** for content state: - Created automatically at `contentrain_init`. - All content writes happen on feature branches forked from `contentrain`. - The `contentrain` branch is **protected from deletion**. -- context.json is committed together with content changes, not as a separate commit. +- context.json is never committed on feature branches — it is regenerated on the `contentrain` branch after merge. ### 3.2 Worktree-Based Transactions diff --git a/packages/skills/skills/contentrain/references/mcp-pipelines.md b/packages/skills/skills/contentrain/references/mcp-pipelines.md index 6c04b2a..3ba5b19 100644 --- a/packages/skills/skills/contentrain/references/mcp-pipelines.md +++ b/packages/skills/skills/contentrain/references/mcp-pipelines.md @@ -150,7 +150,7 @@ contentrain_submit # Push (always review mode) - Branch naming: `cr/{operation}/{model}/{timestamp}` (locale included when applicable) - Do not create branches manually. MCP handles Git transactions - Developer's working tree is never mutated during MCP operations (no stash, no checkout, no merge on the developer's tree) -- context.json is committed together with content changes, not as a separate commit +- context.json is never committed on feature branches — it is regenerated on the `contentrain` branch after merge - `auto-merge` mode: feature branch merged into `contentrain`, baseBranch advanced via update-ref, `.contentrain/` files selectively synced to developer's working tree (dirty files skipped with warning) - `review` mode: feature branch pushed to remote by `contentrain_submit` diff --git a/packages/skills/skills/contentrain/references/workflow.md b/packages/skills/skills/contentrain/references/workflow.md index b5931a7..ddfaec7 100644 --- a/packages/skills/skills/contentrain/references/workflow.md +++ b/packages/skills/skills/contentrain/references/workflow.md @@ -79,7 +79,7 @@ The `contentrain` branch is the **single source of truth** for content state: - Created automatically at `contentrain_init`. - All content writes happen on feature branches forked from `contentrain`. - The `contentrain` branch is **protected from deletion**. -- context.json is committed together with content changes, not as a separate commit. +- context.json is never committed on feature branches — it is regenerated on the `contentrain` branch after merge. ### 3.2 Worktree-Based Transactions