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
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,31 @@ 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 <path>`) 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. 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

`packageJsonSync` controls which package.json sections sync from upstream:
Expand Down
18 changes: 18 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,19 @@ 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;
/**
* 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 */
Expand Down Expand Up @@ -367,4 +380,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[];
}
72 changes: 69 additions & 3 deletions src/services/analyze-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string>();
// 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<string>();
let processed = 0;

for (const filePath of allFiles) {
Expand Down Expand Up @@ -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
Expand All @@ -175,6 +188,7 @@ export async function analyzeRefs(
existsInFork: inFork,
existsInUpstream: true,
renamedFrom,
upstreamChanged,
});
continue;
}
Expand Down Expand Up @@ -236,16 +250,68 @@ 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,
isIgnored: fileIsIgnored,
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;
}
}
}

// 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;
}

Expand Down Expand Up @@ -288,8 +354,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) {
Expand Down
10 changes: 9 additions & 1 deletion src/services/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,14 @@ const scopeStatuses: Record<'all' | 'risk' | 'protected', Set<string>> = {
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) {
Expand Down Expand Up @@ -88,6 +93,9 @@ export async function runAnalyze(config: RuntimeConfig): Promise<MergeResult> {
changedCommit: f.changedCommit ?? null,
upstreamChangedAt: f.upstreamChangedAt ?? null,
upstreamCommit: f.upstreamCommit ?? null,
upstreamChanged: f.upstreamChanged ?? false,
upstreamChangedLines: f.upstreamChangedLines ?? null,
upstreamLinesAbsent: f.upstreamLinesAbsent ?? null,
}));
writeStdout(JSON.stringify(out, null, 2));
return result;
Expand Down
28 changes: 2 additions & 26 deletions src/services/contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<Map<string, { additions: number | null; deletions: number | null }>> {
const out = await git(['diff', '--numstat', `${baseRef}..${ref}`], cellaPath, { ignoreErrors: true });
const stat = new Map<string, { additions: number | null; deletions: number | null }>();
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 ───────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -385,7 +361,7 @@ export async function runContributions(config: RuntimeConfig): Promise<void> {
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);
Expand Down
13 changes: 11 additions & 2 deletions src/services/merge-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -310,7 +317,7 @@ async function applyDirectMerge(
}
}

return { remainingConflicts, analyzedFiles, autoMergedFiles };
return { remainingConflicts, analyzedFiles, autoMergedFiles, protectedConflicts };
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -563,6 +570,7 @@ async function runSyncMerge(
summary,
conflicts: remainingConflicts,
autoMergedFiles,
protectedConflicts,
...resultMeta(config, ctx),
};
}
Expand Down Expand Up @@ -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),
};
}
Expand Down
Loading
Loading