diff --git a/src/services/analyze.ts b/src/services/analyze.ts index 8c6a998..b9d09ce 100644 --- a/src/services/analyze.ts +++ b/src/services/analyze.ts @@ -13,6 +13,7 @@ import { createSpinner, type LinkOptions, printAnalysisFileGroups, + printMaskingPinWarning, printSummary, spinnerSuccess, spinnerText, @@ -141,6 +142,9 @@ export async function runAnalyze(config: RuntimeConfig): Promise { // Print summary at the end printSummary(result.summary, 'analysis summary'); + // Surface pins that would silently freeze a file at the old upstream on the next sync + printMaskingPinWarning(result.files); + // Write log file if requested if (config.logFile) { const logPath = writeLogFile(config.forkPath, result.files); diff --git a/src/services/sync.ts b/src/services/sync.ts index 0e0a205..87a4a8e 100644 --- a/src/services/sync.ts +++ b/src/services/sync.ts @@ -19,6 +19,7 @@ import { import { createSpinner, printFlagWarnings, + printMaskingPinWarning, printSummary, printSyncComplete, spinnerFail, @@ -163,6 +164,9 @@ export async function runSync( // Print summary only (no file lists for sync) printSummary(result.summary, 'merge summary'); + // Surface pins that silently froze a file at the old upstream (no conflict, no type error) + printMaskingPinWarning(result.files); + // Write log file if requested if (config.logFile) { const logPath = writeLogFile(config.forkPath, result.files); diff --git a/src/utils/display.ts b/src/utils/display.ts index b29325f..6e9c69b 100644 --- a/src/utils/display.ts +++ b/src/utils/display.ts @@ -570,6 +570,44 @@ export function printSyncComplete(result: MergeResult, options: { stagedBranch?: * Explains what each active flag did and its consequence, and adds a shared * caution to cherry-pick deliberately when either is used. */ +/** + * A pinned file whose fork content is byte-identical to the previous upstream (status + * `behind`) never actually diverged — the pin is silently freezing it at the old upstream + * version and dropping upstream's new changes. Because the fork copy equals the old + * upstream, this produces no merge conflict and no type error, so it slips through unseen + * (e.g. a pinned nav-config losing new upstream entries). These are the pins worth a look. + */ +export function findMaskingPins(files: AnalyzedFile[]): AnalyzedFile[] { + return files.filter( + (file) => file.isPinned && file.status === 'behind' && file.existsInFork && file.existsInUpstream, + ); +} + +/** + * Warn when pins are masking upstream changes (see `findMaskingPins`). Silent by design + * when there is nothing to report, so it is safe to call unconditionally after a summary. + */ +export function printMaskingPinWarning(files: AnalyzedFile[]): void { + const masking = findMaskingPins(files); + if (masking.length === 0) return; + + const many = masking.length > 1; + console.info(); + console.info( + `${warningMark} ${pc.yellow( + `${masking.length} pinned ${many ? 'files match' : 'file matches'} the previous upstream but changed upstream —`, + )}`, + ); + console.info(pc.yellow(' the pin keeps the old fork copy and silently drops those upstream changes:')); + for (const file of masking) { + console.info(pc.dim(` ⨀ ${file.path}`)); + } + console.info( + pc.yellow(' If the fork never customized these, unpin them in cella/cella.config.ts to take upstream.'), + ); + console.info(); +} + export function printFlagWarnings(options: { hard?: boolean; unpinned?: boolean }): void { const { hard, unpinned } = options; if (!hard && !unpinned) return; diff --git a/src/utils/managed-files.ts b/src/utils/managed-files.ts index 890ddd3..2ae4b3b 100644 --- a/src/utils/managed-files.ts +++ b/src/utils/managed-files.ts @@ -5,6 +5,26 @@ /** Sync config path, relative to the fork repo root. Fork-owned, never synced from upstream. */ export const CONFIG_FILE = 'cella/cella.config.ts'; +/** + * Legacy config path, before the config moved into `cella/`. Kept so the one sync that + * performs the root→`cella/` move still recognizes the file as managed at its old path: + * during that move git may present the change under the old path (or as a delete+add that + * skips rename detection), and an exact match on the new path alone would let it fall + * through to a normal — conflicting — merge. Harmless to match forever; it can only appear + * on the move sync of a not-yet-migrated fork. + */ +export const LEGACY_CONFIG_FILE = 'cella.config.ts'; + +/** Every path the sync config has lived at. Compared against to always-ignore across the move. */ +export const CONFIG_FILE_PATHS: readonly string[] = [CONFIG_FILE, LEGACY_CONFIG_FILE]; + +/** + * Check if a file path is the sync config, at its current or legacy (pre-`cella/`) path. + */ +export function isConfigFile(filePath: string): boolean { + return CONFIG_FILE_PATHS.includes(filePath); +} + /** * Check if a file path is a package.json file. */ @@ -16,5 +36,5 @@ export function isPackageJson(filePath: string): boolean { * Check if a file path is managed by cella outside normal file sync categories. */ export function isManagedFile(filePath: string): boolean { - return isPackageJson(filePath) || filePath === 'pnpm-lock.yaml' || filePath === CONFIG_FILE; + return isPackageJson(filePath) || filePath === 'pnpm-lock.yaml' || isConfigFile(filePath); } diff --git a/tests/managed-files.test.ts b/tests/managed-files.test.ts new file mode 100644 index 0000000..d11a414 --- /dev/null +++ b/tests/managed-files.test.ts @@ -0,0 +1,43 @@ +/** + * Unit tests for managed-file detection. + * + * Covers `isManagedFile` / `isConfigFile` recognizing the sync config at both its + * current (`cella/cella.config.ts`) and legacy root (`cella.config.ts`) path, so the + * one sync that moves the config into `cella/` does not spuriously conflict. + */ +import { describe, expect, it } from 'vitest'; +import { CONFIG_FILE, isConfigFile, isManagedFile, LEGACY_CONFIG_FILE } from '../src/utils/managed-files'; + +describe('isConfigFile', () => { + it('matches the current config path', () => { + expect(isConfigFile(CONFIG_FILE)).toBe(true); + expect(isConfigFile('cella/cella.config.ts')).toBe(true); + }); + + it('matches the legacy root config path (pre-cella/ move)', () => { + expect(isConfigFile(LEGACY_CONFIG_FILE)).toBe(true); + expect(isConfigFile('cella.config.ts')).toBe(true); + }); + + it('does not match unrelated paths', () => { + expect(isConfigFile('cella/cella.manifest.json')).toBe(false); + expect(isConfigFile('src/cella.config.ts')).toBe(false); + }); +}); + +describe('isManagedFile', () => { + it('treats the config as managed at both current and legacy paths', () => { + expect(isManagedFile('cella/cella.config.ts')).toBe(true); + expect(isManagedFile('cella.config.ts')).toBe(true); + }); + + it('still treats package.json and the lockfile as managed', () => { + expect(isManagedFile('package.json')).toBe(true); + expect(isManagedFile('backend/package.json')).toBe(true); + expect(isManagedFile('pnpm-lock.yaml')).toBe(true); + }); + + it('does not treat ordinary files as managed', () => { + expect(isManagedFile('frontend/src/nav-config.tsx')).toBe(false); + }); +}); diff --git a/tests/masking-pins.test.ts b/tests/masking-pins.test.ts new file mode 100644 index 0000000..865acc1 --- /dev/null +++ b/tests/masking-pins.test.ts @@ -0,0 +1,53 @@ +/** + * Unit tests for masking-pin detection. + * + * A pin whose fork content is byte-identical to the previous upstream (`behind`) silently + * freezes the file at the old version and drops upstream's changes — no conflict, no type + * error. `findMaskingPins` isolates exactly those so the sync/analyze output can flag them. + */ +import { describe, expect, it } from 'vitest'; +import type { AnalyzedFile, FileStatus } from '../src/config/types'; +import { findMaskingPins } from '../src/utils/display'; + +function file(overrides: Partial & { path: string; status: FileStatus }): AnalyzedFile { + return { + isIgnored: false, + isPinned: false, + existsInFork: true, + existsInUpstream: true, + ...overrides, + }; +} + +describe('findMaskingPins', () => { + it('flags a pinned file that is behind upstream (fork == old upstream)', () => { + const masking = file({ path: 'frontend/src/nav-config.tsx', status: 'behind', isPinned: true }); + expect(findMaskingPins([masking])).toEqual([masking]); + }); + + it('ignores a legitimately diverged pin (fork genuinely changed)', () => { + const diverged = file({ path: 'a.ts', status: 'pinned', isPinned: true }); + expect(findMaskingPins([diverged])).toEqual([]); + }); + + it('ignores behind files that are not pinned (upstream is taken normally)', () => { + const behind = file({ path: 'b.ts', status: 'behind', isPinned: false }); + expect(findMaskingPins([behind])).toEqual([]); + }); + + it('ignores a pinned file the fork deleted or upstream removed', () => { + const forkDeleted = file({ path: 'c.ts', status: 'behind', isPinned: true, existsInFork: false }); + const upstreamGone = file({ path: 'd.ts', status: 'behind', isPinned: true, existsInUpstream: false }); + expect(findMaskingPins([forkDeleted, upstreamGone])).toEqual([]); + }); + + it('returns only the masking pins from a mixed set', () => { + const files = [ + file({ path: 'keep.ts', status: 'behind', isPinned: true }), + file({ path: 'diverged.ts', status: 'pinned', isPinned: true }), + file({ path: 'plain.ts', status: 'behind' }), + file({ path: 'identical.ts', status: 'identical', isPinned: true }), + ]; + expect(findMaskingPins(files).map((f) => f.path)).toEqual(['keep.ts']); + }); +});