@cellajs/cli 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cella-cli.ts
4
4
  import { existsSync as existsSync13, statSync as statSync3 } from "fs";
5
- import { join as join21, resolve as resolve7 } from "path";
5
+ import { join as join21, resolve as resolve8 } from "path";
6
6
  import process9 from "process";
7
7
 
8
8
  // src/cli.ts
@@ -220,7 +220,7 @@ import process4 from "process";
220
220
  // package.json
221
221
  var package_default = {
222
222
  name: "@cellajs/cli",
223
- version: "0.3.0",
223
+ version: "0.3.1",
224
224
  private: false,
225
225
  license: "MIT",
226
226
  description: "CLI for managing your monorepo and syncing with upstream Cella.",
@@ -287,7 +287,7 @@ var package_default = {
287
287
  rimraf: "^6.1.3",
288
288
  tsup: "^8.5.1",
289
289
  tsx: "^4.23.15",
290
- typescript: "^6.0.3",
290
+ typescript: "6.0.3",
291
291
  vitest: "^5.0.3"
292
292
  },
293
293
  publishConfig: {
@@ -812,7 +812,7 @@ import { dirname as dirname3, join as join6 } from "path";
812
812
  import { execFile } from "child_process";
813
813
  import { existsSync as existsSync3 } from "fs";
814
814
  import { mkdir as mkdir2, readdir, rmdir, unlink } from "fs/promises";
815
- import { dirname as dirname2, join as join5 } from "path";
815
+ import { dirname as dirname2, join as join5, resolve as resolve3 } from "path";
816
816
  import { promisify } from "util";
817
817
 
818
818
  // src/utils/manifest.ts
@@ -872,8 +872,26 @@ async function getCurrentBranch(cwd) {
872
872
  async function switchBranch(cwd, branch) {
873
873
  await git(["switch", branch], cwd);
874
874
  }
875
+ async function switchDetached(cwd, ref) {
876
+ await git(["switch", "--detach", ref], cwd);
877
+ }
875
878
  async function createBranchFrom(cwd, branch, startPoint) {
876
- await git(["switch", "-c", branch, startPoint], cwd);
879
+ await git(["switch", "-c", branch, "--no-track", startPoint], cwd);
880
+ }
881
+ async function branchExists(cwd, branch) {
882
+ return await git(["rev-parse", "-q", "--verify", `refs/heads/${branch}`], cwd, { ignoreErrors: true }) !== "";
883
+ }
884
+ async function getBranchWorktree(cwd, branch) {
885
+ const output = await git(["worktree", "list", "--porcelain"], cwd, { ignoreErrors: true });
886
+ let worktree = null;
887
+ for (const line of output.split("\n")) {
888
+ if (line.startsWith("worktree ")) worktree = line.slice("worktree ".length);
889
+ else if (line === `branch refs/heads/${branch}`) return worktree;
890
+ }
891
+ return null;
892
+ }
893
+ async function fastForwardBranch(cwd, branch, ref) {
894
+ await git(["fetch", "--quiet", ".", `${ref}:refs/heads/${branch}`], cwd);
877
895
  }
878
896
  async function deleteBranch(cwd, branch) {
879
897
  await git(["branch", "-D", branch], cwd, { ignoreErrors: true });
@@ -881,14 +899,21 @@ async function deleteBranch(cwd, branch) {
881
899
  async function pullFastForward(cwd) {
882
900
  await git(["pull", "--ff-only"], cwd, { ignoreErrors: true, skipEditor: true });
883
901
  }
884
- async function getUpstreamStatus(cwd) {
885
- const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd, {
902
+ async function getBranchUpstream(cwd, branch = "") {
903
+ const upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{upstream}`], cwd, {
886
904
  ignoreErrors: true
887
905
  });
906
+ return upstream || null;
907
+ }
908
+ async function getUpstreamStatus(cwd, branch) {
909
+ const local = branch ? `refs/heads/${branch}` : "HEAD";
910
+ const upstream = await getBranchUpstream(cwd, branch);
888
911
  if (!upstream) return { upstream: null, ahead: 0, behind: 0 };
889
912
  const remote = upstream.split("/")[0];
890
913
  await git(["fetch", remote], cwd, { ignoreErrors: true, skipEditor: true });
891
- const counts = await git(["rev-list", "--left-right", "--count", `HEAD...${upstream}`], cwd, { ignoreErrors: true });
914
+ const counts = await git(["rev-list", "--left-right", "--count", `${local}...${upstream}`], cwd, {
915
+ ignoreErrors: true
916
+ });
892
917
  const [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
893
918
  return { upstream, ahead: ahead ?? 0, behind: behind ?? 0 };
894
919
  }
@@ -907,16 +932,19 @@ async function flattenBranch(cwd, baseRef, message) {
907
932
  }
908
933
  async function commitSquash(cwd, message) {
909
934
  for (const file of ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"]) {
910
- const path4 = join5(cwd, ".git", file);
935
+ const path4 = await gitPath(cwd, file);
911
936
  if (existsSync3(path4)) await unlink(path4);
912
937
  }
913
938
  await git(["commit", "-m", message], cwd, { skipEditor: true });
914
939
  }
940
+ async function gitPath(cwd, file) {
941
+ return resolve3(cwd, await git(["rev-parse", "--git-path", file], cwd));
942
+ }
915
943
  async function stageAll(cwd) {
916
944
  await git(["add", "-A"], cwd);
917
945
  }
918
- function mergeInProgress(cwd) {
919
- return existsSync3(join5(cwd, ".git", "MERGE_HEAD"));
946
+ async function mergeInProgress(cwd) {
947
+ return await git(["rev-parse", "-q", "--verify", "MERGE_HEAD"], cwd, { ignoreErrors: true }) !== "";
920
948
  }
921
949
  async function getShortSha(cwd, ref) {
922
950
  return git(["rev-parse", "--short", ref], cwd);
@@ -957,6 +985,33 @@ async function resolveLatestReleaseTag(cwd, remoteName) {
957
985
  }
958
986
  return null;
959
987
  }
988
+ async function resolveUpstreamCommit(cwd, remoteName, ref) {
989
+ if (ref.startsWith("-")) return null;
990
+ const tagRef = `refs/${remoteName}/tags/${ref}`;
991
+ for (const candidate of [`refs/remotes/${remoteName}/${ref}`, tagRef, ref]) {
992
+ const sha = await git(["rev-parse", "-q", "--verify", `${candidate}^{commit}`], cwd, { ignoreErrors: true });
993
+ if (!sha) continue;
994
+ const release = candidate === tagRef && /^v\d[\w.+-]*$/.test(ref) ? { tag: ref, ref: tagRef } : void 0;
995
+ return { sha, release };
996
+ }
997
+ return null;
998
+ }
999
+ async function isPublishedUpstream(cwd, remoteName, branchRef, sha) {
1000
+ const containing = await git(
1001
+ [
1002
+ "for-each-ref",
1003
+ "--count=1",
1004
+ "--format=%(refname)",
1005
+ "--contains",
1006
+ sha,
1007
+ `refs/remotes/${branchRef}`,
1008
+ `refs/${remoteName}/tags/v*`
1009
+ ],
1010
+ cwd,
1011
+ { ignoreErrors: true }
1012
+ );
1013
+ return containing !== "";
1014
+ }
960
1015
  async function getCommitInfo(cwd, ref) {
961
1016
  const format = "%H%n%s%n%ar";
962
1017
  const output = await git(["log", "-1", `--format=${format}`, ref], cwd);
@@ -1586,6 +1641,7 @@ function readOptions(opts) {
1586
1641
  hard: opts.hard === true,
1587
1642
  unpinned: opts.unpinned === true,
1588
1643
  track: opts.track === "release" || opts.track === "branch" ? opts.track : void 0,
1644
+ ref: typeof opts.ref === "string" && opts.ref ? opts.ref : void 0,
1589
1645
  force: opts.force === true,
1590
1646
  checkOverrides: opts.checkOverrides === true,
1591
1647
  coverage: opts.coverage === true,
@@ -1605,6 +1661,7 @@ var serviceDefinitions = [
1605
1661
  { flags: "--json", description: "machine-readable output for tooling/agents" },
1606
1662
  { flags: "--scope <scope>", description: "analyze scope for --list/--json: all|risk|protected" },
1607
1663
  { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
1664
+ { flags: "--ref <ref>", description: "pin the upstream commit for this run: sha, release tag or branch" },
1608
1665
  { flags: "--diff <path>", description: "print unified diff for one file, then exit" },
1609
1666
  { flags: "--open-diff <path>", description: "open a browser diff for one file, then exit" }
1610
1667
  ],
@@ -1617,7 +1674,8 @@ var serviceDefinitions = [
1617
1674
  { flags: "--log", description: "write complete file list to cella-sync.log" },
1618
1675
  { flags: "--hard", description: "overwrite drifted files with upstream version (aggressive realignment)" },
1619
1676
  { flags: "--unpinned", description: "ignore pinned files (except package.json) to resurface upstream changes" },
1620
- { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" }
1677
+ { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
1678
+ { flags: "--ref <ref>", description: "pin the upstream commit for this run: sha, release tag or branch" }
1621
1679
  ],
1622
1680
  includeInMenu: (context) => !context.isUpstreamRepo,
1623
1681
  menuDescription: () => "merge upstream changes + sync package.json"
@@ -1716,6 +1774,7 @@ function buildProgram(setSelection) {
1716
1774
  " $ cella sync --hard",
1717
1775
  " $ cella sync --unpinned",
1718
1776
  " $ cella sync --track branch",
1777
+ " $ cella sync --ref 4f7d87c",
1719
1778
  " $ cella migrate --mark 20261002T0614-config-switch",
1720
1779
  " $ cella audit --check-overrides",
1721
1780
  " $ cella contributions --fork raak --json"
@@ -1894,14 +1953,14 @@ import { join as join11 } from "path";
1894
1953
  import { createHash } from "crypto";
1895
1954
  import { existsSync as existsSync6, rmSync } from "fs";
1896
1955
  import { tmpdir as tmpdir2 } from "os";
1897
- import { basename as basename3, join as join10, resolve as resolve3 } from "path";
1956
+ import { basename as basename3, join as join10, resolve as resolve4 } from "path";
1898
1957
  import process7 from "process";
1899
1958
  var WORKTREE_PREFIXES = {
1900
1959
  sync: "cella-sync-",
1901
1960
  view: "cella-view-"
1902
1961
  };
1903
1962
  function buildWorktreePath(kind, repoPath) {
1904
- const key = createHash("sha256").update(resolve3(repoPath)).digest("hex").slice(0, 8);
1963
+ const key = createHash("sha256").update(resolve4(repoPath)).digest("hex").slice(0, 8);
1905
1964
  return join10(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}-${key}`);
1906
1965
  }
1907
1966
  function legacyWorktreePath(kind, repoPath) {
@@ -2511,6 +2570,20 @@ function formatLocalCheckoutDetail(forkPath, branch, headSha, changeCount, upstr
2511
2570
  const workingTree = changeCount === 0 ? " working tree clean" : ` ${changeCount} uncommitted change${changeCount === 1 ? "" : "s"} in working tree, not included in analysis`;
2512
2571
  return [`${forkPath}`, ` ${branch} @ ${headSha}${tracking}`, workingTree].join("\n");
2513
2572
  }
2573
+ async function resolvePinnedRef(forkPath, config, remoteName, branchRef, ref) {
2574
+ const resolved = await resolveUpstreamCommit(forkPath, remoteName, ref);
2575
+ if (!resolved) {
2576
+ throw new Error(
2577
+ `--ref '${ref}' does not resolve to a commit. Pass a commit sha, a release tag (v*) or a branch on ${config.settings.upstreamUrl}.`
2578
+ );
2579
+ }
2580
+ if (!await isPublishedUpstream(forkPath, remoteName, branchRef, resolved.sha)) {
2581
+ throw new Error(
2582
+ `--ref '${ref}' (${resolved.sha.slice(0, 7)}) is not on ${branchRef} or in an upstream release (v*). Sync only to commits upstream has published there.`
2583
+ );
2584
+ }
2585
+ return resolved.release ? { ref: resolved.release.ref, releaseTag: resolved.release.tag } : { ref: resolved.sha };
2586
+ }
2514
2587
  async function prepareUpstream(config, onProgress, onStep) {
2515
2588
  const { forkPath } = config;
2516
2589
  onProgress?.("checking local checkout...");
@@ -2530,7 +2603,12 @@ async function prepareUpstream(config, onProgress, onStep) {
2530
2603
  await fetch2(forkPath, remoteName);
2531
2604
  let upstreamRef = branchRef;
2532
2605
  let releaseTag;
2533
- if (track === "release") {
2606
+ if (config.ref) {
2607
+ await fetchUpstreamTags(forkPath, remoteName);
2608
+ const pinned = await resolvePinnedRef(forkPath, config, remoteName, branchRef, config.ref);
2609
+ upstreamRef = pinned.ref;
2610
+ releaseTag = pinned.releaseTag;
2611
+ } else if (track === "release") {
2534
2612
  await fetchUpstreamTags(forkPath, remoteName);
2535
2613
  const latest = await resolveLatestReleaseTag(forkPath, remoteName);
2536
2614
  if (!latest) {
@@ -2541,8 +2619,9 @@ async function prepareUpstream(config, onProgress, onStep) {
2541
2619
  upstreamRef = latest.ref;
2542
2620
  releaseTag = latest.tag;
2543
2621
  }
2622
+ const upstreamLabel = releaseTag ?? (config.ref ? `--ref ${config.ref}` : upstreamRef);
2544
2623
  config.upstreamRef = upstreamRef;
2545
- onStep?.("remote configured", `${releaseTag ?? upstreamRef} \u2192 ${config.settings.upstreamUrl}`);
2624
+ onStep?.("remote configured", `${upstreamLabel} \u2192 ${config.settings.upstreamUrl}`);
2546
2625
  const cliRange = await readUpstreamCliRange(forkPath, upstreamRef);
2547
2626
  const cliMismatch = cliRange ? cliVersionMismatch(VERSION, cliRange) : null;
2548
2627
  if (cliMismatch) throw new Error(cliMismatch);
@@ -2550,8 +2629,20 @@ async function prepareUpstream(config, onProgress, onStep) {
2550
2629
  await ensureSyncBase(forkPath, "HEAD", upstreamRef);
2551
2630
  const upstreamGitHubUrl = getGitHubBaseUrl(config.settings.upstreamUrl) ?? void 0;
2552
2631
  const aggressive = config.hard === true || config.unpinned === true;
2553
- const mergeBase = aggressive ? await getMergeBase(forkPath, "HEAD", upstreamRef) : (await getEffectiveMergeBase(forkPath, "HEAD", upstreamRef)).base;
2632
+ const syncPoint = (await getEffectiveMergeBase(forkPath, "HEAD", upstreamRef)).base;
2633
+ const mergeBase = aggressive ? await getMergeBase(forkPath, "HEAD", upstreamRef) : syncPoint;
2554
2634
  const upstreamCommit = await getCommitInfo(forkPath, upstreamRef);
2635
+ if (upstreamCommit.hash !== syncPoint && await isAncestor(forkPath, upstreamCommit.hash, syncPoint)) {
2636
+ const [upstreamShort, syncShort] = await Promise.all([
2637
+ getShortSha(forkPath, upstreamCommit.hash),
2638
+ getShortSha(forkPath, syncPoint)
2639
+ ]);
2640
+ const hint = config.ref ? "Pick a newer --ref." : releaseTag ? `Nothing to sync until a release past ${syncShort}; to follow the tip instead, run with --track branch.` : `Nothing to sync until '${branchRef}' moves past ${syncShort}.`;
2641
+ throw new Error(
2642
+ `upstream ${upstreamLabel} (${upstreamShort}) is behind the last sync point ${syncShort}: the fork already has it, and syncing it would revert the upstream changes after it.
2643
+ ${hint}`
2644
+ );
2645
+ }
2555
2646
  const commitCount = await countCommitsBetween(forkPath, mergeBase, upstreamRef);
2556
2647
  const commitListMax = 50;
2557
2648
  const commitSkip = commitCount > commitListMax ? commitCount - commitListMax : 0;
@@ -3776,7 +3867,7 @@ import {
3776
3867
  import { select as select2 } from "@inquirer/prompts";
3777
3868
 
3778
3869
  // src/services/contrib-core.ts
3779
- import { dirname as dirname5, join as join15, resolve as resolve4 } from "path";
3870
+ import { dirname as dirname5, join as join15, resolve as resolve5 } from "path";
3780
3871
  async function listFilesAtRef(repoPath, ref) {
3781
3872
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
3782
3873
  return new Set(raw.split("\n").filter(Boolean));
@@ -3839,7 +3930,7 @@ async function readBlobInfo(repoPath, ref, filePath) {
3839
3930
  }
3840
3931
  async function buildContribBranch(repoPath, baseRef, forkRef, detection, forkName) {
3841
3932
  const branch = `contrib/${forkName}`;
3842
- const gitDir = resolve4(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
3933
+ const gitDir = resolve5(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
3843
3934
  const tmpIndex = join15(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
3844
3935
  const indexEnv = { GIT_INDEX_FILE: tmpIndex };
3845
3936
  try {
@@ -3884,14 +3975,14 @@ ${commitBody}`;
3884
3975
 
3885
3976
  // src/services/fork-utils.ts
3886
3977
  import { existsSync as existsSync9 } from "fs";
3887
- import { dirname as dirname6, resolve as resolve5 } from "path";
3978
+ import { dirname as dirname6, resolve as resolve6 } from "path";
3888
3979
  async function resolveForkBasePath(repoPath) {
3889
3980
  const commonDir = await git(["rev-parse", "--git-common-dir"], repoPath, { ignoreErrors: true });
3890
- return commonDir ? dirname6(resolve5(repoPath, commonDir)) : repoPath;
3981
+ return commonDir ? dirname6(resolve6(repoPath, commonDir)) : repoPath;
3891
3982
  }
3892
3983
  function validateForkPath(fork, basePath, requireConfig = false) {
3893
- const resolvedPath = resolve5(basePath, fork.localPath);
3894
- if (!resolvedPath.startsWith(resolve5(basePath, ".."))) {
3984
+ const resolvedPath = resolve6(basePath, fork.localPath);
3985
+ if (!resolvedPath.startsWith(resolve6(basePath, ".."))) {
3895
3986
  return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
3896
3987
  }
3897
3988
  if (!existsSync9(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
@@ -4259,7 +4350,7 @@ async function runContributions(config) {
4259
4350
  }
4260
4351
 
4261
4352
  // src/services/forks.ts
4262
- import { resolve as resolve6 } from "path";
4353
+ import { resolve as resolve7 } from "path";
4263
4354
  import process8 from "process";
4264
4355
  import { Separator as Separator2, select as select4 } from "@inquirer/prompts";
4265
4356
 
@@ -4517,6 +4608,26 @@ function safeMergePnpm(forkPnpm, upstreamPnpm, basePnpm) {
4517
4608
  }
4518
4609
  return { merged, changed };
4519
4610
  }
4611
+ function mergeModuleType(forkType, upstreamType, baseType) {
4612
+ const asString = (value) => typeof value === "string" ? value : void 0;
4613
+ const fork = asString(forkType);
4614
+ const upstream = asString(upstreamType);
4615
+ if (fork === upstream) return null;
4616
+ if (fork === void 0) return { value: upstream, change: "added" };
4617
+ if (fork !== asString(baseType)) return null;
4618
+ return { value: upstream, change: upstream === void 0 ? "removed" : "updated" };
4619
+ }
4620
+ function setTopLevelKey(pkg, key, value) {
4621
+ if (key in pkg) {
4622
+ pkg[key] = value;
4623
+ return;
4624
+ }
4625
+ const entries = Object.entries(pkg);
4626
+ const after = Math.max(...["name", "version", "private"].map((name) => entries.findIndex(([k]) => k === name)));
4627
+ entries.splice(after + 1, 0, [key, value]);
4628
+ for (const name of Object.keys(pkg)) delete pkg[name];
4629
+ Object.assign(pkg, Object.fromEntries(entries));
4630
+ }
4520
4631
  function readPackageJson(filePath) {
4521
4632
  if (!existsSync11(filePath)) return null;
4522
4633
  try {
@@ -4570,6 +4681,13 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4570
4681
  }
4571
4682
  const upstreamPkg = upstream.data;
4572
4683
  let updated = false;
4684
+ const type = mergeModuleType(forkPkg.type, upstreamPkg.type, basePkg?.type);
4685
+ if (type) {
4686
+ if (type.value === void 0) delete forkPkg.type;
4687
+ else setTopLevelKey(forkPkg, "type", type.value);
4688
+ updated = true;
4689
+ changes.push(`type: ${type.change}`);
4690
+ }
4573
4691
  for (const key of keysToSync) {
4574
4692
  if (key === "pnpm") {
4575
4693
  const result2 = safeMergePnpm(forkPkg.pnpm, upstreamPkg.pnpm, basePkg?.pnpm);
@@ -4677,11 +4795,17 @@ async function runPackages(config, options = {}) {
4677
4795
  }
4678
4796
 
4679
4797
  // src/services/sync.ts
4680
- async function ensureBaseUpToDate(forkPath, base) {
4681
- const { upstream, ahead, behind } = await getUpstreamStatus(forkPath);
4798
+ async function resolveCycleStart(forkPath, base, currentBranch) {
4799
+ if (!await branchExists(forkPath, base)) {
4800
+ await fetch2(forkPath, "origin").catch(() => {
4801
+ });
4802
+ console.info(colors_default.dim(`'${base}' has no local branch \u2014 cutting from 'origin/${base}'.`));
4803
+ return `origin/${base}`;
4804
+ }
4805
+ const { upstream, ahead, behind } = await getUpstreamStatus(forkPath, base);
4682
4806
  if (!upstream) {
4683
4807
  console.info(colors_default.dim(`'${base}' has no upstream \u2014 skipping the up-to-date check.`));
4684
- return;
4808
+ return base;
4685
4809
  }
4686
4810
  if (ahead > 0 && behind > 0) {
4687
4811
  throw new Error(
@@ -4690,26 +4814,53 @@ Reconcile '${base}' with '${upstream}' first (e.g. rebase or reset it), then re-
4690
4814
  );
4691
4815
  }
4692
4816
  if (behind > 0) {
4817
+ const worktree = currentBranch === base ? null : await getBranchWorktree(forkPath, base);
4818
+ if (worktree) {
4819
+ console.info(
4820
+ colors_default.dim(`'${base}' is ${behind} behind '${upstream}' but checked out at ${worktree} \u2014 leaving it as it is.`)
4821
+ );
4822
+ return upstream;
4823
+ }
4693
4824
  console.info(colors_default.dim(`fast-forwarding '${base}' to '${upstream}' (${behind} behind)...`));
4694
- await pullFastForward(forkPath);
4695
- return;
4825
+ if (currentBranch === base) await pullFastForward(forkPath);
4826
+ else await fastForwardBranch(forkPath, base, upstream);
4827
+ return base;
4696
4828
  }
4697
4829
  if (ahead > 0) {
4698
4830
  console.info(colors_default.dim(`'${base}' is ${ahead} commit(s) ahead of '${upstream}' (unpushed) \u2014 continuing.`));
4699
4831
  }
4832
+ return base;
4700
4833
  }
4701
4834
  async function setupTemporarySyncBranch(config) {
4702
4835
  const { forkPath, settings } = config;
4703
4836
  const base = resolveReleaseBase(settings);
4704
4837
  const startBranch = await getCurrentBranch(forkPath);
4705
- console.info(colors_default.dim(`switching to '${base}' and updating...`));
4706
- await switchBranch(forkPath, base);
4707
- await ensureBaseUpToDate(forkPath, base);
4838
+ const startCommit = (await getCommitInfo(forkPath, "HEAD")).hash;
4839
+ console.info(colors_default.dim(`updating '${base}'...`));
4840
+ const startPoint = await resolveCycleStart(forkPath, base, startBranch);
4708
4841
  const temporaryBranch = buildTemporarySyncBranch();
4709
- console.info(colors_default.dim(`creating temporary sync branch '${temporaryBranch}' from '${base}'...`));
4842
+ console.info(colors_default.dim(`creating temporary sync branch '${temporaryBranch}' from '${startPoint}'...`));
4710
4843
  console.info();
4711
- await createBranchFrom(forkPath, temporaryBranch, base);
4712
- return { temporaryBranch, base, startBranch };
4844
+ await createBranchFrom(forkPath, temporaryBranch, startPoint);
4845
+ return { temporaryBranch, base, startBranch, startCommit };
4846
+ }
4847
+ async function resolveCutBase(forkPath, base) {
4848
+ if (!await branchExists(forkPath, base)) return `origin/${base}`;
4849
+ const upstream = await getBranchUpstream(forkPath, base);
4850
+ return upstream && await isAncestor(forkPath, base, upstream) ? upstream : base;
4851
+ }
4852
+ async function returnToBase(forkPath, base) {
4853
+ const worktree = await getBranchWorktree(forkPath, base);
4854
+ if (worktree) {
4855
+ console.info(colors_default.dim(`'${base}' is checked out at ${worktree} \u2014 detaching at '${base}' here instead...`));
4856
+ await switchDetached(forkPath, base);
4857
+ return `detached at '${base}'`;
4858
+ }
4859
+ console.info(colors_default.dim(`switching back to '${base}'...`));
4860
+ await switchBranch(forkPath, base);
4861
+ await pullFastForward(forkPath).catch(() => {
4862
+ });
4863
+ return `back on '${base}'`;
4713
4864
  }
4714
4865
  async function runSync(config, options) {
4715
4866
  createSpinner("starting sync...");
@@ -4850,7 +5001,8 @@ function indentLines(text) {
4850
5001
  async function shipSyncBranch(config, branch) {
4851
5002
  const { forkPath, settings } = config;
4852
5003
  const base = resolveReleaseBase(settings);
4853
- const flattened = await flattenSyncBranch(forkPath, branch, base);
5004
+ const cutBase = await resolveCutBase(forkPath, base);
5005
+ const flattened = await flattenSyncBranch(forkPath, branch, cutBase);
4854
5006
  let prUrl;
4855
5007
  let prOpened = false;
4856
5008
  const headSubject = (await getCommitInfo(forkPath, "HEAD").catch(() => null))?.message;
@@ -4866,7 +5018,7 @@ async function shipSyncBranch(config, branch) {
4866
5018
  }
4867
5019
  if (ghAvailable()) {
4868
5020
  console.info(colors_default.dim("opening a pull request..."));
4869
- const prBody = await buildSyncPrBodyForBranch(forkPath, base);
5021
+ const prBody = await buildSyncPrBodyForBranch(forkPath, cutBase);
4870
5022
  const bodyArgs = prBody ? ["--body", prBody] : ["--fill"];
4871
5023
  const pr = spawnSync3("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
4872
5024
  cwd: forkPath,
@@ -4885,17 +5037,14 @@ ${pr.stderr ?? ""}`);
4885
5037
  console.info(colors_default.yellow("`gh` not found \u2014 open the PR manually:"));
4886
5038
  printPrCreateStep(branch, base, prTitle);
4887
5039
  }
4888
- console.info(colors_default.dim(`switching back to '${base}'...`));
4889
- await switchBranch(forkPath, base);
4890
- await pullFastForward(forkPath).catch(() => {
4891
- });
5040
+ const position = await returnToBase(forkPath, base);
4892
5041
  console.info();
4893
5042
  if (prUrl) {
4894
5043
  console.info(`${colors_default.green("\u2713")} Sync pull request ${prOpened ? "opened" : "ready"}`);
4895
- console.info(colors_default.dim(` ${prUrl} \xB7 branch pushed, back on '${base}'`));
5044
+ console.info(colors_default.dim(` ${prUrl} \xB7 branch pushed, ${position}`));
4896
5045
  } else {
4897
5046
  console.info(`${colors_default.green("\u2713")} Sync branch pushed`);
4898
- console.info(colors_default.dim(` '${branch}' is on origin, back on '${base}'`));
5047
+ console.info(colors_default.dim(` '${branch}' is on origin, ${position}`));
4899
5048
  }
4900
5049
  await printMigrationNotesLine(config);
4901
5050
  }
@@ -4915,8 +5064,9 @@ async function runSyncCycle(config) {
4915
5064
  await runPackages(config, { conflictedFiles: result.conflicts });
4916
5065
  }
4917
5066
  if (result.conflicts.length > 0) return { status: "conflicts", branch };
4918
- if (!mergeInProgress(forkPath)) {
4919
- await switchBranch(forkPath, branch.startBranch === branch.temporaryBranch ? branch.base : branch.startBranch);
5067
+ if (!await mergeInProgress(forkPath)) {
5068
+ if (branch.startBranch === "HEAD") await switchDetached(forkPath, branch.startCommit);
5069
+ else await switchBranch(forkPath, branch.startBranch);
4920
5070
  await deleteBranch(forkPath, branch.temporaryBranch);
4921
5071
  return { status: "noop" };
4922
5072
  }
@@ -4974,9 +5124,6 @@ async function mergeOpenSyncPrs(config, open) {
4974
5124
  closePr(forkPath, pr.number);
4975
5125
  await deleteBranch(forkPath, pr.headRefName);
4976
5126
  }
4977
- console.info(colors_default.dim(`switching to '${base}' and fast-forwarding...`));
4978
- await switchBranch(forkPath, base);
4979
- await pullFastForward(forkPath);
4980
5127
  return "continue";
4981
5128
  }
4982
5129
  async function guardAgainstOpenSyncPr(config) {
@@ -5015,7 +5162,7 @@ async function runSyncCommand(config) {
5015
5162
  const { forkPath } = config;
5016
5163
  const currentBranch = await getCurrentBranch(forkPath);
5017
5164
  const onSyncBranch = isTemporarySyncBranch(currentBranch);
5018
- if (onSyncBranch && mergeInProgress(forkPath)) {
5165
+ if (onSyncBranch && await mergeInProgress(forkPath)) {
5019
5166
  await commitSyncMerge(config, currentBranch);
5020
5167
  return;
5021
5168
  }
@@ -5138,7 +5285,7 @@ async function runForks(config) {
5138
5285
  console.error(colors_default.red(`fork '${config.fork}' not found in config`));
5139
5286
  return;
5140
5287
  }
5141
- const resolvedPath = resolve6(await resolveForkBasePath(config.forkPath), match.localPath);
5288
+ const resolvedPath = resolve7(await resolveForkBasePath(config.forkPath), match.localPath);
5142
5289
  await syncFork(config, match, resolvedPath);
5143
5290
  return;
5144
5291
  }
@@ -5154,7 +5301,7 @@ async function runForks(config) {
5154
5301
  if (selectedPath === "_exit") {
5155
5302
  process8.exit(0);
5156
5303
  }
5157
- const resolvedForkPath = resolve6(forkBasePath, selectedPath);
5304
+ const resolvedForkPath = resolve7(forkBasePath, selectedPath);
5158
5305
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
5159
5306
  if (!selectedFork) {
5160
5307
  console.error(colors_default.red(`fork '${selectedPath}' not found in config`));
@@ -5424,7 +5571,7 @@ async function runStats(forkPath, options = {}) {
5424
5571
  function getForkPath() {
5425
5572
  const envPath = getEnv("CELLA_FORK_PATH");
5426
5573
  if (envPath) {
5427
- const resolved = resolve7(envPath);
5574
+ const resolved = resolve8(envPath);
5428
5575
  if (!existsSync13(resolved) || !statSync3(resolved).isDirectory()) {
5429
5576
  throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
5430
5577
  }
@@ -5432,7 +5579,7 @@ function getForkPath() {
5432
5579
  }
5433
5580
  let cwd = process9.cwd();
5434
5581
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
5435
- cwd = resolve7(cwd, "../..");
5582
+ cwd = resolve8(cwd, "../..");
5436
5583
  }
5437
5584
  return cwd;
5438
5585
  }