diff --git a/.changeset/phase-14a-mcp-boundary-cli-commands.md b/.changeset/phase-14a-mcp-boundary-cli-commands.md new file mode 100644 index 0000000..4ad2412 --- /dev/null +++ b/.changeset/phase-14a-mcp-boundary-cli-commands.md @@ -0,0 +1,88 @@ +--- +"@contentrain/mcp": minor +"@contentrain/types": minor +"contentrain": minor +--- + +feat: MCP boundary hardening + CLI command polish + +Folds the P2 "MCP entrypoint owns a private provider contract" finding +into a single pass with CLI gap-filling — one cohesive PR because the +new CLI commands (`merge`, `describe`, `describe-format`, `scaffold`) +ride the very in-memory client helper that the boundary refactor +makes safe to commit to. + +### `@contentrain/types` — `MergeResult.sync` + +- `MergeResult` gains an optional `sync?: SyncResult` field. Remote + providers (GitHub, GitLab) omit it; `LocalProvider` populates it + so selective-sync bookkeeping survives the trip through the shared + `RepoProvider.mergeBranch()` boundary. + +### `@contentrain/mcp` — provider boundary + +- `LocalProvider` now implements the full `RepoProvider` surface: + `listBranches`, `createBranch`, `deleteBranch`, `getBranchDiff`, + `mergeBranch`, `isMerged`, `getDefaultBranch`. All seven wrap + existing simple-git / transaction helpers through a new + `providers/local/branch-ops.ts` module that mirrors the + `providers/github/branch-ops.ts` shape. +- `mergeBranch(branch, into)` asserts `into === CONTENTRAIN_BRANCH` — + the local flow merges feature branches into the content-tracking + branch and advances the base branch via `update-ref`, so arbitrary + targets would bypass that invariant. +- `server.ts`: the private `ToolProvider = RepoReader & RepoWriter & + { capabilities }` alias collapses to `type ToolProvider = + RepoProvider`. Tool handlers now depend on the shared surface + directly; the alias is kept purely so existing `ToolProvider` + imports do not have to migrate. +- `providers/local/types.ts` — `LocalSelectiveSyncResult` is removed + in favour of the shared `SyncResult` from `@contentrain/types`. + `workflowOverride` is typed with the shared `WorkflowMode` union + instead of the duplicated `'review' | 'auto-merge'` literal. + Matching swap inside `git/transaction.ts` so the whole write path + speaks one union. + +### `contentrain` — four new commands + shared MCP client + +- `utils/mcp-client.ts` — new shared `openMcpSession(projectRoot)` + helper built on `InMemoryTransport.createLinkedPair()`. Used by + the new commands and available for future ones that wrap MCP + tools one-shot. +- `contentrain merge ` — scriptable single-branch sibling + to `contentrain diff`. Delegates to the same `mergeBranch()` MCP + helper so dirty-file protections + selective-sync warnings are + preserved. `--yes` skips the confirmation prompt for CI use. +- `contentrain describe ` — wraps `contentrain_describe`. + Human-readable metadata + fields + stats + import snippet view, + with `--sample`, `--locale`, `--json`. +- `contentrain describe-format` — wraps `contentrain_describe_format`. + Useful for humans pairing with an agent that's asked for the + format primer. +- `contentrain scaffold --template ` — wraps + `contentrain_scaffold`. Interactive template picker when no flag + is passed; `--locales en,tr,de`, `--no-sample`, `--json`. +- `commands/status.ts` — branch-health thresholds (50/80) now come + from `checkBranchHealth()` instead of being duplicated inline. The + JSON output surfaces the full `branch_health` object so CI + consumers see the same warning/blocked state the text mode does. + +### Verification + +- `pnpm -r typecheck` across `@contentrain/types`, + `@contentrain/mcp`, and `contentrain` — 0 errors. +- `oxlint` across MCP + CLI + types src/tests — 0 warnings. +- `@contentrain/types` vitest — 110/110. +- `contentrain` vitest — 130/130. Includes the 11 new command tests + (`merge`, `describe`, `scaffold`) and the updated `status` branch- + health test against the new `checkBranchHealth()` mock. +- New `tests/providers/local/branch-ops.test.ts` — 7/7. Covers + contract shape, prefix-filtered branch listing, create/delete + round-trip, diff status mapping (added/modified), post-merge + `isMerged` flip, `mergeBranch` target guard, and config-driven + `getDefaultBranch`. + +### Tool surface + +No changes. Same 16 MCP tools, same arg schemas, same response +shapes. The boundary changes are purely internal. diff --git a/packages/cli/README.md b/packages/cli/README.md index 3adad1c..54d58ba 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -56,7 +56,11 @@ Requirements: | `contentrain doctor` | Check setup health, SDK freshness, orphan content, and branch limits | | `contentrain validate` | Validate content against schemas, optionally create review-branch fixes | | `contentrain generate` | Generate `.contentrain/client/` and `#contentrain` package imports | -| `contentrain diff` | Review and merge or reject pending `cr/*` branches | +| `contentrain diff` | Review and merge or reject pending `cr/*` branches interactively | +| `contentrain merge ` | Merge one pending `cr/*` branch non-interactively (CI/agents) | +| `contentrain describe ` | Inspect a model's schema, stats, and import snippet | +| `contentrain describe-format` | Print the Contentrain content-format specification | +| `contentrain scaffold --template` | Apply a template (`blog`, `landing`, `docs`, `ecommerce`, `saas`, `i18n`, `mobile`) | | `contentrain serve` | Start the local review UI, the MCP stdio server (`--stdio`), or the MCP HTTP server (`--mcpHttp`) | | `contentrain studio connect` | Connect a repository to a Studio project | | `contentrain studio login` | Authenticate with Contentrain Studio | diff --git a/packages/cli/src/commands/describe-format.ts b/packages/cli/src/commands/describe-format.ts new file mode 100644 index 0000000..30e18f7 --- /dev/null +++ b/packages/cli/src/commands/describe-format.ts @@ -0,0 +1,44 @@ +import { defineCommand } from 'citty' +import { intro, outro, log } from '@clack/prompts' +import { resolveProjectRoot } from '../utils/context.js' +import { openMcpSession } from '../utils/mcp-client.js' +import { pc } from '../utils/ui.js' + +/** + * Dumps the Contentrain content-format specification (what + * `contentrain_describe_format` returns). Mostly useful as a + * copy/paste primer for humans pairing with an agent, or for a quick + * `--json` handoff into another tool. + */ +export default defineCommand({ + meta: { + name: 'describe-format', + description: 'Print the Contentrain content-format specification', + }, + args: { + root: { type: 'string', description: 'Project root path', required: false }, + json: { type: 'boolean', description: 'Emit raw JSON for scripts', required: false }, + }, + async run({ args }) { + const projectRoot = await resolveProjectRoot(args.root) + const session = await openMcpSession(projectRoot) + + try { + const result = await session.call>('contentrain_describe_format', {}) + + if (args.json) { + process.stdout.write(JSON.stringify(result, null, 2)) + return + } + + intro(pc.bold('contentrain describe-format')) + log.message(pc.dim(JSON.stringify(result, null, 2))) + outro('') + } catch (error) { + log.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } finally { + await session.close() + } + }, +}) diff --git a/packages/cli/src/commands/describe.ts b/packages/cli/src/commands/describe.ts new file mode 100644 index 0000000..8c53c71 --- /dev/null +++ b/packages/cli/src/commands/describe.ts @@ -0,0 +1,100 @@ +import { defineCommand } from 'citty' +import { intro, outro, log } from '@clack/prompts' +import { resolveProjectRoot } from '../utils/context.js' +import { openMcpSession } from '../utils/mcp-client.js' +import { pc } from '../utils/ui.js' + +/** + * Read-only inspector for a single model, wrapping `contentrain_describe`. + * + * Useful for humans driving the CLI and for agents that want to sanity- + * check a model's fields, stats, and import snippet without committing + * anything. `--json` mirrors the MCP tool response verbatim. + */ +export default defineCommand({ + meta: { + name: 'describe', + description: 'Show the schema, stats, and import snippet for a model', + }, + args: { + model: { type: 'positional', description: 'Model ID (e.g. "blog-post", "hero")', required: true }, + root: { type: 'string', description: 'Project root path', required: false }, + sample: { type: 'boolean', description: 'Include one sample entry', required: false }, + locale: { type: 'string', description: 'Locale for the sample entry', required: false }, + json: { type: 'boolean', description: 'Emit raw JSON for scripts', required: false }, + }, + async run({ args }) { + const projectRoot = await resolveProjectRoot(args.root) + const modelId = String(args.model) + const session = await openMcpSession(projectRoot) + + try { + const result = await session.call>('contentrain_describe', { + model: modelId, + include_sample: Boolean(args.sample), + ...(args.locale ? { locale: String(args.locale) } : {}), + }) + + if (args.json) { + process.stdout.write(JSON.stringify(result, null, 2)) + return + } + + intro(pc.bold(`contentrain describe: ${modelId}`)) + + log.info(pc.bold('Metadata')) + log.message(` Name: ${String(result['name'] ?? '—')}`) + log.message(` Kind: ${pc.cyan(String(result['kind'] ?? '—'))}`) + log.message(` Domain: ${String(result['domain'] ?? '—')}`) + log.message(` i18n: ${result['i18n'] ? pc.green('yes') : pc.dim('no')}`) + if (result['description']) log.message(` About: ${String(result['description'])}`) + + const stats = result['stats'] as { total_entries?: number, locales?: Record } | undefined + if (stats) { + log.info(pc.bold('\nStats')) + log.message(` Total entries: ${stats.total_entries ?? 0}`) + if (stats.locales) { + for (const [loc, n] of Object.entries(stats.locales)) { + log.message(` ${loc}: ${n}`) + } + } + } + + const fields = result['fields'] as Record | undefined + if (fields) { + log.info(pc.bold('\nFields')) + for (const [name, spec] of Object.entries(fields)) { + const s = spec as { type?: string, required?: boolean } + const req = s.required ? pc.yellow(' *') : '' + log.message(` ${pc.bold(name)}${req} ${pc.dim(s.type ?? '')}`) + } + } + + if (result['import_snippet']) { + log.info(pc.bold('\nImport snippet')) + log.message(pc.dim(String(result['import_snippet']))) + } + + const vocab = result['vocabulary_hint'] as { note?: string, terms?: Record } | undefined + if (vocab?.terms && Object.keys(vocab.terms).length > 0) { + log.info(pc.bold('\nVocabulary hint')) + if (vocab.note) log.message(pc.dim(` ${vocab.note}`)) + for (const term of Object.keys(vocab.terms)) { + log.message(` • ${term}`) + } + } + + if (result['sample']) { + log.info(pc.bold('\nSample entry')) + log.message(pc.dim(JSON.stringify(result['sample'], null, 2))) + } + + outro('') + } catch (error) { + log.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } finally { + await session.close() + } + }, +}) diff --git a/packages/cli/src/commands/merge.ts b/packages/cli/src/commands/merge.ts new file mode 100644 index 0000000..ad40d76 --- /dev/null +++ b/packages/cli/src/commands/merge.ts @@ -0,0 +1,82 @@ +import { defineCommand } from 'citty' +import { intro, outro, log, confirm, isCancel } from '@clack/prompts' +import { simpleGit } from 'simple-git' +import { mergeBranch } from '@contentrain/mcp/git/transaction' +import { CONTENTRAIN_BRANCH } from '@contentrain/types' +import { resolveProjectRoot } from '../utils/context.js' +import { pc } from '../utils/ui.js' + +/** + * One-shot merge of a single contentrain feature branch. + * + * `contentrain diff` drives an interactive review over every pending + * branch; this command is the scriptable, one-branch sibling — + * shell-friendly for agents, CI, and manual triage. It reuses the exact + * same MCP helper (`mergeBranch`) that the interactive command + * delegates to, so the worktree transaction + selective-sync + dirty- + * file protections stay on one code path. + */ +export default defineCommand({ + meta: { + name: 'merge', + description: 'Merge one contentrain feature branch into the content branch', + }, + args: { + branch: { type: 'positional', description: 'Feature branch name (e.g. cr/content/blog/...)', required: true }, + root: { type: 'string', description: 'Project root path', required: false }, + yes: { type: 'boolean', description: 'Skip confirmation prompt', required: false }, + }, + async run({ args }) { + const projectRoot = await resolveProjectRoot(args.root) + const branch = String(args.branch) + + intro(pc.bold('contentrain merge')) + + if (branch === CONTENTRAIN_BRANCH) { + log.error(`Cannot merge the content-tracking branch (${CONTENTRAIN_BRANCH}) into itself.`) + outro('') + process.exitCode = 1 + return + } + + const git = simpleGit(projectRoot) + const localBranches = await git.branchLocal() + if (!localBranches.all.includes(branch)) { + log.error(`Branch "${branch}" does not exist locally.`) + outro('') + process.exitCode = 1 + return + } + + if (!args.yes) { + const ok = await confirm({ message: `Merge ${branch} into ${CONTENTRAIN_BRANCH} and fast-forward the base branch?` }) + if (isCancel(ok) || !ok) { + outro('') + return + } + } + + try { + const result = await mergeBranch(projectRoot, branch) + log.success(`Merged ${branch} (commit ${result.commit.slice(0, 8)})`) + if (result.sync?.synced?.length) { + log.message(pc.dim(` Synced ${result.sync.synced.length} file(s) to working tree.`)) + } + if (result.sync?.skipped?.length) { + log.warning(`${result.sync.skipped.length} file(s) skipped during sync — you have uncommitted changes:`) + for (const f of result.sync.skipped) { + log.message(pc.dim(` ${f}`)) + } + if (result.sync.warning) { + log.message(pc.dim(` ${result.sync.warning}`)) + } + } + } catch (error) { + log.error(`Merge failed: ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + return + } + + outro('') + }, +}) diff --git a/packages/cli/src/commands/scaffold.ts b/packages/cli/src/commands/scaffold.ts new file mode 100644 index 0000000..f0b7e57 --- /dev/null +++ b/packages/cli/src/commands/scaffold.ts @@ -0,0 +1,103 @@ +import { defineCommand } from 'citty' +import { intro, outro, log, select, isCancel, spinner } from '@clack/prompts' +import { resolveProjectRoot } from '../utils/context.js' +import { openMcpSession } from '../utils/mcp-client.js' +import { pc } from '../utils/ui.js' + +const KNOWN_TEMPLATES = ['blog', 'landing', 'docs', 'ecommerce', 'saas', 'i18n', 'mobile'] as const + +/** + * Template-based project seeding, wrapping `contentrain_scaffold`. + * + * Agents normally drive scaffolding through the MCP tool directly; this + * command gives humans the same entry point with an interactive + * template picker and a bit more actionable next-step output. + */ +export default defineCommand({ + meta: { + name: 'scaffold', + description: 'Apply a Contentrain template (models + sample content + vocabulary)', + }, + args: { + root: { type: 'string', description: 'Project root path', required: false }, + template: { type: 'string', description: `Template ID (${KNOWN_TEMPLATES.join(', ')})`, required: false }, + locales: { type: 'string', description: 'Comma-separated locale override (e.g. "en,tr,de")', required: false }, + 'no-sample': { type: 'boolean', description: 'Skip sample content', required: false }, + json: { type: 'boolean', description: 'Emit raw JSON for scripts', required: false }, + }, + async run({ args }) { + const projectRoot = await resolveProjectRoot(args.root) + + let templateId = args.template ? String(args.template) : undefined + if (!templateId && !args.json) { + intro(pc.bold('contentrain scaffold')) + const pick = await select({ + message: 'Choose a template', + options: KNOWN_TEMPLATES.map(t => ({ value: t, label: t })), + }) + if (isCancel(pick)) { + outro('') + return + } + templateId = pick as string + } + + if (!templateId) { + log.error('Template is required. Pass --template or run interactively.') + process.exitCode = 1 + return + } + + const locales = args.locales + ? String(args.locales).split(',').map(s => s.trim()).filter(Boolean) + : undefined + + const session = await openMcpSession(projectRoot) + const s = args.json ? null : spinner() + s?.start(`Applying template "${templateId}"...`) + + try { + const result = await session.call>('contentrain_scaffold', { + template: templateId, + ...(locales ? { locales } : {}), + with_sample_content: !args['no-sample'], + }) + + s?.stop(`Template "${templateId}" applied.`) + + if (args.json) { + process.stdout.write(JSON.stringify(result, null, 2)) + return + } + + const git = result['git'] as { branch?: string, action?: string, commit?: string } | undefined + log.success(`Scaffolded ${pc.bold(templateId)} on ${pc.cyan(git?.branch ?? '(branch)')} (${git?.action ?? 'committed'})`) + const models = result['models_created'] as Array<{ id: string }> | undefined + if (models?.length) { + log.message(` Models: ${models.map(m => m.id).join(', ')}`) + } + if (typeof result['content_created'] === 'number') { + log.message(` Sample entries: ${result['content_created']}`) + } + if (typeof result['vocabulary_terms_added'] === 'number' && result['vocabulary_terms_added']) { + log.message(` Vocabulary terms added: ${result['vocabulary_terms_added']}`) + } + + const next = result['next_steps'] as string[] | undefined + if (next?.length) { + log.info(pc.bold('\nNext steps')) + for (const step of next) { + log.message(` • ${step}`) + } + } + + outro('') + } catch (error) { + s?.stop('Scaffold failed.') + log.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } finally { + await session.close() + } + }, +}) diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index d939fd6..e0de02e 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -3,6 +3,7 @@ import { intro, outro, log } from '@clack/prompts' import { simpleGit } from 'simple-git' import { readModel, countEntries } from '@contentrain/mcp/core/model-manager' import { validateProject } from '@contentrain/mcp/core/validator' +import { checkBranchHealth } from '@contentrain/mcp/git/branch-lifecycle' import { CONTENTRAIN_BRANCH } from '@contentrain/types' import { resolveProjectRoot, loadProjectContext, requireInitialized } from '../utils/context.js' import { pc, formatTable, formatPercent, formatCount } from '../utils/ui.js' @@ -39,7 +40,7 @@ export default defineCommand({ } } catch { /* best effort */ } - // Pending branches + // Pending branches + health (shared thresholds with MCP) try { const git = simpleGit(projectRoot) const branches = await git.branch(['--list', 'cr/*']) @@ -47,6 +48,18 @@ export default defineCommand({ const featureBranches = branches.all.filter(b => b !== CONTENTRAIN_BRANCH) jsonResult['pending_branches'] = featureBranches + try { + const health = await checkBranchHealth(projectRoot) + jsonResult['branch_health'] = { + total: health.total, + merged: health.merged, + unmerged: health.unmerged, + warning: health.warning, + blocked: health.blocked, + message: health.message ?? null, + } + } catch { /* best effort */ } + // Content branch info const contentBranchExists = allLocal.all.includes(CONTENTRAIN_BRANCH) const contentBranchInfo: Record = { exists: contentBranchExists } @@ -144,19 +157,22 @@ export default defineCommand({ } catch { /* best effort */ } } - // Pending branches (excluding the system contentrain branch) + // Pending branches — share warning/blocked thresholds with MCP. const branches = await git.branch(['--list', 'cr/*']) const featureBranches = branches.all.filter(b => b !== CONTENTRAIN_BRANCH) if (featureBranches.length > 0) { - const count = featureBranches.length - if (count >= 80) { - log.error(pc.bold(`\nBLOCKED: ${count} active contentrain branches (limit: 80)`)) - log.message(` New writes are blocked. Merge or delete old branches with ${pc.cyan('contentrain diff')}.`) - } else if (count >= 50) { - log.warning(pc.bold(`\nWARNING: ${count} active contentrain branches (limit: 50)`)) - log.message(` Consider merging or deleting old branches with ${pc.cyan('contentrain diff')}.`) + const health = await checkBranchHealth(projectRoot).catch(() => null) + const headerLabel = health + ? `\nPending branches (${health.unmerged} unmerged / ${health.total} total)` + : `\nPending branches (${featureBranches.length})` + if (health?.blocked) { + log.error(pc.bold(headerLabel)) + log.message(` ${health.message}`) + } else if (health?.warning) { + log.warning(pc.bold(headerLabel)) + log.message(` ${health.message}`) } else { - log.info(pc.bold(`\nPending branches (${count})`)) + log.info(pc.bold(headerLabel)) } for (const branch of featureBranches) { log.message(` ${pc.yellow('●')} ${branch}`) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 79d5694..4cf8414 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -16,6 +16,10 @@ const main = defineCommand({ serve: () => import('./commands/serve.js').then(m => m.default), generate: () => import('./commands/generate.js').then(m => m.default), diff: () => import('./commands/diff.js').then(m => m.default), + merge: () => import('./commands/merge.js').then(m => m.default), + describe: () => import('./commands/describe.js').then(m => m.default), + 'describe-format': () => import('./commands/describe-format.js').then(m => m.default), + scaffold: () => import('./commands/scaffold.js').then(m => m.default), setup: () => import('./commands/setup.js').then(m => m.default), skills: () => import('./commands/skills.js').then(m => m.default), studio: () => import('./studio/index.js').then(m => m.default), diff --git a/packages/cli/src/utils/mcp-client.ts b/packages/cli/src/utils/mcp-client.ts new file mode 100644 index 0000000..884b26f --- /dev/null +++ b/packages/cli/src/utils/mcp-client.ts @@ -0,0 +1,72 @@ +import { createServer as createMcpServer } from '@contentrain/mcp/server' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' + +/** + * Thin in-memory MCP client used by CLI commands that front MCP tools + * (merge, describe, describe-format, scaffold). The `contentrain serve` + * HTTP API already uses the same transport directly; this helper just + * isolates the setup/teardown dance so each one-shot command does not + * have to repeat it. + */ +export interface MCPCallOptions { + /** Throw when the tool response has `isError: true`. Default: true. */ + throwOnError?: boolean +} + +export interface MCPSession { + /** Invoke a tool by name. Returns the parsed JSON payload from its first text content. */ + call: (name: string, args?: Record, options?: MCPCallOptions) => Promise + /** Tear down the linked transport pair. Always await in a `finally`. */ + close: () => Promise +} + +/** + * Start an in-memory MCP session against a local project root. Always + * `await session.close()` — typically in a `finally` block — so the + * underlying transports release cleanly. + */ +export async function openMcpSession(projectRoot: string): Promise { + const server = createMcpServer(projectRoot) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'contentrain-cli', version: '1.0.0' }) + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]) + + return { + async call( + name: string, + args: Record = {}, + options: MCPCallOptions = {}, + ): Promise { + const result = await client.callTool({ name, arguments: args }) + const contentArr = (result.content ?? []) as Array<{ type: string, text?: string }> + const textContent = contentArr.find(c => c.type === 'text') + if (!textContent || typeof textContent.text !== 'string') { + throw new Error(`Tool ${name} returned no text content`) + } + let parsed: unknown + try { + parsed = JSON.parse(textContent.text) + } catch { + parsed = textContent.text + } + if (result.isError && options.throwOnError !== false) { + const msg = typeof parsed === 'object' && parsed !== null && 'error' in parsed + ? String((parsed as { error: unknown }).error) + : textContent.text + throw new Error(msg) + } + return parsed as T + }, + async close() { + await Promise.all([ + client.close().catch(() => {}), + server.close().catch(() => {}), + ]) + }, + } +} diff --git a/packages/cli/tests/commands/describe.test.ts b/packages/cli/tests/commands/describe.test.ts new file mode 100644 index 0000000..f02fa58 --- /dev/null +++ b/packages/cli/tests/commands/describe.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() +const closeMock = vi.fn() + +vi.mock('../../src/utils/mcp-client.js', () => ({ + openMcpSession: vi.fn(async () => ({ + call: callMock, + close: closeMock, + })), +})) + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + log: { message: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})) + +vi.mock('../../src/utils/context.js', () => ({ + resolveProjectRoot: vi.fn(async (r?: string) => r ?? '/test/project'), +})) + +describe('describe command', () => { + beforeEach(() => { + vi.clearAllMocks() + process.exitCode = undefined + callMock.mockResolvedValue({ + id: 'blog-post', + name: 'Blog Post', + kind: 'collection', + domain: 'content', + i18n: true, + fields: { title: { type: 'string', required: true } }, + stats: { total_entries: 3, locales: { en: 3 } }, + import_snippet: "import { useBlogPost } from '#contentrain'", + }) + }) + + it('invokes contentrain_describe with the positional model arg', async () => { + const mod = await import('../../src/commands/describe.js') + await mod.default.run?.({ args: { model: 'blog-post' } } as never) + + expect(callMock).toHaveBeenCalledWith('contentrain_describe', { + model: 'blog-post', + include_sample: false, + }) + expect(closeMock).toHaveBeenCalled() + }) + + it('forwards --sample and --locale flags', async () => { + const mod = await import('../../src/commands/describe.js') + await mod.default.run?.({ args: { model: 'blog-post', sample: true, locale: 'tr' } } as never) + + expect(callMock).toHaveBeenCalledWith('contentrain_describe', { + model: 'blog-post', + include_sample: true, + locale: 'tr', + }) + }) + + it('emits raw JSON on --json', async () => { + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const mod = await import('../../src/commands/describe.js') + await mod.default.run?.({ args: { model: 'blog-post', json: true } } as never) + + expect(writeSpy).toHaveBeenCalledTimes(1) + const payload = writeSpy.mock.calls[0]?.[0] as string + expect(JSON.parse(payload).id).toBe('blog-post') + writeSpy.mockRestore() + }) + + it('closes the session even when the tool throws', async () => { + callMock.mockRejectedValueOnce(new Error('boom')) + const mod = await import('../../src/commands/describe.js') + await mod.default.run?.({ args: { model: 'missing' } } as never) + + expect(closeMock).toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) +}) diff --git a/packages/cli/tests/commands/merge.test.ts b/packages/cli/tests/commands/merge.test.ts new file mode 100644 index 0000000..5f0a746 --- /dev/null +++ b/packages/cli/tests/commands/merge.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mergeBranchMock = vi.fn() + +vi.mock('@contentrain/mcp/git/transaction', () => ({ + mergeBranch: mergeBranchMock, +})) + +const branchLocalMock = vi.fn() + +vi.mock('simple-git', () => ({ + simpleGit: vi.fn(() => ({ + branchLocal: branchLocalMock, + })), +})) + +const confirmMock = vi.fn() + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + log: { message: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, + confirm: confirmMock, + isCancel: vi.fn().mockReturnValue(false), +})) + +vi.mock('../../src/utils/context.js', () => ({ + resolveProjectRoot: vi.fn(async (r?: string) => r ?? '/test/project'), +})) + +describe('merge command', () => { + beforeEach(() => { + vi.clearAllMocks() + process.exitCode = undefined + branchLocalMock.mockResolvedValue({ all: ['cr/content/blog/1234-aaaa', 'contentrain', 'main'] }) + confirmMock.mockResolvedValue(true) + mergeBranchMock.mockResolvedValue({ + action: 'merged', + commit: 'abcdef1234', + sync: { synced: ['.contentrain/content/blog/en.json'], skipped: [] }, + }) + }) + + it('delegates to the MCP mergeBranch helper with the positional branch arg', async () => { + const mod = await import('../../src/commands/merge.js') + await mod.default.run?.({ args: { branch: 'cr/content/blog/1234-aaaa', yes: true } } as never) + + expect(mergeBranchMock).toHaveBeenCalledWith('/test/project', 'cr/content/blog/1234-aaaa') + }) + + it('refuses to merge the contentrain singleton branch into itself', async () => { + const mod = await import('../../src/commands/merge.js') + await mod.default.run?.({ args: { branch: 'contentrain', yes: true } } as never) + + expect(mergeBranchMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) + + it('errors when the branch does not exist locally', async () => { + branchLocalMock.mockResolvedValueOnce({ all: ['contentrain', 'main'] }) + const mod = await import('../../src/commands/merge.js') + await mod.default.run?.({ args: { branch: 'cr/content/blog/1234-aaaa', yes: true } } as never) + + expect(mergeBranchMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) + + it('prompts for confirmation by default and aborts when declined', async () => { + confirmMock.mockResolvedValueOnce(false) + const mod = await import('../../src/commands/merge.js') + await mod.default.run?.({ args: { branch: 'cr/content/blog/1234-aaaa' } } as never) + + expect(confirmMock).toHaveBeenCalledTimes(1) + expect(mergeBranchMock).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/tests/commands/scaffold.test.ts b/packages/cli/tests/commands/scaffold.test.ts new file mode 100644 index 0000000..448015a --- /dev/null +++ b/packages/cli/tests/commands/scaffold.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() +const closeMock = vi.fn() + +vi.mock('../../src/utils/mcp-client.js', () => ({ + openMcpSession: vi.fn(async () => ({ + call: callMock, + close: closeMock, + })), +})) + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + log: { message: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, + select: vi.fn(), + spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), + isCancel: vi.fn().mockReturnValue(false), +})) + +vi.mock('../../src/utils/context.js', () => ({ + resolveProjectRoot: vi.fn(async (r?: string) => r ?? '/test/project'), +})) + +describe('scaffold command', () => { + beforeEach(() => { + vi.clearAllMocks() + process.exitCode = undefined + callMock.mockResolvedValue({ + status: 'committed', + models_created: [{ id: 'blog-post' }], + content_created: 3, + vocabulary_terms_added: 0, + git: { branch: 'cr/new/scaffold-blog/1700000000-aaaa', action: 'auto-merged', commit: 'abc1234' }, + next_steps: ['Run contentrain validate'], + }) + }) + + it('passes --template and --no-sample to contentrain_scaffold', async () => { + const mod = await import('../../src/commands/scaffold.js') + await mod.default.run?.({ args: { template: 'blog', 'no-sample': true } } as never) + + expect(callMock).toHaveBeenCalledWith('contentrain_scaffold', { + template: 'blog', + with_sample_content: false, + }) + }) + + it('splits --locales into an array', async () => { + const mod = await import('../../src/commands/scaffold.js') + await mod.default.run?.({ args: { template: 'blog', locales: 'en,tr, de' } } as never) + + expect(callMock).toHaveBeenCalledWith('contentrain_scaffold', expect.objectContaining({ + locales: ['en', 'tr', 'de'], + template: 'blog', + })) + }) + + it('errors when --template is missing and --json is set (no interactive picker)', async () => { + const mod = await import('../../src/commands/scaffold.js') + await mod.default.run?.({ args: { json: true } } as never) + + expect(callMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) +}) diff --git a/packages/cli/tests/commands/status.test.ts b/packages/cli/tests/commands/status.test.ts index 5d5736b..e7b3331 100644 --- a/packages/cli/tests/commands/status.test.ts +++ b/packages/cli/tests/commands/status.test.ts @@ -35,6 +35,14 @@ vi.mock('@contentrain/mcp/core/validator', () => ({ }), })) +const checkBranchHealthMock = vi.fn().mockResolvedValue({ + total: 0, merged: 0, unmerged: 0, warning: false, blocked: false, message: undefined, +}) + +vi.mock('@contentrain/mcp/git/branch-lifecycle', () => ({ + checkBranchHealth: checkBranchHealthMock, +})) + vi.mock('@contentrain/mcp/util/fs', () => ({ pathExists: vi.fn().mockResolvedValue(true), contentrainDir: vi.fn((root: string) => `${root}/.contentrain`), @@ -80,7 +88,15 @@ describe('status command', () => { it('should surface a blocked branch-health state when the project reaches 80 pending branches', async () => { branchMock.mockResolvedValueOnce({ - all: Array.from({ length: 80 }, (_, i) => `contentrain/review/test-${i}`), + all: Array.from({ length: 80 }, (_, i) => `cr/content/blog/test-${i}`), + }) + checkBranchHealthMock.mockResolvedValueOnce({ + total: 80, + merged: 0, + unmerged: 80, + warning: true, + blocked: true, + message: 'BLOCKED: 80 active contentrain branches (limit: 80). Run cleanup or merge/delete old branches before creating new ones.', }) const mod = await import('../../src/commands/status.js') diff --git a/packages/mcp/src/git/transaction.ts b/packages/mcp/src/git/transaction.ts index fbebe3a..9e51384 100644 --- a/packages/mcp/src/git/transaction.ts +++ b/packages/mcp/src/git/transaction.ts @@ -7,7 +7,7 @@ import { readConfig } from '../core/config.js' import { writeContext } from '../core/context.js' import { branchTimestamp } from '../util/id.js' import { migrateLegacyBranches } from '../providers/local/migration.js' -import type { SyncResult } from '@contentrain/types' +import type { SyncResult, WorkflowMode } from '@contentrain/types' import { CONTENTRAIN_BRANCH } from '@contentrain/types' export interface ContextUpdate { @@ -143,7 +143,7 @@ async function selectiveSync( export async function createTransaction( projectRoot: string, branchName: string, - options?: { workflowOverride?: 'review' | 'auto-merge' }, + options?: { workflowOverride?: WorkflowMode }, ): Promise { const git = simpleGit(projectRoot) const config = await readConfig(projectRoot) diff --git a/packages/mcp/src/providers/local/branch-ops.ts b/packages/mcp/src/providers/local/branch-ops.ts new file mode 100644 index 0000000..8bdf0d0 --- /dev/null +++ b/packages/mcp/src/providers/local/branch-ops.ts @@ -0,0 +1,121 @@ +import { simpleGit } from 'simple-git' +import { CONTENTRAIN_BRANCH } from '@contentrain/types' +import type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js' +import { readConfig } from '../../core/config.js' +import { mergeBranch as mergeBranchOp } from '../../git/transaction.js' + +/** + * Branch/merge/diff helpers backed by the local simple-git worktree flow. + * + * Pure functions composed into `LocalProvider` — mirroring the shape of + * `providers/github/branch-ops.ts` so the two providers share the same + * surface at the `RepoProvider` boundary. + */ + +export async function getDefaultBranch(projectRoot: string): Promise { + const config = await readConfig(projectRoot) + if (config?.repository?.default_branch) return config.repository.default_branch + const envBranch = process.env['CONTENTRAIN_BRANCH'] + if (envBranch) return envBranch + const git = simpleGit(projectRoot) + const current = (await git.raw(['branch', '--show-current'])).trim() + return current || 'main' +} + +export async function listBranches( + projectRoot: string, + prefix?: string, +): Promise { + const git = simpleGit(projectRoot) + const summary = await git.branchLocal() + const names = prefix + ? summary.all.filter(n => n.startsWith(prefix)) + : summary.all + const branches: Branch[] = [] + for (const name of names) { + const info = summary.branches[name] + branches.push({ name, sha: info?.commit ?? '' }) + } + return branches +} + +export async function createBranch( + projectRoot: string, + name: string, + fromRef: string, +): Promise { + const git = simpleGit(projectRoot) + await git.raw(['branch', name, fromRef]) +} + +export async function deleteBranch( + projectRoot: string, + name: string, +): Promise { + const git = simpleGit(projectRoot) + await git.deleteLocalBranch(name, true) +} + +export async function getBranchDiff( + projectRoot: string, + branch: string, + base: string, +): Promise { + const git = simpleGit(projectRoot) + const raw = await git.raw(['diff', '--name-status', `${base}...${branch}`]) + const diffs: FileDiff[] = [] + for (const line of raw.split('\n')) { + if (!line.trim()) continue + const [code, ...pathParts] = line.split('\t') + const path = pathParts[pathParts.length - 1] + if (!code || !path) continue + const status: FileDiff['status'] = code.startsWith('A') + ? 'added' + : code.startsWith('D') + ? 'removed' + : 'modified' + diffs.push({ path, status, before: null, after: null }) + } + return diffs +} + +export async function mergeBranch( + projectRoot: string, + branch: string, + into: string, +): Promise { + if (into !== CONTENTRAIN_BRANCH) { + throw Object.assign(new Error( + `LocalProvider.mergeBranch only supports merging into "${CONTENTRAIN_BRANCH}" (got "${into}"). ` + + `The local flow merges feature branches into the content-tracking branch and fast-forwards the base branch via update-ref.`, + ), { + code: 'UNSUPPORTED_MERGE_TARGET', + agent_hint: `Pass "${CONTENTRAIN_BRANCH}" as the merge target, or use a non-local provider that supports arbitrary targets.`, + developer_action: `Merge "${branch}" into "${CONTENTRAIN_BRANCH}" instead.`, + }) + } + const result = await mergeBranchOp(projectRoot, branch) + return { + merged: true, + sha: result.commit, + pullRequestUrl: null, + sync: result.sync, + } +} + +export async function isMerged( + projectRoot: string, + branch: string, + into: string, +): Promise { + const git = simpleGit(projectRoot) + try { + const raw = await git.raw(['branch', '--merged', into]) + const merged = new Set( + raw.split('\n').map(b => b.replace(/^\*?\s+/, '').trim()).filter(Boolean), + ) + return merged.has(branch) + } catch { + return false + } +} diff --git a/packages/mcp/src/providers/local/index.ts b/packages/mcp/src/providers/local/index.ts index 84ec1cd..b7c55d1 100644 --- a/packages/mcp/src/providers/local/index.ts +++ b/packages/mcp/src/providers/local/index.ts @@ -1,3 +1,3 @@ export { LocalProvider } from './provider.js' export { LocalReader } from './reader.js' -export type { LocalApplyPlanInput, LocalApplyResult, LocalContextUpdate, LocalSelectiveSyncResult } from './types.js' +export type { LocalApplyPlanInput, LocalApplyResult, LocalContextUpdate } from './types.js' diff --git a/packages/mcp/src/providers/local/provider.ts b/packages/mcp/src/providers/local/provider.ts index 2e7325b..0c2fa33 100644 --- a/packages/mcp/src/providers/local/provider.ts +++ b/packages/mcp/src/providers/local/provider.ts @@ -1,7 +1,23 @@ -import type { ProviderCapabilities, RepoReader } from '../../core/contracts/index.js' +import { CONTENTRAIN_BRANCH } from '@contentrain/types' +import type { + Branch, + FileDiff, + MergeResult, + ProviderCapabilities, + RepoProvider, +} from '../../core/contracts/index.js' import { LOCAL_CAPABILITIES } from '../../core/contracts/index.js' import { applyChangesToWorktree } from '../../core/ops/index.js' import { createTransaction } from '../../git/transaction.js' +import { + createBranch as createBranchOp, + deleteBranch as deleteBranchOp, + getBranchDiff as getBranchDiffOp, + getDefaultBranch as getDefaultBranchOp, + isMerged as isMergedOp, + listBranches as listBranchesOp, + mergeBranch as mergeBranchOp, +} from './branch-ops.js' import { LocalReader } from './reader.js' import type { LocalApplyPlanInput, LocalApplyResult } from './types.js' @@ -11,18 +27,19 @@ const DEFAULT_AUTHOR_EMAIL = 'mcp@contentrain.io' /** * LocalProvider — the local-filesystem, worktree-backed content provider. * - * Phase 3 scope: LocalProvider wraps the existing `createTransaction` flow - * so tool handlers drive a clean plan/apply surface without knowing about - * worktrees, the contentrain branch guard, or the auto-merge state - * machine. Phase 6 will fold `transaction.ts`'s internals into this - * provider; today it is a thin, behaviour-preserving wrapper. + * Implements the full `RepoProvider` surface: + * - Reader methods delegate to `LocalReader`. + * - `applyPlan` wraps `createTransaction` and returns `LocalApplyResult` + * (a superset of `Commit` carrying workflow action + selective sync). + * - Branch ops mirror `GitHubProvider` — thin wrappers over the local + * simple-git helpers in `./branch-ops.ts`. * - * Implements `RepoReader` (through a private LocalReader). Implements a - * superset of `RepoWriter.applyPlan` — returns `LocalApplyResult` which - * extends `Commit` with workflow action + selective-sync details; the - * extras are ignored by code that consumes it as a plain `Commit`. + * `mergeBranch` only supports merging into the singleton + * `CONTENTRAIN_BRANCH`; the local flow advances the base branch via + * `update-ref` in `transaction.mergeBranch`, so arbitrary merge targets + * would bypass that invariant. */ -export class LocalProvider implements RepoReader { +export class LocalProvider implements RepoProvider { readonly capabilities: ProviderCapabilities = LOCAL_CAPABILITIES private readonly reader: LocalReader @@ -68,4 +85,35 @@ export class LocalProvider implements RepoReader { await tx.cleanup() } } + + listBranches(prefix?: string): Promise { + return listBranchesOp(this.projectRoot, prefix) + } + + async createBranch(name: string, fromRef?: string): Promise { + const resolved = fromRef ?? CONTENTRAIN_BRANCH + await createBranchOp(this.projectRoot, name, resolved) + } + + deleteBranch(name: string): Promise { + return deleteBranchOp(this.projectRoot, name) + } + + getBranchDiff(branch: string, base?: string): Promise { + const resolved = base ?? CONTENTRAIN_BRANCH + return getBranchDiffOp(this.projectRoot, branch, resolved) + } + + mergeBranch(branch: string, into: string): Promise { + return mergeBranchOp(this.projectRoot, branch, into) + } + + isMerged(branch: string, into?: string): Promise { + const resolved = into ?? CONTENTRAIN_BRANCH + return isMergedOp(this.projectRoot, branch, resolved) + } + + getDefaultBranch(): Promise { + return getDefaultBranchOp(this.projectRoot) + } } diff --git a/packages/mcp/src/providers/local/types.ts b/packages/mcp/src/providers/local/types.ts index d100336..05787bc 100644 --- a/packages/mcp/src/providers/local/types.ts +++ b/packages/mcp/src/providers/local/types.ts @@ -1,3 +1,4 @@ +import type { SyncResult, WorkflowMode } from '@contentrain/types' import type { Commit, CommitAuthor, FileChange } from '../../core/contracts/index.js' /** Optional payload written to `.contentrain/context.json` after changes apply. */ @@ -31,16 +32,7 @@ export interface LocalApplyPlanInput { /** Optional context.json payload written through after changes apply. */ context?: LocalContextUpdate /** Override workflow for this call; defaults to the project's configured workflow. */ - workflowOverride?: 'review' | 'auto-merge' -} - -export interface LocalSelectiveSyncResult { - /** Files in the developer's working tree that were updated to match the new HEAD. */ - synced: string[] - /** Files skipped because of local modifications the developer should resolve. */ - skipped: string[] - /** Human-readable advice surfaced to the agent when skips happened. */ - warning?: string + workflowOverride?: WorkflowMode } export interface LocalApplyResult extends Commit { @@ -52,7 +44,7 @@ export interface LocalApplyResult extends Commit { */ workflowAction: 'auto-merged' | 'pending-review' /** Selective-sync bookkeeping; populated when `workflowAction === 'auto-merged'`. */ - sync?: LocalSelectiveSyncResult + sync?: SyncResult /** Non-fatal warning bubbled up from the transaction layer (e.g. partial sync). */ warning?: string } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index be4adbe..7da5a09 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,16 +1,15 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { ProviderCapabilities, RepoReader, RepoWriter } from './core/contracts/index.js' +import type { RepoProvider } from './core/contracts/index.js' import { LocalProvider } from './providers/local/index.js' /** - * Subset of a `RepoProvider` that tool handlers currently consume — a - * `RepoReader`, a `RepoWriter`, and the `capabilities` manifest. Narrower - * than the full `RepoProvider` so both `LocalProvider` (which implements - * reader + writer + applyPlan) and `GitHubProvider` (full provider) - * satisfy it without requiring LocalProvider to stub out the branch-ops - * methods it does not yet own. + * The provider shape tool handlers consume. Now that every provider + * (Local, GitHub, GitLab) implements the full `RepoProvider`, tools can + * depend on the shared surface directly — no private alias required. + * Kept as a re-export so callers that already import `ToolProvider` do + * not need to migrate. */ -export type ToolProvider = RepoReader & RepoWriter & { capabilities: ProviderCapabilities } +export type ToolProvider = RepoProvider import { registerContextTools } from './tools/context.js' import { registerSetupTools } from './tools/setup.js' import { registerModelTools } from './tools/model.js' diff --git a/packages/mcp/tests/providers/local/branch-ops.test.ts b/packages/mcp/tests/providers/local/branch-ops.test.ts new file mode 100644 index 0000000..dc7f557 --- /dev/null +++ b/packages/mcp/tests/providers/local/branch-ops.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { simpleGit } from 'simple-git' +import { CONTENTRAIN_BRANCH } from '@contentrain/types' +import { LocalProvider } from '../../../src/providers/local/provider.js' + +async function writeFileSafe(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content) +} + +vi.setConfig({ testTimeout: 30_000, hookTimeout: 30_000 }) + +let testDir: string + +beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'cr-local-branch-ops-')) + const git = simpleGit(testDir) + await git.init() + await git.addConfig('user.name', 'Test') + await git.addConfig('user.email', 'test@contentrain.io') + await writeFileSafe(join(testDir, 'README.md'), 'base\n') + await git.add('.') + await git.commit('initial') + await git.branch([CONTENTRAIN_BRANCH]) +}) + +afterEach(async () => { + await rm(testDir, { recursive: true, force: true }) +}) + +describe('LocalProvider — RepoProvider surface', () => { + it('exposes branch-ops methods (contract check)', () => { + const provider = new LocalProvider(testDir) + expect(typeof provider.listBranches).toBe('function') + expect(typeof provider.createBranch).toBe('function') + expect(typeof provider.deleteBranch).toBe('function') + expect(typeof provider.getBranchDiff).toBe('function') + expect(typeof provider.mergeBranch).toBe('function') + expect(typeof provider.isMerged).toBe('function') + expect(typeof provider.getDefaultBranch).toBe('function') + expect(provider.capabilities.localWorktree).toBe(true) + }) + + it('listBranches filters by prefix and returns sha', async () => { + const git = simpleGit(testDir) + await git.checkoutBranch('cr/content/blog/1700000000-aaaa', CONTENTRAIN_BRANCH) + await writeFileSafe(join(testDir, '.contentrain/content/blog/en.json'), '{"a":1}\n') + await git.raw(['add', '-A']) + await git.commit('add blog') + await git.checkout(CONTENTRAIN_BRANCH) + + const provider = new LocalProvider(testDir) + const crBranches = await provider.listBranches('cr/') + expect(crBranches.length).toBe(1) + expect(crBranches[0]?.name).toBe('cr/content/blog/1700000000-aaaa') + expect(crBranches[0]?.sha).toMatch(/^[0-9a-f]{7,}$/u) + + const all = await provider.listBranches() + expect(all.length).toBeGreaterThanOrEqual(2) + }) + + it('createBranch / deleteBranch round-trip', async () => { + const provider = new LocalProvider(testDir) + await provider.createBranch('cr/content/test/1700000000-bbbb', CONTENTRAIN_BRANCH) + + const listed = await provider.listBranches('cr/content/test/') + expect(listed.map(b => b.name)).toContain('cr/content/test/1700000000-bbbb') + + await provider.deleteBranch('cr/content/test/1700000000-bbbb') + const afterDelete = await provider.listBranches('cr/content/test/') + expect(afterDelete.map(b => b.name)).not.toContain('cr/content/test/1700000000-bbbb') + }) + + it('getBranchDiff maps added/modified/removed', async () => { + const git = simpleGit(testDir) + await git.checkoutBranch('cr/content/blog/1700000000-cccc', CONTENTRAIN_BRANCH) + await writeFileSafe(join(testDir, '.contentrain/content/blog/en.json'), '{"a":1}\n') + await writeFileSafe(join(testDir, 'README.md'), 'updated\n') + await git.raw(['add', '-A']) + await git.commit('modify README + add blog') + await git.checkout(CONTENTRAIN_BRANCH) + + const provider = new LocalProvider(testDir) + const diff = await provider.getBranchDiff('cr/content/blog/1700000000-cccc') + const byPath = new Map(diff.map(d => [d.path, d.status])) + expect(byPath.get('.contentrain/content/blog/en.json')).toBe('added') + expect(byPath.get('README.md')).toBe('modified') + }) + + it('isMerged returns true once the feature branch is merged into contentrain', async () => { + const git = simpleGit(testDir) + await git.checkoutBranch('cr/content/blog/1700000000-dddd', CONTENTRAIN_BRANCH) + await writeFileSafe(join(testDir, '.contentrain/content/blog/en.json'), '{"a":1}\n') + await git.raw(['add', '-A']) + await git.commit('add blog') + await git.checkout(CONTENTRAIN_BRANCH) + + const provider = new LocalProvider(testDir) + expect(await provider.isMerged('cr/content/blog/1700000000-dddd')).toBe(false) + + await git.merge(['cr/content/blog/1700000000-dddd', '--no-edit', '--ff-only']) + expect(await provider.isMerged('cr/content/blog/1700000000-dddd')).toBe(true) + }) + + it('mergeBranch rejects a target other than CONTENTRAIN_BRANCH', async () => { + const provider = new LocalProvider(testDir) + await expect( + provider.mergeBranch('cr/content/any/1700000000-ffff', 'main'), + ).rejects.toThrow(/only supports merging into/iu) + }) + + it('getDefaultBranch reads from config when present', async () => { + await writeFileSafe( + join(testDir, '.contentrain/config.json'), + JSON.stringify({ + stack: 'nuxt', + locales: { default: 'en', supported: ['en'] }, + domains: ['content'], + workflow: 'auto-merge', + repository: { default_branch: 'trunk' }, + }), + ) + + const provider = new LocalProvider(testDir) + expect(await provider.getDefaultBranch()).toBe('trunk') + }) +}) diff --git a/packages/types/src/provider.ts b/packages/types/src/provider.ts index b2b1d0d..6a95043 100644 --- a/packages/types/src/provider.ts +++ b/packages/types/src/provider.ts @@ -1,3 +1,5 @@ +import type { SyncResult } from './index.js' + // ─── Repository Provider Contracts ─── // // Shared interfaces for the provider-agnostic content repository model used @@ -167,6 +169,12 @@ export interface MergeResult { merged: boolean sha: string | null pullRequestUrl: string | null + /** + * Selective-sync bookkeeping — only populated by providers that back onto + * a local worktree (LocalProvider). Remote-API providers (GitHub, GitLab, + * etc.) omit it because they do not touch a developer's working tree. + */ + sync?: SyncResult } // ─── Provider (full surface) ───