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
4 changes: 4 additions & 0 deletions src/services/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createSpinner,
type LinkOptions,
printAnalysisFileGroups,
printMaskingPinWarning,
printSummary,
spinnerSuccess,
spinnerText,
Expand Down Expand Up @@ -141,6 +142,9 @@ export async function runAnalyze(config: RuntimeConfig): Promise<MergeResult> {
// 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);
Expand Down
4 changes: 4 additions & 0 deletions src/services/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import {
createSpinner,
printFlagWarnings,
printMaskingPinWarning,
printSummary,
printSyncComplete,
spinnerFail,
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions src/utils/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 21 additions & 1 deletion src/utils/managed-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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);
}
43 changes: 43 additions & 0 deletions tests/managed-files.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
53 changes: 53 additions & 0 deletions tests/masking-pins.test.ts
Original file line number Diff line number Diff line change
@@ -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<AnalyzedFile> & { 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']);
});
});