@biffo/cli 0.159.0 → 0.160.0

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.
Files changed (2) hide show
  1. package/dist/index.js +522 -422
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -506,15 +506,317 @@ import { Command as Command3 } from "commander";
506
506
 
507
507
  // src/adapters/git/index.ts
508
508
  import { randomUUID } from "crypto";
509
- import { existsSync as existsSync4, mkdtempSync, rmSync } from "fs";
509
+ import { existsSync as existsSync6, mkdtempSync as mkdtempSync2, rmSync as rmSync2 } from "fs";
510
+ import { tmpdir as tmpdir2 } from "os";
511
+ import { join as join6 } from "path";
512
+ import { execa as execa2 } from "execa";
513
+
514
+ // src/lib/core-upgrade.ts
515
+ import {
516
+ chmodSync,
517
+ existsSync as existsSync5,
518
+ mkdirSync,
519
+ mkdtempSync,
520
+ readFileSync as readFileSync4,
521
+ rmSync,
522
+ statSync,
523
+ writeFileSync as writeFileSync2
524
+ } from "fs";
510
525
  import { tmpdir } from "os";
511
- import { join as join4 } from "path";
526
+ import { dirname as dirname3, join as join5 } from "path";
512
527
  import { execa } from "execa";
528
+
529
+ // src/lib/core-ownership-guard.ts
530
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
531
+ import { join as join4 } from "path";
532
+ import { z as z3 } from "zod";
533
+ var DIVERGENCE_FILE = "biffo.divergence.json";
534
+ var DivergenceEntrySchema = z3.object({
535
+ prefix: z3.string().min(1),
536
+ reason: z3.string().min(1),
537
+ upstream: z3.string().min(1)
538
+ });
539
+ var DivergenceConfigSchema = z3.object({
540
+ note: z3.string().optional(),
541
+ warnOnly: z3.array(DivergenceEntrySchema).default([])
542
+ });
543
+ function readDivergenceConfig(repoRoot) {
544
+ const path = join4(repoRoot, DIVERGENCE_FILE);
545
+ if (!existsSync4(path)) return { warnOnly: [] };
546
+ let raw;
547
+ try {
548
+ raw = JSON.parse(readFileSync3(path, "utf8"));
549
+ } catch (err) {
550
+ throw new Error(`${DIVERGENCE_FILE} is not valid JSON: ${err.message}`);
551
+ }
552
+ const parsed = DivergenceConfigSchema.safeParse(raw);
553
+ if (!parsed.success) {
554
+ const issues = parsed.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
555
+ throw new Error(`${DIVERGENCE_FILE} is invalid:
556
+ ${issues}`);
557
+ }
558
+ return parsed.data;
559
+ }
560
+ function parseTrailer(commitMessage, key) {
561
+ const body = commitMessage.split("\n").filter((line) => !line.startsWith("#")).join("\n");
562
+ const match = new RegExp(String.raw`^Core-${key}:[ \t]*(\S.*?)[ \t]*$`, "m").exec(body);
563
+ return match?.[1] ?? null;
564
+ }
565
+ function parseDivergenceTrailer(commitMessage) {
566
+ return parseTrailer(commitMessage, "Divergence");
567
+ }
568
+ function parseConvergenceTrailer(commitMessage) {
569
+ return parseTrailer(commitMessage, "Convergence");
570
+ }
571
+ function resolveBranch(env, gitBranch) {
572
+ return (env["GITHUB_HEAD_REF"] || env["GITHUB_REF_NAME"] || gitBranch).trim();
573
+ }
574
+ function parseNameStatus(stdout) {
575
+ const changed = [];
576
+ const deleted = [];
577
+ for (const line of stdout.split("\n")) {
578
+ const parts = line.split(" ").filter(Boolean);
579
+ const status = parts[0];
580
+ const path = parts[parts.length - 1];
581
+ if (!status || !path || parts.length < 2) continue;
582
+ changed.push(path);
583
+ if (status.startsWith("D")) deleted.push(path);
584
+ }
585
+ return { changed, deleted };
586
+ }
587
+ function checkCoreOwnership({
588
+ changedFiles,
589
+ manifest,
590
+ isInstance,
591
+ branch = "",
592
+ commitMessage = "",
593
+ warnOnly = []
594
+ }) {
595
+ const empty = { blocked: [], warned: [], divergenceReason: null, convergenceReason: null };
596
+ if (!isInstance) return { skipped: "template", ...empty };
597
+ if (branch.startsWith(UPGRADE_BRANCH_PREFIX)) return { skipped: "upgrade-branch", ...empty };
598
+ const templateOwned = changedFiles.filter((f) => isTemplateOwned(f, manifest));
599
+ const acknowledged = (path) => warnOnly.filter((entry) => path.startsWith(entry.prefix)).reduce(
600
+ (best, entry) => !best || entry.prefix.length > best.prefix.length ? entry : best,
601
+ void 0
602
+ );
603
+ const warned = [];
604
+ const offending = [];
605
+ for (const path of templateOwned) {
606
+ const entry = acknowledged(path);
607
+ if (entry) warned.push({ path, entry });
608
+ else offending.push(path);
609
+ }
610
+ const divergenceReason = parseDivergenceTrailer(commitMessage);
611
+ const convergenceReason = parseConvergenceTrailer(commitMessage);
612
+ if (offending.length > 0) {
613
+ if (divergenceReason !== null) {
614
+ return {
615
+ skipped: "divergence-trailer",
616
+ blocked: [],
617
+ warned,
618
+ divergenceReason,
619
+ convergenceReason: null
620
+ };
621
+ }
622
+ if (convergenceReason !== null) {
623
+ return {
624
+ skipped: "convergence-trailer",
625
+ blocked: [],
626
+ warned,
627
+ divergenceReason: null,
628
+ convergenceReason
629
+ };
630
+ }
631
+ }
632
+ return {
633
+ skipped: null,
634
+ blocked: offending,
635
+ warned,
636
+ divergenceReason: null,
637
+ convergenceReason: null
638
+ };
639
+ }
640
+
641
+ // src/lib/core-upgrade.ts
642
+ var gitMergeFile = async (base, ours, theirs) => {
643
+ const dir = mkdtempSync(join5(tmpdir(), "biffo-merge-"));
644
+ try {
645
+ const b = join5(dir, "base");
646
+ const o = join5(dir, "ours");
647
+ const t = join5(dir, "theirs");
648
+ writeFileSync2(b, base);
649
+ writeFileSync2(o, ours);
650
+ writeFileSync2(t, theirs);
651
+ const result = await execa("git", ["merge-file", "-p", o, b, t], {
652
+ reject: false,
653
+ stripFinalNewline: false
654
+ });
655
+ if (typeof result.exitCode !== "number" || result.exitCode < 0) {
656
+ throw new Error(`git merge-file failed: ${result.stderr}`);
657
+ }
658
+ return { conflicted: result.exitCode > 0, content: result.stdout };
659
+ } finally {
660
+ rmSync(dir, { recursive: true, force: true });
661
+ }
662
+ };
663
+ function read(root, rel) {
664
+ return readFileSync4(join5(root, rel), "utf8");
665
+ }
666
+ var EMPTY_SUMMARY = () => ({
667
+ unchanged: 0,
668
+ "take-theirs": 0,
669
+ "keep-ours": 0,
670
+ merged: 0,
671
+ conflict: 0,
672
+ added: 0,
673
+ "add-conflict": 0,
674
+ restored: 0,
675
+ removed: 0,
676
+ "remove-conflict": 0
677
+ });
678
+ async function planCoreUpgrade(options) {
679
+ const mergeFile = options.mergeFile ?? gitMergeFile;
680
+ const base = new Set(listTemplateOwnedFiles(options.baseDir, options.manifest));
681
+ const ours = new Set(listTemplateOwnedFiles(options.oursDir, options.manifest));
682
+ const theirs = new Set(listTemplateOwnedFiles(options.theirsDir, options.manifest));
683
+ const divergentPrefixes = readDivergenceConfig(options.oursDir).warnOnly.map((e) => e.prefix);
684
+ const isDeclaredDivergent = (path) => divergentPrefixes.some((prefix) => path.startsWith(prefix));
685
+ const paths = [.../* @__PURE__ */ new Set([...base, ...ours, ...theirs])].sort();
686
+ const entries = [];
687
+ const divergenceSkips = [];
688
+ for (const path of paths) {
689
+ entries.push(
690
+ await classify(
691
+ path,
692
+ base,
693
+ ours,
694
+ theirs,
695
+ options,
696
+ mergeFile,
697
+ isDeclaredDivergent,
698
+ (p) => divergenceSkips.push(p)
699
+ )
700
+ );
701
+ }
702
+ const summary = EMPTY_SUMMARY();
703
+ for (const e of entries) summary[e.status]++;
704
+ const changes = entries.filter((e) => e.status !== "unchanged" && e.status !== "keep-ours");
705
+ const conflicts = entries.filter((e) => e.conflicted);
706
+ return { entries, changes, conflicts, summary, divergenceSkips };
707
+ }
708
+ async function classify(path, base, ours, theirs, opts, mergeFile, isDeclaredDivergent, noteDivergenceSkip) {
709
+ const inBase = base.has(path);
710
+ const inOurs = ours.has(path);
711
+ const inTheirs = theirs.has(path);
712
+ if (!inBase && !inTheirs) {
713
+ return { path, status: "keep-ours", conflicted: false };
714
+ }
715
+ if (!inBase && inTheirs) {
716
+ const theirsContent2 = read(opts.theirsDir, path);
717
+ if (!inOurs) return { path, status: "added", conflicted: false, content: theirsContent2 };
718
+ const oursContent2 = read(opts.oursDir, path);
719
+ if (oursContent2 === theirsContent2) return { path, status: "unchanged", conflicted: false };
720
+ return { path, status: "add-conflict", conflicted: true, content: theirsContent2 };
721
+ }
722
+ if (inBase && !inTheirs) {
723
+ if (!inOurs) return { path, status: "removed", conflicted: false };
724
+ const baseContent2 = read(opts.baseDir, path);
725
+ const oursContent2 = read(opts.oursDir, path);
726
+ if (oursContent2 === baseContent2) return { path, status: "removed", conflicted: false };
727
+ return { path, status: "remove-conflict", conflicted: true };
728
+ }
729
+ const baseContent = read(opts.baseDir, path);
730
+ const theirsContent = read(opts.theirsDir, path);
731
+ if (!inOurs) {
732
+ if (isDeclaredDivergent(path)) {
733
+ noteDivergenceSkip(path);
734
+ return { path, status: "removed", conflicted: false };
735
+ }
736
+ return { path, status: "restored", conflicted: false, content: theirsContent };
737
+ }
738
+ const oursContent = read(opts.oursDir, path);
739
+ const oursChanged = oursContent !== baseContent;
740
+ const theirsChanged = theirsContent !== baseContent;
741
+ if (!theirsChanged) {
742
+ return { path, status: oursChanged ? "keep-ours" : "unchanged", conflicted: false };
743
+ }
744
+ if (!oursChanged) {
745
+ return { path, status: "take-theirs", conflicted: false, content: theirsContent };
746
+ }
747
+ if (oursContent === theirsContent) {
748
+ return { path, status: "unchanged", conflicted: false };
749
+ }
750
+ const { conflicted, content } = await mergeFile(baseContent, oursContent, theirsContent);
751
+ return { path, status: conflicted ? "conflict" : "merged", conflicted, content };
752
+ }
753
+ function applyUpgradePlan(instanceDir, plan, theirsDir) {
754
+ const written = [];
755
+ const deleted = [];
756
+ for (const e of plan.entries) {
757
+ const abs = join5(instanceDir, e.path);
758
+ if (e.status === "removed") {
759
+ if (existsSync5(abs)) {
760
+ rmSync(abs);
761
+ deleted.push(e.path);
762
+ }
763
+ continue;
764
+ }
765
+ if (e.content !== void 0) {
766
+ mkdirSync(dirname3(abs), { recursive: true });
767
+ writeFileSync2(abs, e.content);
768
+ if (theirsDir !== void 0) {
769
+ const source = join5(theirsDir, e.path);
770
+ if (existsSync5(source) && (statSync(source).mode & 73) !== 0) {
771
+ chmodSync(abs, 493);
772
+ }
773
+ }
774
+ written.push(e.path);
775
+ }
776
+ }
777
+ return { written, deleted };
778
+ }
779
+ function parseGitHubRepo(remoteUrl) {
780
+ const ssh = /^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(remoteUrl);
781
+ if (ssh && ssh[1] && ssh[2]) return { owner: ssh[1], repo: ssh[2] };
782
+ const https = /^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(remoteUrl);
783
+ if (https && https[1] && https[2]) return { owner: https[1], repo: https[2] };
784
+ throw new Error(`Could not parse a GitHub owner/repo from remote URL: ${remoteUrl}`);
785
+ }
786
+ var UPGRADE_BRANCH_PREFIX = "biffo/core-upgrade-";
787
+ function upgradeBranchName(from, to) {
788
+ return `${UPGRADE_BRANCH_PREFIX}${from}-to-${to}`.replace(/[^a-zA-Z0-9._/-]/g, "-");
789
+ }
790
+
791
+ // src/lib/upgrade-branch-reaper.ts
792
+ var BRANCH_REF_FORMAT = "%(refname:short) %(upstream) %(upstream:track)";
793
+ function parseBranchRefs(stdout) {
794
+ return stdout.split("\n").map((line) => line.trimEnd()).filter((line) => line !== "").map((line) => {
795
+ const [name = "", upstream = "", track = ""] = line.split(" ");
796
+ return { name, upstream, track };
797
+ }).filter((ref) => ref.name !== "");
798
+ }
799
+ function classifyUpgradeBranches(refs, currentBranch) {
800
+ const reapable = [];
801
+ const unverifiable = [];
802
+ for (const ref of refs) {
803
+ if (!ref.name.startsWith(UPGRADE_BRANCH_PREFIX)) continue;
804
+ if (ref.name === currentBranch) continue;
805
+ if (ref.track.includes("gone")) {
806
+ reapable.push(ref.name);
807
+ } else if (ref.upstream === "") {
808
+ unverifiable.push(ref.name);
809
+ }
810
+ }
811
+ return { reapable, unverifiable };
812
+ }
813
+
814
+ // src/adapters/git/index.ts
513
815
  var GitAdapter = class {
514
816
  /** True if `cwd` is inside a git working tree. */
515
817
  async isGitRepo(cwd) {
516
818
  try {
517
- await execa("git", ["rev-parse", "--is-inside-work-tree"], { cwd });
819
+ await execa2("git", ["rev-parse", "--is-inside-work-tree"], { cwd });
518
820
  return true;
519
821
  } catch {
520
822
  return false;
@@ -536,16 +838,16 @@ var GitAdapter = class {
536
838
  * or a crash report.
537
839
  */
538
840
  async cloneToTemp(repoUrl, namePrefix, token) {
539
- const dir = mkdtempSync(join4(tmpdir(), `${namePrefix}-${randomUUID().slice(0, 8)}-`));
841
+ const dir = mkdtempSync2(join6(tmpdir2(), `${namePrefix}-${randomUUID().slice(0, 8)}-`));
540
842
  const cloneUrl = token ? injectToken(repoUrl, token) : repoUrl;
541
843
  try {
542
- await execa("git", ["clone", "--depth", "1", cloneUrl, dir]);
844
+ await execa2("git", ["clone", "--depth", "1", cloneUrl, dir]);
543
845
  } catch (err) {
544
- rmSync(dir, { recursive: true, force: true });
846
+ rmSync2(dir, { recursive: true, force: true });
545
847
  throw new Error(`Failed to clone ${repoUrl}: ${err.message}`);
546
848
  }
547
- const gitDir = join4(dir, ".git");
548
- if (existsSync4(gitDir)) rmSync(gitDir, { recursive: true, force: true });
849
+ const gitDir = join6(dir, ".git");
850
+ if (existsSync6(gitDir)) rmSync2(gitDir, { recursive: true, force: true });
549
851
  return dir;
550
852
  }
551
853
  /**
@@ -559,19 +861,19 @@ var GitAdapter = class {
559
861
  * method have no need for.
560
862
  */
561
863
  async cloneForEditing(repoUrl, namePrefix, token) {
562
- const dir = mkdtempSync(join4(tmpdir(), `${namePrefix}-${randomUUID().slice(0, 8)}-`));
864
+ const dir = mkdtempSync2(join6(tmpdir2(), `${namePrefix}-${randomUUID().slice(0, 8)}-`));
563
865
  const cloneUrl = token ? injectToken(repoUrl, token) : repoUrl;
564
866
  try {
565
- await execa("git", ["clone", cloneUrl, dir]);
867
+ await execa2("git", ["clone", cloneUrl, dir]);
566
868
  } catch (err) {
567
- rmSync(dir, { recursive: true, force: true });
869
+ rmSync2(dir, { recursive: true, force: true });
568
870
  throw new Error(`Failed to clone ${repoUrl}: ${err.message}`);
569
871
  }
570
872
  return dir;
571
873
  }
572
874
  /** Removes a directory created by cloneToTemp/cloneForEditing. Safe to call more than once. */
573
875
  cleanup(dir) {
574
- rmSync(dir, { recursive: true, force: true });
876
+ rmSync2(dir, { recursive: true, force: true });
575
877
  }
576
878
  /**
577
879
  * `git init` a fresh working tree — for `biffo sibling create` (ADR-0007),
@@ -586,26 +888,26 @@ var GitAdapter = class {
586
888
  * repo uses (#559).
587
889
  */
588
890
  async init(cwd, initialBranch = "dev") {
589
- await execa("git", ["init", "-b", initialBranch], { cwd });
891
+ await execa2("git", ["init", "-b", initialBranch], { cwd });
590
892
  }
591
893
  /** Adds a remote. Fails if a remote with this name already exists. */
592
894
  async addRemote(cwd, name, url) {
593
- await execa("git", ["remote", "add", name, url], { cwd });
895
+ await execa2("git", ["remote", "add", name, url], { cwd });
594
896
  }
595
897
  async add(cwd, paths) {
596
- await execa("git", ["add", ...paths], { cwd });
898
+ await execa2("git", ["add", ...paths], { cwd });
597
899
  }
598
900
  async commit(cwd, message) {
599
- await execa("git", ["commit", "-m", message], { cwd });
901
+ await execa2("git", ["commit", "-m", message], { cwd });
600
902
  }
601
903
  /** The current branch name (e.g. "dev"). */
602
904
  async currentBranch(cwd) {
603
- const { stdout } = await execa("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
905
+ const { stdout } = await execa2("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
604
906
  return stdout.trim();
605
907
  }
606
908
  /** True if the working tree or index has uncommitted changes. */
607
909
  async hasUncommittedChanges(cwd) {
608
- const { stdout } = await execa("git", ["status", "--porcelain"], { cwd });
910
+ const { stdout } = await execa2("git", ["status", "--porcelain"], { cwd });
609
911
  return stdout.trim().length > 0;
610
912
  }
611
913
  /** Best-effort fetch of the tracking remote, so the ahead/behind check below
@@ -613,13 +915,13 @@ var GitAdapter = class {
613
915
  * offline or a missing remote must not block an upgrade on its own — the
614
916
  * ahead/behind check that follows simply works from whatever is local. */
615
917
  async fetch(cwd, remote = "origin") {
616
- await execa("git", ["fetch", "--quiet", remote], { cwd, reject: false });
918
+ await execa2("git", ["fetch", "--quiet", remote], { cwd, reject: false });
617
919
  }
618
920
  /** HEAD's position relative to its upstream. `hasUpstream` is false when the
619
921
  * branch tracks nothing (or HEAD is detached), in which case currency cannot
620
922
  * be established and ahead/behind are 0 (#394). */
621
923
  async aheadBehind(cwd) {
622
- const { stdout, exitCode } = await execa(
924
+ const { stdout, exitCode } = await execa2(
623
925
  "git",
624
926
  ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
625
927
  { cwd, reject: false }
@@ -630,12 +932,48 @@ var GitAdapter = class {
630
932
  }
631
933
  /** The fetch URL of a remote (default "origin"). */
632
934
  async getRemoteUrl(cwd, remote = "origin") {
633
- const { stdout } = await execa("git", ["remote", "get-url", remote], { cwd });
935
+ const { stdout } = await execa2("git", ["remote", "get-url", remote], { cwd });
634
936
  return stdout.trim();
635
937
  }
938
+ /**
939
+ * Prunes remote-tracking refs whose remote branch is gone, so `upstream:track`
940
+ * reports `[gone]` for a merged-and-deleted branch (#758).
941
+ *
942
+ * `fetch()` above deliberately does NOT prune: it exists to make the currency
943
+ * check compare against reality, and pruning is a side effect no caller of it
944
+ * asked for. Reaping genuinely needs it — without a prune, a branch whose
945
+ * remote copy was deleted last month still looks alive — so it is a separate,
946
+ * equally best-effort call.
947
+ */
948
+ async fetchPrune(cwd, remote = "origin") {
949
+ await execa2("git", ["fetch", "--quiet", "--prune", remote], { cwd, reject: false });
950
+ }
951
+ /** Every local branch with its upstream and tracking state (#758). */
952
+ async listBranchRefs(cwd) {
953
+ const { stdout, exitCode } = await execa2(
954
+ "git",
955
+ ["for-each-ref", `--format=${BRANCH_REF_FORMAT}`, "refs/heads"],
956
+ { cwd, reject: false }
957
+ );
958
+ if (exitCode !== 0) return [];
959
+ return parseBranchRefs(stdout);
960
+ }
961
+ /**
962
+ * Force-deletes a local branch, returning whether it went.
963
+ *
964
+ * `-D` rather than `-d` because these branches are squash-merged: their tips
965
+ * are never ancestors of the base, so `-d` refuses every one of them. That is
966
+ * precisely why nobody ever cleaned them up by hand. The safety that `-d`
967
+ * would have provided is supplied instead by the caller, which only ever
968
+ * passes branches whose upstream git reports as gone.
969
+ */
970
+ async deleteBranch(cwd, branch) {
971
+ const { exitCode } = await execa2("git", ["branch", "-D", branch], { cwd, reject: false });
972
+ return exitCode === 0;
973
+ }
636
974
  /** Create and switch to a new branch. Fails if it already exists. */
637
975
  async createBranch(cwd, branch) {
638
- await execa("git", ["switch", "-c", branch], { cwd });
976
+ await execa2("git", ["switch", "-c", branch], { cwd });
639
977
  }
640
978
  /**
641
979
  * Push the current HEAD to `branch` on the remote. When `token` is given and
@@ -653,7 +991,7 @@ var GitAdapter = class {
653
991
  if (authed !== url) target = authed;
654
992
  }
655
993
  try {
656
- await execa("git", ["push", target, `HEAD:refs/heads/${branch}`], { cwd });
994
+ await execa2("git", ["push", target, `HEAD:refs/heads/${branch}`], { cwd });
657
995
  } catch {
658
996
  throw new Error(`Failed to push branch '${branch}' to remote '${remote}'.`);
659
997
  }
@@ -704,9 +1042,9 @@ var GitAdapter = class {
704
1042
  */
705
1043
  async setUpstreamAfterPush(cwd, branch, remote) {
706
1044
  const opts = { cwd, reject: false };
707
- await execa("git", ["update-ref", `refs/remotes/${remote}/${branch}`, "HEAD"], opts);
708
- await execa("git", ["config", `branch.${branch}.remote`, remote], opts);
709
- await execa("git", ["config", `branch.${branch}.merge`, `refs/heads/${branch}`], opts);
1045
+ await execa2("git", ["update-ref", `refs/remotes/${remote}/${branch}`, "HEAD"], opts);
1046
+ await execa2("git", ["config", `branch.${branch}.remote`, remote], opts);
1047
+ await execa2("git", ["config", `branch.${branch}.merge`, `refs/heads/${branch}`], opts);
710
1048
  }
711
1049
  };
712
1050
  function injectToken(repoUrl, token) {
@@ -1441,8 +1779,8 @@ var GitHubAdapter = class {
1441
1779
 
1442
1780
  // src/lib/core-migrations.ts
1443
1781
  import { createHash } from "crypto";
1444
- import { existsSync as existsSync5, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
1445
- import { dirname as dirname3, join as join5 } from "path";
1782
+ import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync5, readdirSync as readdirSync2, writeFileSync as writeFileSync3 } from "fs";
1783
+ import { dirname as dirname4, join as join7 } from "path";
1446
1784
  var MIGRATIONS_VERSIONS_DIR = "services/api/migrations/versions";
1447
1785
  var REVISION_RE = /^(revision\b[^=\n]*=\s*)(.+)$/m;
1448
1786
  var DOWN_REVISION_RE = /^(down_revision\b[^=\n]*=\s*)(.+)$/m;
@@ -1481,8 +1819,8 @@ function parseMigration(file, content) {
1481
1819
  return { file, revision, downRevision, content };
1482
1820
  }
1483
1821
  function readMigrations(versionsDir) {
1484
- if (!existsSync5(versionsDir)) return [];
1485
- return readdirSync2(versionsDir).filter((f) => f.endsWith(".py") && f !== "__init__.py").sort().map((f) => parseMigration(f, readFileSync3(join5(versionsDir, f), "utf8")));
1822
+ if (!existsSync7(versionsDir)) return [];
1823
+ return readdirSync2(versionsDir).filter((f) => f.endsWith(".py") && f !== "__init__.py").sort().map((f) => parseMigration(f, readFileSync5(join7(versionsDir, f), "utf8")));
1486
1824
  }
1487
1825
  function rechainMigration(content, revision, downRevision) {
1488
1826
  let out = content.replace(REVISION_RE, (_m, lhs) => `${lhs}${renderLiteral(revision)}`);
@@ -1599,8 +1937,8 @@ function migrationSlug(file) {
1599
1937
  return file.replace(/\.py$/, "").replace(/^[0-9a-f]+_/i, "");
1600
1938
  }
1601
1939
  function planMigrationCarry(options) {
1602
- const templateVersions = join5(options.templateDir, MIGRATIONS_VERSIONS_DIR);
1603
- const instanceVersions = join5(options.instanceDir, MIGRATIONS_VERSIONS_DIR);
1940
+ const templateVersions = join7(options.templateDir, MIGRATIONS_VERSIONS_DIR);
1941
+ const instanceVersions = join7(options.instanceDir, MIGRATIONS_VERSIONS_DIR);
1604
1942
  const template = readMigrations(templateVersions);
1605
1943
  const instance = readMigrations(instanceVersions);
1606
1944
  const instanceHead = validateChain(instance, `${MIGRATIONS_VERSIONS_DIR} (instance)`);
@@ -1639,414 +1977,137 @@ function planMigrationCarry(options) {
1639
1977
  file: m.file,
1640
1978
  instanceFile: already.instance.file,
1641
1979
  how: already.how
1642
- });
1643
- }
1644
- continue;
1645
- }
1646
- let revision = m.revision;
1647
- let reissuedFrom;
1648
- if (usedRevisions.has(revision)) {
1649
- const replacement = reissuedRevisionId(m.file);
1650
- if (usedRevisions.has(replacement)) {
1651
- throw new Error(
1652
- `Cannot carry ${m.file}: its revision id "${revision}" is already used in the instance, and so is the deterministic replacement "${replacement}". Resolve manually.`
1653
- );
1654
- }
1655
- reissuedFrom = revision;
1656
- revision = replacement;
1657
- }
1658
- const entry = {
1659
- path: `${MIGRATIONS_VERSIONS_DIR}/${m.file}`,
1660
- file: m.file,
1661
- revision,
1662
- downRevision: head,
1663
- // Stamped before re-chaining so the instance's copy records which template
1664
- // migration it is, whatever it is later renamed or renumbered to.
1665
- content: rechainMigration(stampCarriedFrom(m.content, m.file), revision, head)
1666
- };
1667
- if (reissuedFrom !== void 0) entry.reissuedFrom = reissuedFrom;
1668
- entries.push(entry);
1669
- usedRevisions.add(revision);
1670
- head = revision;
1671
- }
1672
- validateChain(
1673
- [
1674
- ...instance,
1675
- ...entries.map((e) => ({
1676
- file: e.file,
1677
- revision: e.revision,
1678
- downRevision: e.downRevision,
1679
- content: e.content
1680
- }))
1681
- ],
1682
- `${MIGRATIONS_VERSIONS_DIR} (after carry)`
1683
- );
1684
- const templateFiles = new Set(template.map((m) => m.file));
1685
- const staleDeclines = [...declinedIndex.keys()].filter((f) => !templateFiles.has(f));
1686
- return {
1687
- entries,
1688
- instanceHead,
1689
- skipped,
1690
- recognised,
1691
- declined,
1692
- staleDeclines,
1693
- divergedBodies
1694
- };
1695
- }
1696
- var TEST_PATH_RE = /(^|\/)tests?\/.*\btest_[^/]*\.py$/;
1697
- function findMigrationTestPairings(changes, divergedBodies) {
1698
- if (divergedBodies.length === 0) return [];
1699
- const pairings = [];
1700
- for (const change of changes) {
1701
- if (!TEST_PATH_RE.test(change.path)) continue;
1702
- const content = change.content;
1703
- if (content === void 0) continue;
1704
- for (const d of divergedBodies) {
1705
- if (content.includes(d.file)) {
1706
- pairings.push({ testPath: change.path, migration: d.file, instanceFile: d.instanceFile });
1707
- }
1708
- }
1709
- }
1710
- return pairings;
1711
- }
1712
- function indexInstanceMigrations(instance) {
1713
- const index = {
1714
- byCarriedFrom: /* @__PURE__ */ new Map(),
1715
- byFile: /* @__PURE__ */ new Map(),
1716
- byBody: /* @__PURE__ */ new Map(),
1717
- bySlug: /* @__PURE__ */ new Map()
1718
- };
1719
- for (const m of instance) {
1720
- index.byFile.set(m.file, m);
1721
- const from = parseCarriedFrom(m.content);
1722
- if (from !== null) index.byCarriedFrom.set(from, m);
1723
- const body = migrationBodyHash(m.content);
1724
- index.byBody.set(body, [...index.byBody.get(body) ?? [], m]);
1725
- const slug = migrationSlug(m.file);
1726
- index.bySlug.set(slug, [...index.bySlug.get(slug) ?? [], m]);
1727
- }
1728
- return index;
1729
- }
1730
- function alreadyCarried(m, index, templateBodyCounts) {
1731
- const byProvenance = index.byCarriedFrom.get(m.file);
1732
- if (byProvenance) return { instance: byProvenance, how: "provenance" };
1733
- const byFile = index.byFile.get(m.file);
1734
- if (byFile) return { instance: byFile, how: "filename" };
1735
- const body = migrationBodyHash(m.content);
1736
- const bodyMatches = index.byBody.get(body) ?? [];
1737
- if (bodyMatches.length === 1 && templateBodyCounts.get(body) === 1) {
1738
- return { instance: bodyMatches[0], how: "body" };
1739
- }
1740
- const slugMatches = (index.bySlug.get(migrationSlug(m.file)) ?? []).filter(
1741
- // A file already claimed as a copy of some OTHER template migration is not
1742
- // an ambiguous match for this one.
1743
- (candidate) => parseCarriedFrom(candidate.content) === null
1744
- );
1745
- if (slugMatches.length > 0) {
1746
- const names = slugMatches.map((c) => c.file).join(", ");
1747
- throw new Error(
1748
- `Cannot carry ${m.file}: the instance has ${names}, which describes the same migration but whose contents differ, and which carries no provenance marker. Refusing to guess.
1749
-
1750
- If it IS this migration (carried before provenance was recorded, then renamed or edited), add this line above its 'revision' assignment and re-run:
1751
-
1752
- ${CARRIED_FROM_MARKER} ${m.file}
1753
-
1754
- If it is unrelated, rename it so the descriptions differ.
1755
-
1756
- Carrying it blindly would re-issue an already-applied migration and run its DDL against a database that already has those objects (#366).`
1757
- );
1758
- }
1759
- return null;
1760
- }
1761
- function applyMigrationCarry(instanceDir, plan) {
1762
- const written = [];
1763
- for (const e of plan.entries) {
1764
- const abs = join5(instanceDir, e.path);
1765
- if (existsSync5(abs)) {
1766
- throw new Error(`Refusing to overwrite an existing migration: ${e.path}`);
1767
- }
1768
- mkdirSync(dirname3(abs), { recursive: true });
1769
- writeFileSync2(abs, e.content);
1770
- written.push(e.path);
1771
- }
1772
- return written;
1773
- }
1774
-
1775
- // src/lib/core-upgrade.ts
1776
- import {
1777
- chmodSync,
1778
- existsSync as existsSync7,
1779
- mkdirSync as mkdirSync2,
1780
- mkdtempSync as mkdtempSync2,
1781
- readFileSync as readFileSync5,
1782
- rmSync as rmSync2,
1783
- statSync,
1784
- writeFileSync as writeFileSync3
1785
- } from "fs";
1786
- import { tmpdir as tmpdir2 } from "os";
1787
- import { dirname as dirname4, join as join7 } from "path";
1788
- import { execa as execa2 } from "execa";
1789
-
1790
- // src/lib/core-ownership-guard.ts
1791
- import { existsSync as existsSync6, readFileSync as readFileSync4 } from "fs";
1792
- import { join as join6 } from "path";
1793
- import { z as z3 } from "zod";
1794
- var DIVERGENCE_FILE = "biffo.divergence.json";
1795
- var DivergenceEntrySchema = z3.object({
1796
- prefix: z3.string().min(1),
1797
- reason: z3.string().min(1),
1798
- upstream: z3.string().min(1)
1799
- });
1800
- var DivergenceConfigSchema = z3.object({
1801
- note: z3.string().optional(),
1802
- warnOnly: z3.array(DivergenceEntrySchema).default([])
1803
- });
1804
- function readDivergenceConfig(repoRoot) {
1805
- const path = join6(repoRoot, DIVERGENCE_FILE);
1806
- if (!existsSync6(path)) return { warnOnly: [] };
1807
- let raw;
1808
- try {
1809
- raw = JSON.parse(readFileSync4(path, "utf8"));
1810
- } catch (err) {
1811
- throw new Error(`${DIVERGENCE_FILE} is not valid JSON: ${err.message}`);
1812
- }
1813
- const parsed = DivergenceConfigSchema.safeParse(raw);
1814
- if (!parsed.success) {
1815
- const issues = parsed.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
1816
- throw new Error(`${DIVERGENCE_FILE} is invalid:
1817
- ${issues}`);
1818
- }
1819
- return parsed.data;
1820
- }
1821
- function parseTrailer(commitMessage, key) {
1822
- const body = commitMessage.split("\n").filter((line) => !line.startsWith("#")).join("\n");
1823
- const match = new RegExp(String.raw`^Core-${key}:[ \t]*(\S.*?)[ \t]*$`, "m").exec(body);
1824
- return match?.[1] ?? null;
1825
- }
1826
- function parseDivergenceTrailer(commitMessage) {
1827
- return parseTrailer(commitMessage, "Divergence");
1828
- }
1829
- function parseConvergenceTrailer(commitMessage) {
1830
- return parseTrailer(commitMessage, "Convergence");
1831
- }
1832
- function resolveBranch(env, gitBranch) {
1833
- return (env["GITHUB_HEAD_REF"] || env["GITHUB_REF_NAME"] || gitBranch).trim();
1834
- }
1835
- function parseNameStatus(stdout) {
1836
- const changed = [];
1837
- const deleted = [];
1838
- for (const line of stdout.split("\n")) {
1839
- const parts = line.split(" ").filter(Boolean);
1840
- const status = parts[0];
1841
- const path = parts[parts.length - 1];
1842
- if (!status || !path || parts.length < 2) continue;
1843
- changed.push(path);
1844
- if (status.startsWith("D")) deleted.push(path);
1845
- }
1846
- return { changed, deleted };
1847
- }
1848
- function checkCoreOwnership({
1849
- changedFiles,
1850
- manifest,
1851
- isInstance,
1852
- branch = "",
1853
- commitMessage = "",
1854
- warnOnly = []
1855
- }) {
1856
- const empty = { blocked: [], warned: [], divergenceReason: null, convergenceReason: null };
1857
- if (!isInstance) return { skipped: "template", ...empty };
1858
- if (branch.startsWith(UPGRADE_BRANCH_PREFIX)) return { skipped: "upgrade-branch", ...empty };
1859
- const templateOwned = changedFiles.filter((f) => isTemplateOwned(f, manifest));
1860
- const acknowledged = (path) => warnOnly.filter((entry) => path.startsWith(entry.prefix)).reduce(
1861
- (best, entry) => !best || entry.prefix.length > best.prefix.length ? entry : best,
1862
- void 0
1863
- );
1864
- const warned = [];
1865
- const offending = [];
1866
- for (const path of templateOwned) {
1867
- const entry = acknowledged(path);
1868
- if (entry) warned.push({ path, entry });
1869
- else offending.push(path);
1870
- }
1871
- const divergenceReason = parseDivergenceTrailer(commitMessage);
1872
- const convergenceReason = parseConvergenceTrailer(commitMessage);
1873
- if (offending.length > 0) {
1874
- if (divergenceReason !== null) {
1875
- return {
1876
- skipped: "divergence-trailer",
1877
- blocked: [],
1878
- warned,
1879
- divergenceReason,
1880
- convergenceReason: null
1881
- };
1980
+ });
1981
+ }
1982
+ continue;
1882
1983
  }
1883
- if (convergenceReason !== null) {
1884
- return {
1885
- skipped: "convergence-trailer",
1886
- blocked: [],
1887
- warned,
1888
- divergenceReason: null,
1889
- convergenceReason
1890
- };
1984
+ let revision = m.revision;
1985
+ let reissuedFrom;
1986
+ if (usedRevisions.has(revision)) {
1987
+ const replacement = reissuedRevisionId(m.file);
1988
+ if (usedRevisions.has(replacement)) {
1989
+ throw new Error(
1990
+ `Cannot carry ${m.file}: its revision id "${revision}" is already used in the instance, and so is the deterministic replacement "${replacement}". Resolve manually.`
1991
+ );
1992
+ }
1993
+ reissuedFrom = revision;
1994
+ revision = replacement;
1891
1995
  }
1996
+ const entry = {
1997
+ path: `${MIGRATIONS_VERSIONS_DIR}/${m.file}`,
1998
+ file: m.file,
1999
+ revision,
2000
+ downRevision: head,
2001
+ // Stamped before re-chaining so the instance's copy records which template
2002
+ // migration it is, whatever it is later renamed or renumbered to.
2003
+ content: rechainMigration(stampCarriedFrom(m.content, m.file), revision, head)
2004
+ };
2005
+ if (reissuedFrom !== void 0) entry.reissuedFrom = reissuedFrom;
2006
+ entries.push(entry);
2007
+ usedRevisions.add(revision);
2008
+ head = revision;
1892
2009
  }
2010
+ validateChain(
2011
+ [
2012
+ ...instance,
2013
+ ...entries.map((e) => ({
2014
+ file: e.file,
2015
+ revision: e.revision,
2016
+ downRevision: e.downRevision,
2017
+ content: e.content
2018
+ }))
2019
+ ],
2020
+ `${MIGRATIONS_VERSIONS_DIR} (after carry)`
2021
+ );
2022
+ const templateFiles = new Set(template.map((m) => m.file));
2023
+ const staleDeclines = [...declinedIndex.keys()].filter((f) => !templateFiles.has(f));
1893
2024
  return {
1894
- skipped: null,
1895
- blocked: offending,
1896
- warned,
1897
- divergenceReason: null,
1898
- convergenceReason: null
2025
+ entries,
2026
+ instanceHead,
2027
+ skipped,
2028
+ recognised,
2029
+ declined,
2030
+ staleDeclines,
2031
+ divergedBodies
1899
2032
  };
1900
2033
  }
1901
-
1902
- // src/lib/core-upgrade.ts
1903
- var gitMergeFile = async (base, ours, theirs) => {
1904
- const dir = mkdtempSync2(join7(tmpdir2(), "biffo-merge-"));
1905
- try {
1906
- const b = join7(dir, "base");
1907
- const o = join7(dir, "ours");
1908
- const t = join7(dir, "theirs");
1909
- writeFileSync3(b, base);
1910
- writeFileSync3(o, ours);
1911
- writeFileSync3(t, theirs);
1912
- const result = await execa2("git", ["merge-file", "-p", o, b, t], {
1913
- reject: false,
1914
- stripFinalNewline: false
1915
- });
1916
- if (typeof result.exitCode !== "number" || result.exitCode < 0) {
1917
- throw new Error(`git merge-file failed: ${result.stderr}`);
2034
+ var TEST_PATH_RE = /(^|\/)tests?\/.*\btest_[^/]*\.py$/;
2035
+ function findMigrationTestPairings(changes, divergedBodies) {
2036
+ if (divergedBodies.length === 0) return [];
2037
+ const pairings = [];
2038
+ for (const change of changes) {
2039
+ if (!TEST_PATH_RE.test(change.path)) continue;
2040
+ const content = change.content;
2041
+ if (content === void 0) continue;
2042
+ for (const d of divergedBodies) {
2043
+ if (content.includes(d.file)) {
2044
+ pairings.push({ testPath: change.path, migration: d.file, instanceFile: d.instanceFile });
2045
+ }
1918
2046
  }
1919
- return { conflicted: result.exitCode > 0, content: result.stdout };
1920
- } finally {
1921
- rmSync2(dir, { recursive: true, force: true });
1922
2047
  }
1923
- };
1924
- function read(root, rel) {
1925
- return readFileSync5(join7(root, rel), "utf8");
2048
+ return pairings;
1926
2049
  }
1927
- var EMPTY_SUMMARY = () => ({
1928
- unchanged: 0,
1929
- "take-theirs": 0,
1930
- "keep-ours": 0,
1931
- merged: 0,
1932
- conflict: 0,
1933
- added: 0,
1934
- "add-conflict": 0,
1935
- restored: 0,
1936
- removed: 0,
1937
- "remove-conflict": 0
1938
- });
1939
- async function planCoreUpgrade(options) {
1940
- const mergeFile = options.mergeFile ?? gitMergeFile;
1941
- const base = new Set(listTemplateOwnedFiles(options.baseDir, options.manifest));
1942
- const ours = new Set(listTemplateOwnedFiles(options.oursDir, options.manifest));
1943
- const theirs = new Set(listTemplateOwnedFiles(options.theirsDir, options.manifest));
1944
- const divergentPrefixes = readDivergenceConfig(options.oursDir).warnOnly.map((e) => e.prefix);
1945
- const isDeclaredDivergent = (path) => divergentPrefixes.some((prefix) => path.startsWith(prefix));
1946
- const paths = [.../* @__PURE__ */ new Set([...base, ...ours, ...theirs])].sort();
1947
- const entries = [];
1948
- const divergenceSkips = [];
1949
- for (const path of paths) {
1950
- entries.push(
1951
- await classify(
1952
- path,
1953
- base,
1954
- ours,
1955
- theirs,
1956
- options,
1957
- mergeFile,
1958
- isDeclaredDivergent,
1959
- (p) => divergenceSkips.push(p)
1960
- )
1961
- );
2050
+ function indexInstanceMigrations(instance) {
2051
+ const index = {
2052
+ byCarriedFrom: /* @__PURE__ */ new Map(),
2053
+ byFile: /* @__PURE__ */ new Map(),
2054
+ byBody: /* @__PURE__ */ new Map(),
2055
+ bySlug: /* @__PURE__ */ new Map()
2056
+ };
2057
+ for (const m of instance) {
2058
+ index.byFile.set(m.file, m);
2059
+ const from = parseCarriedFrom(m.content);
2060
+ if (from !== null) index.byCarriedFrom.set(from, m);
2061
+ const body = migrationBodyHash(m.content);
2062
+ index.byBody.set(body, [...index.byBody.get(body) ?? [], m]);
2063
+ const slug = migrationSlug(m.file);
2064
+ index.bySlug.set(slug, [...index.bySlug.get(slug) ?? [], m]);
1962
2065
  }
1963
- const summary = EMPTY_SUMMARY();
1964
- for (const e of entries) summary[e.status]++;
1965
- const changes = entries.filter((e) => e.status !== "unchanged" && e.status !== "keep-ours");
1966
- const conflicts = entries.filter((e) => e.conflicted);
1967
- return { entries, changes, conflicts, summary, divergenceSkips };
2066
+ return index;
1968
2067
  }
1969
- async function classify(path, base, ours, theirs, opts, mergeFile, isDeclaredDivergent, noteDivergenceSkip) {
1970
- const inBase = base.has(path);
1971
- const inOurs = ours.has(path);
1972
- const inTheirs = theirs.has(path);
1973
- if (!inBase && !inTheirs) {
1974
- return { path, status: "keep-ours", conflicted: false };
1975
- }
1976
- if (!inBase && inTheirs) {
1977
- const theirsContent2 = read(opts.theirsDir, path);
1978
- if (!inOurs) return { path, status: "added", conflicted: false, content: theirsContent2 };
1979
- const oursContent2 = read(opts.oursDir, path);
1980
- if (oursContent2 === theirsContent2) return { path, status: "unchanged", conflicted: false };
1981
- return { path, status: "add-conflict", conflicted: true, content: theirsContent2 };
1982
- }
1983
- if (inBase && !inTheirs) {
1984
- if (!inOurs) return { path, status: "removed", conflicted: false };
1985
- const baseContent2 = read(opts.baseDir, path);
1986
- const oursContent2 = read(opts.oursDir, path);
1987
- if (oursContent2 === baseContent2) return { path, status: "removed", conflicted: false };
1988
- return { path, status: "remove-conflict", conflicted: true };
1989
- }
1990
- const baseContent = read(opts.baseDir, path);
1991
- const theirsContent = read(opts.theirsDir, path);
1992
- if (!inOurs) {
1993
- if (isDeclaredDivergent(path)) {
1994
- noteDivergenceSkip(path);
1995
- return { path, status: "removed", conflicted: false };
1996
- }
1997
- return { path, status: "restored", conflicted: false, content: theirsContent };
1998
- }
1999
- const oursContent = read(opts.oursDir, path);
2000
- const oursChanged = oursContent !== baseContent;
2001
- const theirsChanged = theirsContent !== baseContent;
2002
- if (!theirsChanged) {
2003
- return { path, status: oursChanged ? "keep-ours" : "unchanged", conflicted: false };
2004
- }
2005
- if (!oursChanged) {
2006
- return { path, status: "take-theirs", conflicted: false, content: theirsContent };
2068
+ function alreadyCarried(m, index, templateBodyCounts) {
2069
+ const byProvenance = index.byCarriedFrom.get(m.file);
2070
+ if (byProvenance) return { instance: byProvenance, how: "provenance" };
2071
+ const byFile = index.byFile.get(m.file);
2072
+ if (byFile) return { instance: byFile, how: "filename" };
2073
+ const body = migrationBodyHash(m.content);
2074
+ const bodyMatches = index.byBody.get(body) ?? [];
2075
+ if (bodyMatches.length === 1 && templateBodyCounts.get(body) === 1) {
2076
+ return { instance: bodyMatches[0], how: "body" };
2007
2077
  }
2008
- if (oursContent === theirsContent) {
2009
- return { path, status: "unchanged", conflicted: false };
2078
+ const slugMatches = (index.bySlug.get(migrationSlug(m.file)) ?? []).filter(
2079
+ // A file already claimed as a copy of some OTHER template migration is not
2080
+ // an ambiguous match for this one.
2081
+ (candidate) => parseCarriedFrom(candidate.content) === null
2082
+ );
2083
+ if (slugMatches.length > 0) {
2084
+ const names = slugMatches.map((c) => c.file).join(", ");
2085
+ throw new Error(
2086
+ `Cannot carry ${m.file}: the instance has ${names}, which describes the same migration but whose contents differ, and which carries no provenance marker. Refusing to guess.
2087
+
2088
+ If it IS this migration (carried before provenance was recorded, then renamed or edited), add this line above its 'revision' assignment and re-run:
2089
+
2090
+ ${CARRIED_FROM_MARKER} ${m.file}
2091
+
2092
+ If it is unrelated, rename it so the descriptions differ.
2093
+
2094
+ Carrying it blindly would re-issue an already-applied migration and run its DDL against a database that already has those objects (#366).`
2095
+ );
2010
2096
  }
2011
- const { conflicted, content } = await mergeFile(baseContent, oursContent, theirsContent);
2012
- return { path, status: conflicted ? "conflict" : "merged", conflicted, content };
2097
+ return null;
2013
2098
  }
2014
- function applyUpgradePlan(instanceDir, plan, theirsDir) {
2099
+ function applyMigrationCarry(instanceDir, plan) {
2015
2100
  const written = [];
2016
- const deleted = [];
2017
2101
  for (const e of plan.entries) {
2018
2102
  const abs = join7(instanceDir, e.path);
2019
- if (e.status === "removed") {
2020
- if (existsSync7(abs)) {
2021
- rmSync2(abs);
2022
- deleted.push(e.path);
2023
- }
2024
- continue;
2025
- }
2026
- if (e.content !== void 0) {
2027
- mkdirSync2(dirname4(abs), { recursive: true });
2028
- writeFileSync3(abs, e.content);
2029
- if (theirsDir !== void 0) {
2030
- const source = join7(theirsDir, e.path);
2031
- if (existsSync7(source) && (statSync(source).mode & 73) !== 0) {
2032
- chmodSync(abs, 493);
2033
- }
2034
- }
2035
- written.push(e.path);
2103
+ if (existsSync7(abs)) {
2104
+ throw new Error(`Refusing to overwrite an existing migration: ${e.path}`);
2036
2105
  }
2106
+ mkdirSync2(dirname4(abs), { recursive: true });
2107
+ writeFileSync3(abs, e.content);
2108
+ written.push(e.path);
2037
2109
  }
2038
- return { written, deleted };
2039
- }
2040
- function parseGitHubRepo(remoteUrl) {
2041
- const ssh = /^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(remoteUrl);
2042
- if (ssh && ssh[1] && ssh[2]) return { owner: ssh[1], repo: ssh[2] };
2043
- const https = /^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(remoteUrl);
2044
- if (https && https[1] && https[2]) return { owner: https[1], repo: https[2] };
2045
- throw new Error(`Could not parse a GitHub owner/repo from remote URL: ${remoteUrl}`);
2046
- }
2047
- var UPGRADE_BRANCH_PREFIX = "biffo/core-upgrade-";
2048
- function upgradeBranchName(from, to) {
2049
- return `${UPGRADE_BRANCH_PREFIX}${from}-to-${to}`.replace(/[^a-zA-Z0-9._/-]/g, "-");
2110
+ return written;
2050
2111
  }
2051
2112
 
2052
2113
  // src/lib/core-template-trees.ts
@@ -2446,7 +2507,10 @@ var coreUpgradeCommand = new Command3("upgrade").description("Three-way-merge te
2446
2507
  ).option(
2447
2508
  "--allow-dirty",
2448
2509
  "Compute the plan even with uncommitted changes in the instance tree (they become part of the merge)"
2449
- ).option("--base <branch>", "Base branch for the PR (defaults to the repo\u2019s default branch)").option("--remote <name>", "Git remote to push to and open the PR on (default: origin)").action(
2510
+ ).option("--base <branch>", "Base branch for the PR (defaults to the repo\u2019s default branch)").option("--remote <name>", "Git remote to push to and open the PR on (default: origin)").option(
2511
+ "--reap",
2512
+ "Delete local branches previous upgrade runs left behind, once their PR has merged (#758)"
2513
+ ).action(
2450
2514
  async (options) => {
2451
2515
  const cwd = options.cwd ? resolve3(options.cwd) : process.cwd();
2452
2516
  const runOptions = {
@@ -2462,6 +2526,7 @@ var coreUpgradeCommand = new Command3("upgrade").description("Three-way-merge te
2462
2526
  if (options.toTemplate) runOptions.theirsDir = resolve3(options.toTemplate);
2463
2527
  if (options.base) runOptions.base = options.base;
2464
2528
  if (options.remote) runOptions.remote = options.remote;
2529
+ if (options.reap) runOptions.reap = true;
2465
2530
  try {
2466
2531
  await runCoreUpgrade(runOptions);
2467
2532
  } catch (err) {
@@ -2496,10 +2561,45 @@ async function runCoreUpgrade(options, deps = defaultDeps()) {
2496
2561
  const cleanups = [];
2497
2562
  try {
2498
2563
  await runCoreUpgradeResolved(options, deps, cleanups);
2564
+ await reportUpgradeBranches(options, deps);
2499
2565
  } finally {
2500
2566
  for (const c of cleanups) c();
2501
2567
  }
2502
2568
  }
2569
+ async function reportUpgradeBranches(options, deps) {
2570
+ const git = deps.git;
2571
+ if (git.listBranchRefs === void 0 || git.fetchPrune === void 0) return;
2572
+ try {
2573
+ if (!await git.isGitRepo(options.cwd)) return;
2574
+ await git.fetchPrune(options.cwd, options.remote);
2575
+ const refs = await git.listBranchRefs(options.cwd);
2576
+ const current = await git.currentBranch(options.cwd);
2577
+ const { reapable, unverifiable } = classifyUpgradeBranches(refs, current);
2578
+ if (reapable.length === 0 && unverifiable.length === 0) return;
2579
+ if (reapable.length > 0 && options.reap === true && git.deleteBranch !== void 0) {
2580
+ let deleted = 0;
2581
+ for (const branch of reapable) {
2582
+ if (await git.deleteBranch(options.cwd, branch)) deleted++;
2583
+ }
2584
+ log.success(`Reaped ${String(deleted)} merged upgrade branch(es).`);
2585
+ } else if (reapable.length > 0) {
2586
+ console.log(
2587
+ chalk4.dim(
2588
+ `
2589
+ ${String(reapable.length)} previous upgrade branch(es) are merged and their remote copies are gone. Re-run with --reap to delete them.`
2590
+ )
2591
+ );
2592
+ }
2593
+ if (unverifiable.length > 0) {
2594
+ console.log(
2595
+ chalk4.dim(
2596
+ ` ${String(unverifiable.length)} older upgrade branch(es) have no upstream recorded (created before #761), so they cannot be proven merged and are left alone.`
2597
+ )
2598
+ );
2599
+ }
2600
+ } catch {
2601
+ }
2602
+ }
2503
2603
  async function checkInstanceCurrency(git, cwd, allowDirty) {
2504
2604
  if (!await git.isGitRepo(cwd)) return;
2505
2605
  const branch = await git.currentBranch(cwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.159.0",
3
+ "version": "0.160.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",