From 7ef0a7dcd222cea91e4c080b20d20cc9890f0e61 Mon Sep 17 00:00:00 2001 From: flipvanhaaren Date: Wed, 2 Sep 2026 11:44:47 +0200 Subject: [PATCH 1/2] feat(analyze): surface pinned files that are behind upstream Pinned/ignored files win whole-file, so when upstream also changed one since the last sync its hunks are dropped without a trace: analyze showed the file like any other protected file and sync said nothing. A pinned tailwind.css missed seven upstream @utility rules across three syncs this way. - analyze-core: flag protected files both sides changed (`upstreamChanged`) and size the dropped change with one batched numstat (`upstreamChangedLines`) - display: new "protected but behind upstream" section (supersedes the plain pinned group), and the sync summary lists `protectedConflicts` with the same hint right when the drop happens - merge-engine: record `protectedConflicts` (batch-restored protected files upstream changed + conflicts auto-resolved to fork) on the MergeResult - analyze --list/--json: include these files in the all/protected scopes and emit the new fields; README documents the section and its last-sync-point limit - git: shared `getDiffStat` (lifted from contributions) Co-Authored-By: Claude Fable 5.1 --- README.md | 19 +++- src/config/types.ts | 12 +++ src/services/analyze-core.ts | 46 +++++++- src/services/analyze.ts | 9 +- src/services/contributions.ts | 28 +---- src/services/merge-engine.ts | 13 ++- src/utils/display.ts | 137 ++++++++++++++++++----- src/utils/git.ts | 35 ++++++ tests/protected-behind.test.ts | 192 +++++++++++++++++++++++++++++++++ 9 files changed, 428 insertions(+), 63 deletions(-) create mode 100644 tests/protected-behind.test.ts diff --git a/README.md b/README.md index e24afe6..b91d412 100644 --- a/README.md +++ b/README.md @@ -144,13 +144,28 @@ During analysis and sync, files are displayed with status indicators: | ◇ | `managed` | Package/config file changed | Handled separately by cella | | ⨂ | `ignored` | Protected by ignored config | Excluded from sync | | ✓ | `identical` | Fork matches upstream | No action needed | -| ↑ | `ahead` | Fork changed (pinned) | Protected, keeping fork | +| ↑ | `ahead` | Fork changed (pinned), upstream did not | Protected, keeping fork | | ! | `drifted` | Fork changed, not protected | At risk, consider pinning | | ↓ | `behind` | Upstream has changes | Will sync from upstream | | ⇅ | `diverged` | Both sides changed | Will merge from upstream | -| ⨀ | `pinned` | Both changed, fork wins | Protected, keeping fork | +| ⨀ | `pinned` | Both changed, fork wins | Protected, keeping fork — review, see below | | + | `local` | Only in fork, never in upstream | No action needed | +### Protected but behind upstream + +A pinned or ignored file wins whole-file: when upstream also changed it since the last sync, +upstream's hunks are dropped, not merged. That is easy to miss (a pinned stylesheet quietly +missing new upstream utilities that synced components rely on), so both commands call it out: + +- `analyze` lists them in a `⚠ protected but behind upstream` section (pinned files as ⨀, + ignored as ⨂) with the number of lines upstream changed; `--list`/`--json` include them in + `--scope all` and `--scope protected` (`--json` adds `upstreamChanged` and `upstreamChangedLines`). +- `sync` prints the same list at the end of its summary, right when the drop happens. + +Diff each against upstream (`cella analyze --open-diff `) and adopt what you need. The +check is relative to the last sync point: a drop that happened in an earlier sync only shows up +as plain `ahead` afterwards, so review the list on every sync. + ## Package.json sync `packageJsonSync` controls which package.json sections sync from upstream: diff --git a/src/config/types.ts b/src/config/types.ts index c085ec6..ff02113 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -320,6 +320,13 @@ export interface AnalyzedFile { upstreamCommit?: string; /** For renamed files: the original path before rename */ renamedFrom?: string; + /** + * Protected (pinned/ignored) files only: both sides changed since the merge-base. The fork + * side wins whole-file, so upstream's changes to this path are dropped by the sync. + */ + upstreamChanged?: boolean; + /** For `upstreamChanged` files: lines upstream changed since the merge-base (undefined for binary) */ + upstreamChangedLines?: number; } /** Summary counts by status */ @@ -367,4 +374,9 @@ export interface MergeResult { }>; /** Files that were auto-merged by git (diverged without remaining conflicts) */ autoMergedFiles?: string[]; + /** + * Protected (pinned/ignored) files resolved to the fork side although upstream also changed + * them: upstream's hunks were dropped, not merged. Review each against upstream. + */ + protectedConflicts?: string[]; } diff --git a/src/services/analyze-core.ts b/src/services/analyze-core.ts index de5223b..5041cb1 100644 --- a/src/services/analyze-core.ts +++ b/src/services/analyze-core.ts @@ -12,10 +12,14 @@ * * The result fields `existsInFork`/`existsInUpstream` map to local/incoming * respectively (named for the sync direction, reused as-is for contributions). + * + * Protected files (pinned/ignored) additionally get `upstreamChanged` when both sides + * changed since the merge-base: the local side wins whole-file there, so incoming's hunks + * are dropped silently unless surfaced. */ import type { AnalyzedFile, FileStatus } from '../config/types'; -import { getFileChangeInfo, getFileChanges, getFileHashesAtRef } from '../utils/git'; +import { getDiffStat, getFileChangeInfo, getFileChanges, getFileHashesAtRef } from '../utils/git'; /** Predicates and options that steer classification (direction-specific). */ export interface AnalyzePredicates { @@ -86,6 +90,9 @@ export async function analyzeRefs( const analyzedFiles: AnalyzedFile[] = []; // Track old paths that have been handled as part of a rename const handledOldPaths = new Set(); + // Protected files flagged `upstreamChanged` via a rename: their numstat at the new path + // would count the whole file as added, so they get no line count. + const renamedProtected = new Set(); let processed = 0; for (const filePath of allFiles) { @@ -149,9 +156,15 @@ export async function analyzeRefs( // pinned list may still reference the old path. const oldPathPinned = predicates.isPinned(oldPath); + let upstreamChanged: boolean | undefined; if (fileIsPinned || oldPathPinned || predicates.isIgnored(oldPath)) { // Pinned or ignored - keep local's version at the new path status = 'pinned'; + // Local edited the old file and incoming changed its content beyond the rename + if (forkModifiedOld && upstreamHash !== baseOldHash) { + upstreamChanged = true; + renamedProtected.add(filePath); + } } else if (!oldPathInFork) { // Old path doesn't exist locally (already deleted or moved it) // Treat as normal behind - let git's merge handle it @@ -175,6 +188,7 @@ export async function analyzeRefs( existsInFork: inFork, existsInUpstream: true, renamedFrom, + upstreamChanged, }); continue; } @@ -236,6 +250,14 @@ export async function analyzeRefs( } } + // Protected file where BOTH sides changed since the merge-base (a local edit or deletion, + // and incoming content that differs from base). Local wins whole-file, so incoming's + // hunks are dropped — flag it so analyze/sync can surface the loss. A protected file + // that only changed locally is plain `ahead`; one that only changed incoming (`behind` + // with the local copy still equal to base) is reported separately as a masking pin. + const upstreamChanged = + (fileIsPinned || fileIsIgnored) && inUpstream && upstreamHash !== baseHash && forkHash !== baseHash; + analyzedFiles.push({ path: filePath, status, @@ -243,9 +265,27 @@ export async function analyzeRefs( isPinned: fileIsPinned, existsInFork: inFork, existsInUpstream: inUpstream, + upstreamChanged: upstreamChanged || undefined, }); } + // Size the dropped incoming changes: one numstat call limited to the flagged paths. + const flagged = analyzedFiles.filter((file) => file.upstreamChanged && !renamedProtected.has(file.path)); + if (flagged.length > 0) { + const stat = await getDiffStat( + repoPath, + mergeBaseRef, + incomingRef, + flagged.map((file) => file.path), + ); + for (const file of flagged) { + const entry = stat.get(file.path); + if (entry && entry.additions !== null && entry.deletions !== null) { + file.upstreamChangedLines = entry.additions + entry.deletions; + } + } + } + return analyzedFiles; } @@ -288,8 +328,8 @@ export async function enrichChangeInfo( file.changedTs = info.timestamp; file.changedCommit = info.hash; } - } else if (file.status === 'diverged' || file.status === 'pinned') { - // For diverged/pinned: store both local and incoming info + } else if (file.status === 'diverged' || file.status === 'pinned' || file.upstreamChanged) { + // For diverged/pinned (and protected files incoming also changed): store both sides const forkFileInfo = forkInfo.get(file.path); const upstreamFileInfo = upstreamInfo.get(file.path); if (forkFileInfo) { diff --git a/src/services/analyze.ts b/src/services/analyze.ts index b9d09ce..4f50062 100644 --- a/src/services/analyze.ts +++ b/src/services/analyze.ts @@ -28,9 +28,14 @@ const scopeStatuses: Record<'all' | 'risk' | 'protected', Set> = { protected: new Set(['ahead']), }; +/** + * Scope filter. Protected files upstream also changed (`upstreamChanged`, status `pinned` or + * `ignored`) ride along in `all` and `protected`: they are the pins most worth reviewing. + */ function filterByScope(files: MergeResult['files'], scope: 'all' | 'risk' | 'protected'): MergeResult['files'] { const statuses = scopeStatuses[scope]; - return files.filter((f) => statuses.has(f.status)); + const includeProtectedBehind = scope !== 'risk'; + return files.filter((f) => statuses.has(f.status) || (includeProtectedBehind && f.upstreamChanged === true)); } function findTargetFile(files: MergeResult['files'], targetPath: string) { @@ -88,6 +93,8 @@ export async function runAnalyze(config: RuntimeConfig): Promise { changedCommit: f.changedCommit ?? null, upstreamChangedAt: f.upstreamChangedAt ?? null, upstreamCommit: f.upstreamCommit ?? null, + upstreamChanged: f.upstreamChanged ?? false, + upstreamChangedLines: f.upstreamChangedLines ?? null, })); writeStdout(JSON.stringify(out, null, 2)); return result; diff --git a/src/services/contributions.ts b/src/services/contributions.ts index 22ca494..8db77e6 100644 --- a/src/services/contributions.ts +++ b/src/services/contributions.ts @@ -26,7 +26,7 @@ import pc from '../utils/colors'; import { DEFAULT_BRANCH, loadConfig } from '../utils/config'; import { gitDiffFile, openDiffInBrowser } from '../utils/diff'; import { createSpinner, DIVIDER, spinnerFail, spinnerSuccess, warningMark, writeStdout } from '../utils/display'; -import { getCurrentBranch, git, removeFileFromWorktree, restoreWorktreeFromRef } from '../utils/git'; +import { getCurrentBranch, getDiffStat, git, removeFileFromWorktree, restoreWorktreeFromRef } from '../utils/git'; import { buildContribBranch, countDetection, detectContributableFiles } from './contrib-core'; import { printNoForksHint, resolveForkBasePath, type ValidatedFork, validateForkPath } from './fork-utils'; @@ -254,30 +254,6 @@ async function forkRefMeta(cellaPath: string, forkRef: string): Promise<{ sha: s return { sha, date }; } -/** - * Lines added/removed per file between cella base and the contrib branch. - * Binary files report null. Used to enrich `--json` output for triage. - */ -async function diffStat( - cellaPath: string, - baseRef: string, - ref: string, -): Promise> { - const out = await git(['diff', '--numstat', `${baseRef}..${ref}`], cellaPath, { ignoreErrors: true }); - const stat = new Map(); - for (const line of out.split('\n')) { - if (!line.trim()) continue; - const [a, d, ...rest] = line.split('\t'); - const path = rest.join('\t'); - if (!path) continue; - stat.set(path, { - additions: a === '-' ? null : Number(a), - deletions: d === '-' ? null : Number(d), - }); - } - return stat; -} - // ── Main entry ─────────────────────────────────────────────────────────────── /** @@ -385,7 +361,7 @@ export async function runContributions(config: RuntimeConfig): Promise { if (countDetection(detection) > 0) { const { branch, appliedFiles } = await buildContribBranch(config.forkPath, baseRef, forkRef, detection, forkName); if (appliedFiles.length > 0) { - const stat = await diffStat(config.forkPath, baseRef, branch); + const stat = await getDiffStat(config.forkPath, baseRef, branch); const metaByPath = new Map(detection.files.map((f) => [f.path, f])); for (const path of [...detection.modified, ...detection.created]) { const fileMeta = metaByPath.get(path); diff --git a/src/services/merge-engine.ts b/src/services/merge-engine.ts index 4f4fcaf..2761684 100644 --- a/src/services/merge-engine.ts +++ b/src/services/merge-engine.ts @@ -115,6 +115,7 @@ async function applyDirectMerge( remainingConflicts: string[]; analyzedFiles: AnalyzedFile[]; autoMergedFiles: string[]; + protectedConflicts: string[]; }> { // Phase 1: Pre-analyze using git refs (invisible to IDE). // analyzeRefs only uses git plumbing (ls-tree, diff-tree) on refs, @@ -273,6 +274,11 @@ async function applyDirectMerge( } } + // Protected files where upstream's changes were dropped by the fork-wins resolution above: + // the batch-restored pinned/ignored files that upstream also changed since the merge-base. + // Surfaced in the sync summary so the loss is visible right when it happens. + const protectedConflicts = analyzedFiles.filter((file) => file.upstreamChanged).map((file) => file.path); + // Handle remaining git conflicts: auto-resolve only ignored/pinned (fork wins); // everything else keeps its markers for IDE 3-way resolution. const gitConflicts = await getConflictedFiles(forkPath); @@ -283,6 +289,7 @@ async function applyDirectMerge( if (await fileExistsAtRef(forkPath, 'HEAD', filePath)) { onProgress?.(`→ ${filePath}: keeping fork (protected conflict)`); await restoreToHead(forkPath, filePath); + if (!protectedConflicts.includes(filePath)) protectedConflicts.push(filePath); } else { onProgress?.(`→ ${filePath}: removing (protected conflict, not in fork)`); await removeFileFully(forkPath, filePath); @@ -310,7 +317,7 @@ async function applyDirectMerge( } } - return { remainingConflicts, analyzedFiles, autoMergedFiles }; + return { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts }; } /** @@ -495,7 +502,7 @@ async function runSyncMerge( const { forkPath } = config; const { upstreamRef, releaseTag, mergeBase, upstreamGitHubUrl, upstreamCommit } = ctx; - const { remainingConflicts, analyzedFiles, autoMergedFiles } = await applyDirectMerge( + const { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts } = await applyDirectMerge( forkPath, upstreamRef, mergeBase, @@ -563,6 +570,7 @@ async function runSyncMerge( summary, conflicts: remainingConflicts, autoMergedFiles, + protectedConflicts, ...resultMeta(config, ctx), }; } @@ -616,6 +624,7 @@ async function runAnalyzePreview( summary: calculateSummary(analyzedFiles), // For analyze mode, count diverged files as potential conflicts conflicts: analyzedFiles.filter((f) => f.status === 'diverged').map((f) => f.path), + protectedConflicts: analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path), ...resultMeta(config, ctx), }; } diff --git a/src/utils/display.ts b/src/utils/display.ts index a806531..e765d9f 100644 --- a/src/utils/display.ts +++ b/src/utils/display.ts @@ -432,54 +432,102 @@ export function printSummary(summary: AnalysisSummary, title = 'summary'): void printLine('behind', summary.behind); } +/** Options shared by the file-list section printers. */ +interface FileSectionOptions { + /** Override section header title */ + title?: string; + /** Footer hint text (dimmed) */ + hint?: string; + /** Which commit/date fields to use: 'fork' or 'upstream' */ + dateSource?: 'fork' | 'upstream'; +} + /** - * Print a group of files with a specific status, including section header and footer. + * Print a list of files as a section: header, one line per file (capped), optional hint. + * `icon` may vary per file (e.g. pinned vs ignored) and `suffix` appends per-file detail. */ -function printFileGroup( +function printFileSection( files: AnalyzedFile[], - status: FileStatus, + title: string, linkOptions: LinkOptions, - options?: { - /** Override section header title */ - title?: string; - /** Footer hint text (dimmed) */ - hint?: string; - /** Which commit/date fields to use: 'fork' or 'upstream' */ - dateSource?: 'fork' | 'upstream'; + options: FileSectionOptions & { + icon: string | ((file: AnalyzedFile) => string); + suffix?: (file: AnalyzedFile) => string; }, ): void { - const filtered = files.filter((f) => f.status === status && !isManagedFile(f.path)); - if (filtered.length === 0) return; + if (files.length === 0) return; - const config = statusConfig[status]; - const title = options?.title ?? `${config.icon} ${config.label}`; - - printSectionHeader(`${title} ${pc.dim(`· ${filtered.length} files`)}`); + printSectionHeader(`${title} ${pc.dim(`· ${files.length} files`)}`); - const useUpstream = options?.dateSource === 'upstream'; + const useUpstream = options.dateSource === 'upstream'; const maxLines = 100; - const shown = filtered.length > maxLines ? filtered.slice(0, maxLines) : filtered; + const shown = files.length > maxLines ? files.slice(0, maxLines) : files; for (const file of shown) { + const icon = typeof options.icon === 'string' ? options.icon : options.icon(file); const commit = useUpstream ? file.upstreamCommit : file.changedCommit; const date = useUpstream ? file.upstreamChangedAt : file.changedAt; const dateInfo = formatFileDateInfo(file.path, commit, date, linkOptions); - console.info(` ${config.icon} ${file.path}${dateInfo}`); + console.info(` ${icon} ${file.path}${dateInfo}${options.suffix?.(file) ?? ''}`); } - if (filtered.length > maxLines) { - console.info(pc.dim(` ... + ${filtered.length - maxLines} more`)); + if (files.length > maxLines) { + console.info(pc.dim(` ... + ${files.length - maxLines} more`)); } - if (options?.hint) { + if (options.hint) { console.info(); console.info(pc.dim(` ${options.hint}`)); } } +/** + * Print a group of files with a specific status, including section header and footer. + */ +function printFileGroup( + files: AnalyzedFile[], + status: FileStatus, + linkOptions: LinkOptions, + options?: FileSectionOptions, +): void { + const filtered = files.filter((f) => f.status === status && !isManagedFile(f.path)); + const config = statusConfig[status]; + const title = options?.title ?? `${config.icon} ${config.label}`; + printFileSection(filtered, title, linkOptions, { ...options, icon: config.icon }); +} + +/** + * Protected (pinned/ignored) files that upstream also changed since the last sync. The fork + * side wins whole-file on these, so upstream's hunks are dropped — the regression class where + * a pinned stylesheet misses new upstream utilities that synced components rely on. + */ +export function findProtectedBehind(files: AnalyzedFile[]): AnalyzedFile[] { + return files.filter((file) => file.upstreamChanged && !isManagedFile(file.path)); +} + +/** Shared hint for protected-but-behind output (analyze section and sync summary). */ +const PROTECTED_BEHIND_HINT = + 'pinned/ignored files where upstream also changed since the last sync; the fork side wins on conflict, ' + + 'so diff each against upstream (analyze --open-diff ) and adopt what you need.'; + +/** Icon by protection kind (⨂ ignored, ⨀ pinned) for protected-but-behind lines. */ +function protectionIcon(file: AnalyzedFile): string { + return file.isIgnored ? statusConfig.ignored.icon : statusConfig.pinned.icon; +} + +/** Per-file detail: how many lines upstream changed (dropped by the fork-wins resolution). */ +function formatUpstreamChangedLines(file: AnalyzedFile): string { + if (file.upstreamChangedLines === undefined) return ''; + const n = file.upstreamChangedLines; + return pc.yellow(` · ${n} ${n === 1 ? 'line' : 'lines'} changed upstream`); +} + /** * Print all analyze-mode file group sections in review order: - * behind, ahead (protected), drifted, diverged, and pinned. + * behind, ahead (protected), protected-but-behind, drifted, and diverged. + * + * Protected-but-behind covers every `pinned`-status file (both changed, fork wins) plus + * ignored files upstream also changed, so there is no separate `pinned` group. */ export function printAnalysisFileGroups(files: AnalyzedFile[], linkOptions: LinkOptions): void { printFileGroup(files, 'behind', linkOptions, { @@ -487,8 +535,19 @@ export function printAnalysisFileGroups(files: AnalyzedFile[], linkOptions: Link }); printFileGroup(files, 'ahead', linkOptions, { title: `${pc.blue('↑ protected in fork')}`, - hint: 'these files have fork changes but are protected (pinned).', + hint: 'these files have fork changes but are protected (pinned); upstream did not change them since the last sync.', }); + printFileSection( + findProtectedBehind(files), + `${warningMark} ${pc.yellow('protected but behind upstream')}`, + linkOptions, + { + icon: protectionIcon, + suffix: formatUpstreamChangedLines, + dateSource: 'upstream', + hint: PROTECTED_BEHIND_HINT, + }, + ); printFileGroup(files, 'drifted', linkOptions, { title: `${warningMark} ${pc.yellow('drifted from upstream')}`, hint: 'these files have fork changes but are not pinned or ignored.', @@ -498,11 +557,6 @@ export function printAnalysisFileGroups(files: AnalyzedFile[], linkOptions: Link hint: 'both fork and upstream changed.', dateSource: 'upstream', }); - printFileGroup(files, 'pinned', linkOptions, { - title: `${pc.green('⨀ pinned')}`, - hint: 'both changed, fork wins (pinned in cella/cella.config.ts).', - dateSource: 'upstream', - }); } /** @@ -559,9 +613,34 @@ export function printSyncComplete(result: MergeResult, options: { stagedBranch?: console.info(pc.dim(` ${updated} files updated, ${merged} auto-merged, ${conflicts} conflicts`)); } + printProtectedConflicts(result); console.info(); } +/** + * List protected files the sync resolved to the fork side although upstream changed them + * (`MergeResult.protectedConflicts`). Printed right when the drop happens so it is not + * discovered weeks later as a styling or behavior regression. Silent when empty. + */ +function printProtectedConflicts(result: MergeResult): void { + const paths = result.protectedConflicts ?? []; + if (paths.length === 0) return; + + const byPath = new Map(result.files.map((file) => [file.path, file])); + console.info(); + console.info( + `${warningMark} ${pc.yellow( + `${paths.length} protected ${paths.length === 1 ? 'file kept' : 'files kept'} the fork version, dropping upstream changes:`, + )}`, + ); + for (const path of paths) { + const file = byPath.get(path); + const icon = file ? protectionIcon(file) : statusConfig.pinned.icon; + console.info(` ${icon} ${path}${file ? formatUpstreamChangedLines(file) : ''}`); + } + console.info(pc.dim(` ${PROTECTED_BEHIND_HINT}`)); +} + /** * Print warnings for aggressive sync flags (--hard / --unpinned) after completion. * diff --git a/src/utils/git.ts b/src/utils/git.ts index 6fe8685..6621904 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -626,6 +626,41 @@ export async function getFileChanges( return changes; } +/** Lines added/removed for one path (`null` for binary files, where git reports `-`). */ +export interface DiffStat { + additions: number | null; + deletions: number | null; +} + +/** + * Lines added/removed per file between two refs (`git diff --numstat`), optionally limited + * to `paths`. Renames are not detected (`--no-renames`) so every entry is keyed by its plain + * path instead of git's `old => new` notation. + */ +export async function getDiffStat( + cwd: string, + fromRef: string, + toRef: string, + paths: string[] = [], +): Promise> { + const args = ['diff', '--numstat', '--no-renames', fromRef, toRef]; + if (paths.length > 0) args.push('--', ...paths); + const out = await git(args, cwd, { ignoreErrors: true }); + + const stat = new Map(); + for (const line of out.split('\n')) { + if (!line.trim()) continue; + const [a, d, ...rest] = line.split('\t'); + const path = rest.join('\t'); + if (!path) continue; + stat.set(path, { + additions: a === '-' ? null : Number(a), + deletions: d === '-' ? null : Number(d), + }); + } + return stat; +} + /** * Get all file hashes at a ref using ls-tree (batch operation). * Returns a Map of filePath -> hash for quick lookups. diff --git a/tests/protected-behind.test.ts b/tests/protected-behind.test.ts new file mode 100644 index 0000000..cd548e1 --- /dev/null +++ b/tests/protected-behind.test.ts @@ -0,0 +1,192 @@ +/** + * Tests for protected-but-behind detection. + * + * A pinned/ignored file wins whole-file on conflict, so when upstream ALSO changed it since the + * merge-base, upstream's hunks are dropped silently. `analyzeRefs` flags exactly those files + * (`upstreamChanged` + `upstreamChangedLines`), `findProtectedBehind` selects them for display, + * and `printSyncComplete` lists `MergeResult.protectedConflicts` at the end of a sync. + */ +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, vi } from 'vitest'; +import type { AnalyzedFile, FileStatus, MergeResult } from '../src/config/types'; +import { analyzeRefs } from '../src/services/analyze-core'; +import { findProtectedBehind, printSyncComplete } from '../src/utils/display'; +import { getMergeBase } from '../src/utils/git'; + +function exec(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} + +function write(dir: string, file: string, content: string): void { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), content); +} + +/** + * Repo with a `main` (fork) and `upstream` branch off one base commit: + * - pinned.css: both changed (upstream +3 −1) → flagged, 4 lines + * - only-fork.css: fork changed, upstream untouched → plain ahead + * - masking.css: upstream changed, fork untouched → behind (masking pin, not flagged) + * - own/mine.ts: ignored, both changed → flagged + * - plain.ts: unprotected, upstream changed → behind, never flagged + */ +function createRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cella-protected-behind-')); + exec('git init -b main', dir); + exec('git config user.email "test@test.com" && git config user.name "Test"', dir); + + write(dir, 'pinned.css', 'a\nb\nc\n'); + write(dir, 'only-fork.css', 'a\n'); + write(dir, 'masking.css', 'a\n'); + write(dir, 'own/mine.ts', 'a\n'); + write(dir, 'plain.ts', 'a\n'); + exec('git add -A && git commit -q -m base', dir); + + exec('git checkout -q -b upstream', dir); + write(dir, 'pinned.css', 'a\nb\nx\ny\nz\n'); // c removed, x y z added + write(dir, 'masking.css', 'a\nupstream\n'); + write(dir, 'own/mine.ts', 'a\nupstream\n'); + write(dir, 'plain.ts', 'a\nupstream\n'); + exec('git add -A && git commit -q -m upstream', dir); + + exec('git checkout -q main', dir); + write(dir, 'pinned.css', 'a\nb\nc\nfork\n'); + write(dir, 'only-fork.css', 'a\nfork\n'); + write(dir, 'own/mine.ts', 'a\nfork\n'); + exec('git add -A && git commit -q -m fork', dir); + + return dir; +} + +function file(overrides: Partial & { path: string; status: FileStatus }): AnalyzedFile { + return { isIgnored: false, isPinned: false, existsInFork: true, existsInUpstream: true, ...overrides }; +} + +describe('analyzeRefs upstreamChanged', () => { + let repoPath: string; + let byPath: Map; + + beforeEach(async () => { + repoPath = createRepo(); + const mergeBase = await getMergeBase(repoPath, 'main', 'upstream'); + const files = await analyzeRefs(repoPath, 'main', 'upstream', mergeBase, { + isIgnored: (p) => p.startsWith('own/'), + isPinned: (p) => p.endsWith('.css'), + }); + byPath = new Map(files.map((f) => [f.path, f])); + }); + + afterEach(() => { + fs.rmSync(repoPath, { recursive: true, force: true }); + }); + + it('flags a pinned file both sides changed and counts the upstream lines', () => { + const pinned = byPath.get('pinned.css'); + expect(pinned?.status).toBe('pinned'); + expect(pinned?.upstreamChanged).toBe(true); + expect(pinned?.upstreamChangedLines).toBe(4); + }); + + it('leaves a pinned file only the fork changed as plain ahead', () => { + const ahead = byPath.get('only-fork.css'); + expect(ahead?.status).toBe('ahead'); + expect(ahead?.upstreamChanged).toBeUndefined(); + expect(ahead?.upstreamChangedLines).toBeUndefined(); + }); + + it('does not flag a masking pin (fork copy still equals base)', () => { + const masking = byPath.get('masking.css'); + expect(masking?.status).toBe('behind'); + expect(masking?.isPinned).toBe(true); + expect(masking?.upstreamChanged).toBeUndefined(); + }); + + it('flags an ignored file both sides changed', () => { + const ignored = byPath.get('own/mine.ts'); + expect(ignored?.status).toBe('ignored'); + expect(ignored?.upstreamChanged).toBe(true); + expect(ignored?.upstreamChangedLines).toBe(1); + }); + + it('never flags unprotected files', () => { + const plain = byPath.get('plain.ts'); + expect(plain?.status).toBe('behind'); + expect(plain?.upstreamChanged).toBeUndefined(); + }); +}); + +describe('findProtectedBehind', () => { + it('selects flagged files and skips managed ones', () => { + const files = [ + file({ path: 'frontend/src/styling/tailwind.css', status: 'pinned', isPinned: true, upstreamChanged: true }), + file({ path: 'own/a.ts', status: 'ignored', isIgnored: true, upstreamChanged: true }), + file({ path: 'package.json', status: 'pinned', isPinned: true, upstreamChanged: true }), + file({ path: 'ahead.ts', status: 'ahead', isPinned: true }), + file({ path: 'plain.ts', status: 'behind' }), + ]; + expect(findProtectedBehind(files).map((f) => f.path)).toEqual(['frontend/src/styling/tailwind.css', 'own/a.ts']); + }); +}); + +describe('printSyncComplete protected conflicts', () => { + const baseResult = (): MergeResult => ({ + success: true, + files: [], + conflicts: [], + summary: { + managed: 0, + identical: 0, + ahead: 0, + local: 0, + drifted: 0, + behind: 0, + diverged: 0, + pinned: 0, + ignored: 0, + deleted: 0, + renamed: 0, + total: 0, + }, + }); + + function capture(result: MergeResult): string { + const lines: string[] = []; + const spy = vi.spyOn(console, 'info').mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }); + try { + printSyncComplete(result); + } finally { + spy.mockRestore(); + } + return lines.join('\n'); + } + + it('lists dropped protected files with their upstream line count and the hint', () => { + const result = baseResult(); + result.files = [ + file({ + path: 'frontend/src/styling/tailwind.css', + status: 'pinned', + isPinned: true, + upstreamChanged: true, + upstreamChangedLines: 21, + }), + ]; + result.protectedConflicts = ['frontend/src/styling/tailwind.css']; + + const out = capture(result); + expect(out).toContain('1 protected file kept the fork version'); + expect(out).toContain('frontend/src/styling/tailwind.css'); + expect(out).toContain('21 lines changed upstream'); + expect(out).toContain('adopt what you need'); + }); + + it('stays silent when nothing was dropped', () => { + const out = capture(baseResult()); + expect(out).not.toContain('protected'); + }); +}); From e1af81539a5bdc89d6e5953ccefc3ac47ccf411d Mon Sep 17 00:00:00 2001 From: flipvanhaaren Date: Wed, 2 Sep 2026 11:55:01 +0200 Subject: [PATCH 2/2] feat(analyze): count upstream lines absent from pinned files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The protected-but-behind check is relative to the last sync point, so a drop that happened in an earlier sync reads as plain `ahead` afterwards. For every pinned (not ignored) `ahead` file whose blobs differ, one batched `git diff --numstat --no-renames HEAD` records the deletion count as `upstreamLinesAbsent`: lines upstream has that the fork lacks, compared at the tips. Binaries are skipped. The "protected in fork" section suffixes lines with `· N upstream lines absent` when N > 0 and adds a hint to diff and decide; `--json` (and thus `--scope protected`) carries the field. Co-Authored-By: Claude Fable 5.1 --- README.md | 5 +++- src/config/types.ts | 6 +++++ src/services/analyze-core.ts | 26 ++++++++++++++++++ src/services/analyze.ts | 1 + src/utils/display.ts | 28 ++++++++++++++------ tests/protected-behind.test.ts | 48 +++++++++++++++++++++++++++++++++- 6 files changed, 104 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b91d412..fe6ef1f 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,10 @@ missing new upstream utilities that synced components rely on), so both commands Diff each against upstream (`cella analyze --open-diff `) and adopt what you need. The check is relative to the last sync point: a drop that happened in an earlier sync only shows up -as plain `ahead` afterwards, so review the list on every sync. +as plain `ahead` afterwards. For that case the `↑ protected in fork` section annotates pinned +files with `· N upstream lines absent` (lines upstream has that the fork lacks, compared at the +tips, `--json`: `upstreamLinesAbsent`). That is upstream content the fork never received, +deliberately or not: diff and decide. Ignored files are not annotated. ## Package.json sync diff --git a/src/config/types.ts b/src/config/types.ts index ff02113..9f442e3 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -327,6 +327,12 @@ export interface AnalyzedFile { upstreamChanged?: boolean; /** For `upstreamChanged` files: lines upstream changed since the merge-base (undefined for binary) */ upstreamChangedLines?: number; + /** + * Pinned `ahead` files only: lines present upstream but absent from the fork (undefined for + * binary). Independent of the sync point, so it also shows upstream content dropped by an + * earlier sync — or removed on purpose; only a diff can tell. + */ + upstreamLinesAbsent?: number; } /** Summary counts by status */ diff --git a/src/services/analyze-core.ts b/src/services/analyze-core.ts index 5041cb1..a0cdfc6 100644 --- a/src/services/analyze-core.ts +++ b/src/services/analyze-core.ts @@ -286,6 +286,32 @@ export async function analyzeRefs( } } + // Retroactive signal for pinned `ahead` files (incoming untouched since the merge-base, so + // the check above cannot fire): count lines incoming has that local lacks, relative to + // the tips rather than the sync point. Pinned only — ignored territory is noise by design. + const stale = analyzedFiles.filter( + (file) => + file.status === 'ahead' && + file.isPinned && + !file.isIgnored && + file.existsInFork && + file.existsInUpstream && + forkHashes.get(file.path) !== upstreamHashes.get(file.path), + ); + if (stale.length > 0) { + // from = incoming, to = local: numstat deletions are incoming lines absent locally + const stat = await getDiffStat( + repoPath, + incomingRef, + localRef, + stale.map((file) => file.path), + ); + for (const file of stale) { + const deletions = stat.get(file.path)?.deletions; + if (deletions !== null && deletions !== undefined) file.upstreamLinesAbsent = deletions; + } + } + return analyzedFiles; } diff --git a/src/services/analyze.ts b/src/services/analyze.ts index 4f50062..42653d2 100644 --- a/src/services/analyze.ts +++ b/src/services/analyze.ts @@ -95,6 +95,7 @@ export async function runAnalyze(config: RuntimeConfig): Promise { upstreamCommit: f.upstreamCommit ?? null, upstreamChanged: f.upstreamChanged ?? false, upstreamChangedLines: f.upstreamChangedLines ?? null, + upstreamLinesAbsent: f.upstreamLinesAbsent ?? null, })); writeStdout(JSON.stringify(out, null, 2)); return result; diff --git a/src/utils/display.ts b/src/utils/display.ts index e765d9f..683ce75 100644 --- a/src/utils/display.ts +++ b/src/utils/display.ts @@ -436,10 +436,12 @@ export function printSummary(summary: AnalysisSummary, title = 'summary'): void interface FileSectionOptions { /** Override section header title */ title?: string; - /** Footer hint text (dimmed) */ - hint?: string; + /** Footer hint text (dimmed), one line per entry */ + hint?: string | string[]; /** Which commit/date fields to use: 'fork' or 'upstream' */ dateSource?: 'fork' | 'upstream'; + /** Per-file detail appended after the date/link info */ + suffix?: (file: AnalyzedFile) => string; } /** @@ -450,10 +452,7 @@ function printFileSection( files: AnalyzedFile[], title: string, linkOptions: LinkOptions, - options: FileSectionOptions & { - icon: string | ((file: AnalyzedFile) => string); - suffix?: (file: AnalyzedFile) => string; - }, + options: FileSectionOptions & { icon: string | ((file: AnalyzedFile) => string) }, ): void { if (files.length === 0) return; @@ -477,7 +476,9 @@ function printFileSection( if (options.hint) { console.info(); - console.info(pc.dim(` ${options.hint}`)); + for (const line of Array.isArray(options.hint) ? options.hint : [options.hint]) { + console.info(pc.dim(` ${line}`)); + } } } @@ -522,6 +523,12 @@ function formatUpstreamChangedLines(file: AnalyzedFile): string { return pc.yellow(` · ${n} ${n === 1 ? 'line' : 'lines'} changed upstream`); } +/** Per-file detail for pinned `ahead` files: upstream lines the fork lacks (only when > 0). */ +function formatUpstreamLinesAbsent(file: AnalyzedFile): string { + const n = file.upstreamLinesAbsent ?? 0; + return n > 0 ? pc.yellow(` · ${n} upstream ${n === 1 ? 'line' : 'lines'} absent`) : ''; +} + /** * Print all analyze-mode file group sections in review order: * behind, ahead (protected), protected-but-behind, drifted, and diverged. @@ -535,7 +542,12 @@ export function printAnalysisFileGroups(files: AnalyzedFile[], linkOptions: Link }); printFileGroup(files, 'ahead', linkOptions, { title: `${pc.blue('↑ protected in fork')}`, - hint: 'these files have fork changes but are protected (pinned); upstream did not change them since the last sync.', + suffix: formatUpstreamLinesAbsent, + hint: [ + 'these files have fork changes but are protected (pinned); upstream did not change them since the last sync.', + 'pinned files keep the fork side on every conflict; a count here is upstream content the fork never received, ' + + 'deliberately or not: diff and decide.', + ], }); printFileSection( findProtectedBehind(files), diff --git a/tests/protected-behind.test.ts b/tests/protected-behind.test.ts index cd548e1..f722560 100644 --- a/tests/protected-behind.test.ts +++ b/tests/protected-behind.test.ts @@ -13,7 +13,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AnalyzedFile, FileStatus, MergeResult } from '../src/config/types'; import { analyzeRefs } from '../src/services/analyze-core'; -import { findProtectedBehind, printSyncComplete } from '../src/utils/display'; +import { findProtectedBehind, printAnalysisFileGroups, printSyncComplete } from '../src/utils/display'; import { getMergeBase } from '../src/utils/git'; function exec(cmd: string, cwd: string): string { @@ -32,6 +32,8 @@ function write(dir: string, file: string, content: string): void { * - masking.css: upstream changed, fork untouched → behind (masking pin, not flagged) * - own/mine.ts: ignored, both changed → flagged * - plain.ts: unprotected, upstream changed → behind, never flagged + * - stale.css: pinned, fork dropped 2 base lines, upstream untouched → ahead, 2 lines absent + * - own/stale.ts: ignored, same shape as stale.css → ignored, never annotated */ function createRepo(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cella-protected-behind-')); @@ -43,6 +45,8 @@ function createRepo(): string { write(dir, 'masking.css', 'a\n'); write(dir, 'own/mine.ts', 'a\n'); write(dir, 'plain.ts', 'a\n'); + write(dir, 'stale.css', 'a\nb\nc\n'); + write(dir, 'own/stale.ts', 'a\nb\n'); exec('git add -A && git commit -q -m base', dir); exec('git checkout -q -b upstream', dir); @@ -56,6 +60,8 @@ function createRepo(): string { write(dir, 'pinned.css', 'a\nb\nc\nfork\n'); write(dir, 'only-fork.css', 'a\nfork\n'); write(dir, 'own/mine.ts', 'a\nfork\n'); + write(dir, 'stale.css', 'a\nfork\n'); // b, c gone: 2 upstream lines absent + write(dir, 'own/stale.ts', 'a\n'); exec('git add -A && git commit -q -m fork', dir); return dir; @@ -116,6 +122,46 @@ describe('analyzeRefs upstreamChanged', () => { expect(plain?.status).toBe('behind'); expect(plain?.upstreamChanged).toBeUndefined(); }); + + it('counts upstream lines absent from a pinned ahead file', () => { + const stale = byPath.get('stale.css'); + expect(stale?.status).toBe('ahead'); + expect(stale?.upstreamLinesAbsent).toBe(2); + // fork only added lines: nothing absent + expect(byPath.get('only-fork.css')?.upstreamLinesAbsent).toBe(0); + }); + + it('never counts absent lines for ignored or unprotected files', () => { + expect(byPath.get('own/stale.ts')?.status).toBe('ignored'); + expect(byPath.get('own/stale.ts')?.upstreamLinesAbsent).toBeUndefined(); + expect(byPath.get('plain.ts')?.upstreamLinesAbsent).toBeUndefined(); + }); +}); + +describe('printAnalysisFileGroups upstream lines absent', () => { + function capture(files: AnalyzedFile[]): string { + const lines: string[] = []; + const spy = vi.spyOn(console, 'info').mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }); + try { + printAnalysisFileGroups(files, {}); + } finally { + spy.mockRestore(); + } + return lines.join('\n'); + } + + it('annotates pinned ahead files with a positive count and prints the hint', () => { + const out = capture([ + file({ path: 'frontend/src/styling/tailwind.css', status: 'ahead', isPinned: true, upstreamLinesAbsent: 7 }), + file({ path: 'clean.css', status: 'ahead', isPinned: true, upstreamLinesAbsent: 0 }), + ]); + expect(out).toContain('frontend/src/styling/tailwind.css'); + expect(out).toContain('7 upstream lines absent'); + expect(out).toMatch(/clean\.css\n/); + expect(out).toContain('diff and decide'); + }); }); describe('findProtectedBehind', () => {