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
8 changes: 8 additions & 0 deletions .changeset/docs-context-feature-branch.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .changeset/remote-no-context-on-feature-branch.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 17 additions & 19 deletions packages/mcp/src/tools/commit-plan.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
16 changes: 10 additions & 6 deletions packages/mcp/tests/server/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
4 changes: 2 additions & 2 deletions packages/rules/context/context-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/rules/essential/contentrain-essentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/rules/shared/mcp-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion packages/rules/shared/workflow-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion packages/skills/skills/contentrain/references/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading