diff --git a/src/utils/display.ts b/src/utils/display.ts index e6fd81d..0b5fb4f 100644 --- a/src/utils/display.ts +++ b/src/utils/display.ts @@ -374,7 +374,7 @@ const summaryStatusConfig: Record = { icon: pc.cyan('◇'), label: 'managed', color: pc.cyan, - description: 'package/config files handled separately', + description: 'package.jsons & cella.config.ts handled separately', }, ignored: { ...statusConfig.ignored, description: 'protected by ignored config' }, }; diff --git a/src/utils/git.ts b/src/utils/git.ts index 6f990ad..dc348be 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -848,7 +848,8 @@ async function commitObjectExists(cwd: string, sha: string): Promise { /** * Get the root (parentless) commit of a ref's history. A create-cella scaffold has a - * single rootless "Initial commit". Returns null when no root is found. + * single rootless "Initial commit" whose tree is a frozen snapshot of the upstream + * template at scaffold time. Returns null when no root is found. */ async function getRootCommit(cwd: string, ref: string): Promise { const out = await git(['rev-list', '--max-parents=0', ref], cwd, { ignoreErrors: true }); @@ -856,6 +857,97 @@ async function getRootCommit(cwd: string, ref: string): Promise { return roots.length > 0 ? roots[roots.length - 1] : null; } +/** Max upstream commits scored when inferring a scaffold base by tree similarity. */ +const SCAFFOLD_CANDIDATE_LIMIT = 400; + +/** Concurrent diff-tree scoring calls during scaffold-base inference. */ +const SCAFFOLD_SCORE_CONCURRENCY = 10; + +/** + * Reject an inferred scaffold base when more than this fraction of the root snapshot's + * paths differ from the best-matching upstream commit. The template cleaner rewrites a + * fixed handful of files and deselected optional modules remove a few folders, so a real + * scaffold stays far below this; a hand-rolled repo blows past it. + */ +const SCAFFOLD_MATCH_MAX_DIFF_RATIO = 0.3; + +/** + * Infer the upstream commit a scaffold was created from, by tree similarity. + * + * A create-cella scaffold's root commit is a frozen snapshot of the upstream tree at + * scaffold time: the template cleaner only rewrites a handful of files (project name, + * ports, changelog) and drops deselected module folders, so the vast majority of paths + * stay byte-identical to exactly one upstream commit. Diff the root tree against recent + * upstream commits and take the closest match — no provenance file needed. + * + * Candidates are bounded to upstream commits no newer than the root commit (plus a + * clock-skew margin): the scaffold cannot come from a commit that didn't exist yet. + * Neighboring candidates can tie when the commits between them only touch files the + * scaffold rewrote anyway (upstream release commits do exactly this) — either tie is a + * correct base for the 3-way merge, and newest wins. + * + * Returns null when no candidate matches convincingly (see SCAFFOLD_MATCH_MAX_DIFF_RATIO). + */ +async function inferScaffoldBase(cwd: string, headRef: string, upstreamRef: string): Promise { + const root = await getRootCommit(cwd, headRef); + if (!root) return null; + + const rootDate = await git(['show', '-s', '--format=%cI', root], cwd, { ignoreErrors: true }); + if (!rootDate) return null; + // A day of margin absorbs clock skew between the scaffolding machine and upstream committers. + const before = new Date(new Date(rootDate).getTime() + 24 * 60 * 60 * 1000).toISOString(); + + const revList = await git( + ['rev-list', `--max-count=${SCAFFOLD_CANDIDATE_LIMIT}`, `--before=${before}`, upstreamRef], + cwd, + { ignoreErrors: true }, + ); + const candidates = revList.split('\n').filter(Boolean); + if (candidates.length === 0) return null; + + const totalPaths = (await git(['ls-tree', '-r', '--name-only', root], cwd)).split('\n').filter(Boolean).length; + if (totalPaths === 0) return null; + + // Score = differing paths between candidate and root snapshot. rev-list is newest-first + // and strict `<` keeps the first (newest) candidate on ties. + let best: { sha: string; diff: number } | null = null; + outer: for (let i = 0; i < candidates.length; i += SCAFFOLD_SCORE_CONCURRENCY) { + const chunk = candidates.slice(i, i + SCAFFOLD_SCORE_CONCURRENCY); + const scores = await Promise.all( + chunk.map(async (sha) => { + try { + const out = await git(['diff-tree', '-r', '--name-only', sha, root], cwd); + return { sha, diff: out ? out.split('\n').length : 0 }; + } catch { + return { sha, diff: Number.POSITIVE_INFINITY }; + } + }), + ); + for (const score of scores) { + if (!best || score.diff < best.diff) best = score; + if (best.diff === 0) break outer; // byte-identical snapshot — cannot do better + } + } + + if (!best || best.diff / totalPaths > SCAFFOLD_MATCH_MAX_DIFF_RATIO) return null; + return best.sha; +} + +/** + * Read the scaffold provenance trailer from the fork's root commit message. + * + * create-cella stamps `Cella-Base: ` on the initial commit, recording the exact + * upstream commit the scaffold snapshot was taken from. Riding in commit history, it + * travels with push/clone — unlike a local ref — and needs no file in the worktree. + */ +async function readRootTrailerBase(cwd: string, headRef: string): Promise { + const root = await getRootCommit(cwd, headRef); + if (!root) return null; + const message = await git(['show', '-s', '--format=%B', root], cwd, { ignoreErrors: true }); + const match = message.match(/^cella-base:\s*([0-9a-f]{40})$/im); + return match ? match[1].toLowerCase() : null; +} + /** * Ensure a native git merge-base exists between the fork and upstream. * @@ -863,40 +955,48 @@ async function getRootCommit(cwd: string, ref: string): Promise { * histories, so `git merge-base` finds nothing and every sync fails at the very first step. * This bootstraps ancestry non-destructively: * 1. If a native merge-base already exists, do nothing (the common case). - * 2. Otherwise resolve the logical base commit: the stored `refs/cella/last-sync` ref, else - * the `cella.manifest.json` provenance committed by the last sync (or create-cella scaffold). + * 2. Otherwise resolve the logical base commit: sources are tried in order and the first + * one whose commit actually exists after the upstream fetch wins — the sync-point + * record (`refs/cella/last-sync` ref, then committed/worktree `cella.manifest.json`), + * then the scaffold origin (the root commit's `Cella-Base:` trailer, then tree-similarity + * inference as reseed fallback for scaffolds whose trailer is absent or stale). * 3. Graft the fork's root commit onto that base with `git replace --graft`, so every native * git operation (merge-base and the 3-way merge itself) sees correct ancestry. The replace * ref is local-only and never pushed, so the fork's own published history stays clean. * - * Throws an actionable error when no base can be determined or the recorded base is missing. + * Throws an actionable error when no valid base can be determined. */ export async function ensureSyncBase(cwd: string, headRef: string, upstreamRef: string): Promise { // Native ancestry already present — nothing to bootstrap. const nativeBase = await git(['merge-base', headRef, upstreamRef], cwd, { ignoreErrors: true }); if (nativeBase) return; - // Ref first (the documented manual seed), then the manifest committed at HEAD, then the - // working-tree manifest as a last resort for repos in odd intermediate states. - const baseSha = - (await getStoredSyncRef(cwd)) ?? (await readManifestBaseAtRef(cwd, headRef)) ?? (await readManifestBase(cwd)); + const sources: Array<() => Promise> = [ + () => getStoredSyncRef(cwd), + () => readManifestBaseAtRef(cwd, headRef), + () => readManifestBase(cwd), + () => readRootTrailerBase(cwd, headRef), + () => inferScaffoldBase(cwd, headRef, upstreamRef), + ]; + + let baseSha: string | null = null; + for (const source of sources) { + const sha = await source(); + if (sha && (await commitObjectExists(cwd, sha))) { + baseSha = sha; + break; + } + } if (!baseSha) { throw new Error( - `no common ancestor between the fork and '${upstreamRef}', and no sync base recorded.\n\n` + + `no common ancestor between the fork and '${upstreamRef}', and no sync base could be determined.\n\n` + 'This fork has unrelated history with upstream — typical for a create-cella scaffold, or after\n' + - 'upstream squashed its history. Seed the base with the upstream commit the fork was created from,\n' + - 'then re-run sync:\n\n' + + 'upstream squashed its history — and no recorded or inferable base commit exists after fetching\n' + + 'upstream. Seed it manually with the upstream commit the fork was created from, then re-run sync:\n\n' + ' git update-ref refs/cella/last-sync \n', ); } - if (!(await commitObjectExists(cwd, baseSha))) { - throw new Error( - `recorded sync base ${baseSha.slice(0, 12)} is not present after fetching '${upstreamRef}'.\n` + - 'It may belong to a different upstream, or have been dropped by an upstream history rewrite.', - ); - } - const root = await getRootCommit(cwd, headRef); if (!root) throw new Error("could not determine the fork's root commit to bootstrap sync ancestry"); diff --git a/src/utils/module-territory.ts b/src/utils/module-territory.ts index d20b739..e5a7042 100644 --- a/src/utils/module-territory.ts +++ b/src/utils/module-territory.ts @@ -3,19 +3,22 @@ * * Each module declares ownership via a `registerModule({ owner, ... })` call in * its `*-module.ts` file. Modules with `owner: 'app'` are fork-specific: their - * folders are fork territory and must never be added, modified, or deleted by - * upstream during sync — nor offered back upstream by the contributions service. + * source folders, plus matching frontend static asset folders when present, are + * fork territory and must never be added, modified, or deleted by upstream during + * sync — nor offered back upstream by the contributions service. * * Parsing is syntax-only (no type-checker), so it is fast and tolerant of * formatting, comments, and property order. Built on ts-morph so the same * project/AST approach can be reused for other source-introspection tasks. */ -import { relative, sep } from 'node:path'; +import { existsSync } from 'node:fs'; +import { basename, join, relative, sep } from 'node:path'; import { Project, SyntaxKind } from 'ts-morph'; /** Glob patterns (relative to repo root) for module definition files. */ const MODULE_FILE_GLOBS = ['backend/src/modules/*/*-module.ts', 'frontend/src/modules/*/*-module.ts']; +const FRONTEND_STATIC_ROOT = 'frontend/public/static'; /** * Read the string value of an object literal's `owner` property, if present. @@ -27,7 +30,7 @@ function readOwner(call: import('ts-morph').CallExpression): string | undefined } /** - * Scan a repository for module folders owned by the app (`owner: 'app'`). + * Scan a repository for module territory owned by the app (`owner: 'app'`). * * @param repoPath - Absolute path to the repository root to scan. * @returns Repo-relative folder paths (POSIX separators), deduplicated. @@ -42,6 +45,7 @@ export function resolveAppModuleFolders(repoPath: string): string[] { const sourceFiles = project.addSourceFilesAtPaths(MODULE_FILE_GLOBS.map((glob) => `${repoPath}/${glob}`)); const folders = new Set(); + const moduleNames = new Set(); for (const sourceFile of sourceFiles) { const ownsApp = sourceFile @@ -52,6 +56,14 @@ export function resolveAppModuleFolders(repoPath: string): string[] { const folder = relative(repoPath, sourceFile.getDirectoryPath()).split(sep).join('/'); folders.add(folder); + moduleNames.add(basename(folder)); + } + + for (const moduleName of moduleNames) { + const staticFolder = `${FRONTEND_STATIC_ROOT}/${moduleName}`; + if (existsSync(join(repoPath, staticFolder))) { + folders.add(staticFolder); + } } return [...folders]; diff --git a/tests/e2e/ensure-sync-base.test.ts b/tests/e2e/ensure-sync-base.test.ts new file mode 100644 index 0000000..cb397d1 --- /dev/null +++ b/tests/e2e/ensure-sync-base.test.ts @@ -0,0 +1,186 @@ +/** + * E2E tests for scaffold-base inference in ensureSyncBase. + * + * A create-cella scaffold has a rootless initial commit with no shared history with + * upstream and no recorded provenance. ensureSyncBase must infer the upstream commit the + * scaffold snapshot was taken from by tree similarity, graft ancestry onto it, and record + * it as refs/cella/last-sync — or fail with the manual-seed error when the fork's root + * does not resemble upstream at all. + */ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ensureSyncBase } from '../../src/utils/git'; + +const UPSTREAM_REMOTE = 'cella-upstream'; +const GIT_USER = 'git config user.email "test@cellajs.com" && git config user.name "Cella Test"'; + +function exec(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} + +function write(repoPath: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const full = path.join(repoPath, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } +} + +function commitAll(repoPath: string, message: string): string { + exec('git add -A', repoPath); + exec(`git commit -m "${message}"`, repoPath); + return exec('git rev-parse HEAD', repoPath); +} + +/** Enough files that scaffold edits stay well under the inference confidence threshold. */ +function templateFiles(): Record { + const files: Record = { + 'package.json': '{"name": "cella", "version": "1.0.0"}\n', + 'README.md': '# Cella\n', + 'CHANGELOG.md': '# Changelog\n\n## 1.0.0\n- everything\n', + }; + for (let i = 0; i < 20; i++) { + files[`backend/src/module-${i}.ts`] = `export const module${i} = ${i};\n`; + } + files['frontend/src/optional/widget.ts'] = 'export const widget = true;\n'; + files['frontend/src/optional/helper.ts'] = 'export const helper = true;\n'; + return files; +} + +/** + * Simulate `create-cella`: snapshot the upstream tree at `ref` into a fresh repo with a + * rootless initial commit, applying template-cleaner-style edits (rewrite project files, + * drop a deselected module folder, add a generated config). When `trailerSha` is given, + * stamp it as the `Cella-Base:` provenance trailer like create-cella does. + */ +function scaffoldFork(upstreamPath: string, ref: string, forkPath: string, trailerSha?: string): void { + fs.mkdirSync(forkPath, { recursive: true }); + exec(`git archive ${ref} | tar -x -C "${forkPath}"`, upstreamPath); + + write(forkPath, { + 'package.json': '{"name": "my-app", "version": "0.0.0"}\n', + 'README.md': '# My App\n', + 'CHANGELOG.md': '# Changelog\n', + 'config.generated.ts': 'export const port = 4000;\n', + }); + fs.rmSync(path.join(forkPath, 'frontend/src/optional'), { recursive: true, force: true }); + + exec('git init -b main', forkPath); + exec(GIT_USER, forkPath); + exec('git add -A', forkPath); + const trailer = trailerSha ? ` -m "Cella-Base: ${trailerSha}"` : ''; + exec(`git commit -m "Initial commit"${trailer}`, forkPath); + + exec(`git remote add ${UPSTREAM_REMOTE} "${upstreamPath}"`, forkPath); + exec(`git fetch ${UPSTREAM_REMOTE}`, forkPath); +} + +describe('ensureSyncBase scaffold inference', () => { + let testDir: string; + let upstreamPath: string; + let forkPath: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cella-base-')); + upstreamPath = path.join(testDir, 'upstream'); + forkPath = path.join(testDir, 'fork'); + + fs.mkdirSync(upstreamPath); + exec('git init -b main', upstreamPath); + exec(GIT_USER, upstreamPath); + write(upstreamPath, templateFiles()); + commitAll(upstreamPath, 'Initial commit'); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('infers the scaffold base by tree similarity and grafts ancestry onto it', async () => { + // Upstream evolves: the scaffold is taken at `base`, then upstream moves on. + write(upstreamPath, { 'backend/src/module-0.ts': 'export const module0 = "changed";\n' }); + const base = commitAll(upstreamPath, 'change module-0'); + + scaffoldFork(upstreamPath, base, forkPath); + + // Upstream commits after the scaffold — inference must not pick these. + write(upstreamPath, { 'backend/src/module-1.ts': 'export const module1 = "newer";\n' }); + commitAll(upstreamPath, 'change module-1'); + write(upstreamPath, { 'backend/src/module-2.ts': 'export const module2 = "newest";\n' }); + commitAll(upstreamPath, 'change module-2'); + exec(`git fetch ${UPSTREAM_REMOTE}`, forkPath); + + // Fork-local work after scaffolding must not affect inference (it scores the root commit). + write(forkPath, { 'frontend/src/app.ts': 'export const app = true;\n' }); + commitAll(forkPath, 'local work'); + + await ensureSyncBase(forkPath, 'HEAD', `${UPSTREAM_REMOTE}/main`); + + expect(exec('git rev-parse refs/cella/last-sync', forkPath)).toBe(base); + expect(exec(`git merge-base HEAD ${UPSTREAM_REMOTE}/main`, forkPath)).toBe(base); + }); + + it('resolves ties toward the newest candidate when commits only touch scaffold-rewritten files', async () => { + // Scaffold from `older`; a release-style commit `newer` touches only files the + // template cleaner rewrites, so both candidates score identically — newest wins, + // and either is a correct 3-way merge base. + const older = exec('git rev-parse HEAD', upstreamPath); + scaffoldFork(upstreamPath, older, forkPath); + + write(upstreamPath, { + 'package.json': '{"name": "cella", "version": "1.1.0"}\n', + 'CHANGELOG.md': '# Changelog\n\n## 1.1.0\n- release\n', + }); + const newer = commitAll(upstreamPath, 'chore: release 1.1.0'); + exec(`git fetch ${UPSTREAM_REMOTE}`, forkPath); + + await ensureSyncBase(forkPath, 'HEAD', `${UPSTREAM_REMOTE}/main`); + + expect(exec('git rev-parse refs/cella/last-sync', forkPath)).toBe(newer); + }); + + it('prefers the Cella-Base trailer over tree inference', async () => { + // Trailer points at the initial upstream commit while the snapshot tree matches a + // newer commit — the exact recorded provenance must win over similarity guessing. + const recorded = exec('git rev-parse HEAD', upstreamPath); + write(upstreamPath, { 'backend/src/module-0.ts': 'export const module0 = "changed";\n' }); + const newer = commitAll(upstreamPath, 'change module-0'); + + scaffoldFork(upstreamPath, newer, forkPath, recorded); + + await ensureSyncBase(forkPath, 'HEAD', `${UPSTREAM_REMOTE}/main`); + + expect(exec('git rev-parse refs/cella/last-sync', forkPath)).toBe(recorded); + }); + + it('falls through to inference when the trailer SHA is not a known commit', async () => { + // A stale trailer (upstream squashed its history, or the fork points at a different + // upstream) must not hard-fail — the reseed fallback infers the base instead. + const base = exec('git rev-parse HEAD', upstreamPath); + scaffoldFork(upstreamPath, base, forkPath, 'a'.repeat(40)); + + await ensureSyncBase(forkPath, 'HEAD', `${UPSTREAM_REMOTE}/main`); + + expect(exec('git rev-parse refs/cella/last-sync', forkPath)).toBe(base); + }); + + it('rejects a fork whose root does not resemble any upstream snapshot', async () => { + fs.mkdirSync(forkPath); + exec('git init -b main', forkPath); + exec(GIT_USER, forkPath); + write(forkPath, { + 'index.js': 'console.log("hand-rolled");\n', + 'lib/util.js': 'module.exports = {};\n', + }); + commitAll(forkPath, 'Initial commit'); + exec(`git remote add ${UPSTREAM_REMOTE} "${upstreamPath}"`, forkPath); + exec(`git fetch ${UPSTREAM_REMOTE}`, forkPath); + + await expect(ensureSyncBase(forkPath, 'HEAD', `${UPSTREAM_REMOTE}/main`)).rejects.toThrow( + /no sync base could be determined/, + ); + }); +}); diff --git a/tests/module-territory.test.ts b/tests/module-territory.test.ts index a9e7b74..ef43065 100644 --- a/tests/module-territory.test.ts +++ b/tests/module-territory.test.ts @@ -2,7 +2,7 @@ * Unit tests for app-module territory resolution. * * Builds a temporary repo fixture with module files declaring different owners - * and asserts that only `owner: 'app'` module folders are returned. + * and asserts that only `owner: 'app'` module territory is returned. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -31,7 +31,7 @@ afterEach(() => { }); describe('resolveAppModuleFolders', () => { - it('returns folders for app-owned modules only', () => { + it('returns source folders for app-owned modules only', () => { writeModule('backend', 'projects', 'app'); writeModule('backend', 'attachment', 'cella'); writeModule('frontend', 'projects', 'app'); @@ -42,6 +42,23 @@ describe('resolveAppModuleFolders', () => { expect(folders.sort()).toEqual(['backend/src/modules/projects', 'frontend/src/modules/projects']); }); + it('returns existing static asset folders for app-owned modules', () => { + writeModule('frontend', 'marketing', 'app'); + writeModule('frontend', 'ui', 'cella'); + mkdirSync(join(repoPath, 'frontend', 'public', 'static', 'marketing'), { recursive: true }); + mkdirSync(join(repoPath, 'frontend', 'public', 'static', 'ui'), { recursive: true }); + + const folders = resolveAppModuleFolders(repoPath); + + expect(folders.sort()).toEqual(['frontend/public/static/marketing', 'frontend/src/modules/marketing']); + }); + + it('does not return missing static asset folders for app-owned modules', () => { + writeModule('frontend', 'marketing', 'app'); + + expect(resolveAppModuleFolders(repoPath)).toEqual(['frontend/src/modules/marketing']); + }); + it('returns an empty array when no app modules exist', () => { writeModule('backend', 'attachment', 'cella'); writeModule('frontend', 'page', 'cella');