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
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,18 @@ pnpm cella sync --track branch # follow the tip once, without editing config
main ──▶ cella/sync/<stamp> ──(3-way merge)──▶ PR ──(squash)──▶ main
```

It runs a real git 3-way merge and leaves the result **staged** — it never auto-commits on the
first pass. `sync` is **idempotent and two-phase**:

1. **First run** cuts the branch and stages the merge, then stops so you can review (and resolve
any conflicts in your IDE — `git add` the resolved files).
2. **Re-run `pnpm cella sync`** on the same branch to finish: it reconciles dependencies
(`pnpm install` + `pnpm check`), stages everything, commits the delta, pushes to `origin`,
It runs a real git 3-way merge. `sync` is **idempotent and staged**: each run advances the sync
one stage, and the run that commits never ships — the pause on the committed branch is where
drift triage (`pnpm cella analyze` diffs committed HEAD) and follow-up commits happen.

1. **First run** cuts the branch and merges. A clean merge is committed right away: dependencies
are reconciled (`pnpm install` + `pnpm check`), everything is staged, and the delta is
committed — then the run stops on the branch. A conflicted merge stops earlier so you can
resolve in your IDE (`git add` the resolved files) and re-run to commit.
2. **Final re-run `pnpm cella sync`** on the committed branch ships it: pushes to `origin`,
opens a PR into `main` (via `gh`), and switches you back to `main`.

When you commit, the in-progress merge state (`MERGE_HEAD`) is discarded, so the staged delta
When the commit stage runs, the in-progress merge state (`MERGE_HEAD`) is discarded, so the staged delta
collapses into a **single-parent commit** (`chore: sync upstream cella <sha>`). This keeps the PR
to one clean commit with the incremental diff — a two-parent merge commit would instead list the
upstream branch's entire history, because the fork doesn't share pushed ancestry with upstream
Expand Down
53 changes: 34 additions & 19 deletions src/services/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ function printShipSteps(temporaryBranch: string, base: string, title?: string):
printPrCreateStep(temporaryBranch, base, title);
}

/** Guidance shown after a fresh cycle stages a merge: re-run to finish and ship it. */
/** Guidance shown after a fresh cycle stops at conflicts: re-run to commit once resolved. */
function printFinishSteps(): void {
console.info(pc.dim(' pnpm cella sync'));
}
Expand Down Expand Up @@ -370,7 +370,8 @@ function enableAutoMerge(forkPath: string, branch: string): boolean {

/**
* Push the finished sync branch to `origin`, open a PR into the trunk, and switch back to the
* trunk. Runs automatically once a rerun completes the merge cleanly.
* trunk. Runs when `cella sync` is invoked on a sync branch whose merge is already committed —
* shipping is always its own run, after the commit stage stopped for drift triage.
*
* Before pushing, any merge commits on the branch are flattened away (see `flattenSyncBranch`)
* so the PR never lists the upstream branch's entire history.
Expand Down Expand Up @@ -503,15 +504,16 @@ async function runSyncCycle(config: RuntimeConfig): Promise<SyncCycleOutcome> {
}

/**
* Finish an in-progress merge left by an earlier `cella sync` run on the same temporary branch.
* Commit an in-progress merge on the temporary sync branch — never shipping in the same run.
*
* This is what makes the command idempotent: after a run stops at conflicts, resolve and stage
* them, then run `cella sync` again. If conflicts remain we point them out and stop; once none
* remain we reconcile dependencies (`pnpm install` + `pnpm check`), stage everything, commit the
* staged delta as a single squashed commit (see `commitSquash`), then push the branch and open
* the PR (see `shipSyncBranch`).
* Runs directly after a clean merge, or on a rerun once a conflicted merge is resolved and
* staged. If conflicts remain we point them out and stop; once none remain we reconcile
* dependencies (`pnpm install` + `pnpm check`), stage everything, and commit the staged delta
* as a single squashed commit (see `commitSquash`). It then stops on the committed branch:
* that is the window for drift triage (`cella analyze` diffs committed HEAD) and follow-up
* commits. Shipping (push + PR) is always its own rerun (see `runSyncCommand`).
*/
async function resumeSyncMerge(config: RuntimeConfig, branch: string): Promise<void> {
async function commitSyncMerge(config: RuntimeConfig, branch: string): Promise<void> {
const { forkPath } = config;
const conflicts = await getConflictedFiles(forkPath);

Expand Down Expand Up @@ -547,7 +549,14 @@ async function resumeSyncMerge(config: RuntimeConfig, branch: string): Promise<v
await commitSquash(forkPath, message);
console.info();
console.info(pc.green(`committed the sync on '${branch}' as '${message}'.`));
await shipSyncBranch(config, branch);
printTriageSteps(branch);
}

/** Guidance after the commit stage stops on the committed sync branch. */
function printTriageSteps(branch: string): void {
console.info(pc.dim(`staying on '${branch}' without pushing.`));
console.info(pc.dim(' run drift triage (`pnpm cella analyze`), commit any follow-ups, then:'));
console.info(pc.dim(' pnpm cella sync (pushes the branch and opens the PR)'));
}

/**
Expand Down Expand Up @@ -645,22 +654,24 @@ async function guardAgainstOpenSyncPr(config: RuntimeConfig): Promise<'continue'
/**
* Run the standalone `cella sync` command.
*
* Idempotent. Behaviour depends on where you are:
* - On a sync branch with a merge in progress: finish that merge (resume after conflicts), then
* push and open the PR.
* - On a sync branch with the merge already committed: push and open the PR (e.g. a previous
* push failed), then switch back to the trunk.
* - Anywhere else: require a clean tree, then cut a fresh temporary branch and merge upstream.
* Idempotent. Each run advances the sync one stage and never commits and ships in the same run:
* - Anywhere else: require a clean tree, cut a fresh temporary branch and merge upstream; a
* clean merge is committed right away (the run stops there, for drift triage), a conflicted
* one stops for IDE resolution.
* - On a sync branch with a merge in progress: commit it (resume after conflicts), then stop.
* - On a sync branch with the merge already committed: push and open the PR, then switch back
* to the trunk. Shipping is deliberately its own run — the pause before it is where drift
* triage and follow-up commits happen.
*/
export async function runSyncCommand(config: RuntimeConfig): Promise<void> {
const { forkPath } = config;
const currentBranch = await getCurrentBranch(forkPath);
const onSyncBranch = isTemporarySyncBranch(currentBranch);

// Resume path: an earlier run left a merge staged on this temporary branch (e.g. after
// conflicts). Re-running finishes it instead of starting over.
// conflicts). Re-running commits it instead of starting over.
if (onSyncBranch && mergeInProgress(forkPath)) {
await resumeSyncMerge(config, currentBranch);
await commitSyncMerge(config, currentBranch);
return;
}

Expand Down Expand Up @@ -701,7 +712,11 @@ export async function runSyncCommand(config: RuntimeConfig): Promise<void> {
if (outcome.status === 'conflicts') {
console.info(`${warningMark} ${pc.yellow(`conflicts on '${temporaryBranch}'. Resolve and stage them, then:`)}`);
printFinishSteps();
console.info(pc.dim(' rerun commits the sync, pushes the branch, and opens a PR.'));
console.info(pc.dim(' rerun commits the sync and stops for drift triage; a further rerun ships (push + PR).'));
console.info(pc.dim(' let the rerun commit — a manual `git commit` records a merge commit that bloats the PR.'));
return;
}

// Clean merge: commit it in the same run (never shipping — that stays a separate rerun).
await commitSyncMerge(config, temporaryBranch);
}
4 changes: 1 addition & 3 deletions src/utils/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,9 +552,7 @@ export function printSyncComplete(result: MergeResult, options: { stagedBranch?:
if (options.stagedBranch) {
console.info(`${pc.green('✓')} Sync merge staged on '${options.stagedBranch}'`);
console.info(
pc.dim(
` ${updated} files updated, ${merged} auto-merged, ${conflicts} conflicts. Review, then rerun \`pnpm cella sync\` to finish.`,
),
pc.dim(` ${updated} files updated, ${merged} auto-merged, ${conflicts} conflicts. Committing next...`),
);
} else {
console.info(`${pc.green('✓')} sync complete`);
Expand Down