@biffo/cli 0.159.0 → 0.161.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.
- package/dist/index.js +522 -422
- 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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
844
|
+
await execa2("git", ["clone", "--depth", "1", cloneUrl, dir]);
|
|
543
845
|
} catch (err) {
|
|
544
|
-
|
|
846
|
+
rmSync2(dir, { recursive: true, force: true });
|
|
545
847
|
throw new Error(`Failed to clone ${repoUrl}: ${err.message}`);
|
|
546
848
|
}
|
|
547
|
-
const gitDir =
|
|
548
|
-
if (
|
|
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 =
|
|
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
|
|
867
|
+
await execa2("git", ["clone", cloneUrl, dir]);
|
|
566
868
|
} catch (err) {
|
|
567
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
895
|
+
await execa2("git", ["remote", "add", name, url], { cwd });
|
|
594
896
|
}
|
|
595
897
|
async add(cwd, paths) {
|
|
596
|
-
await
|
|
898
|
+
await execa2("git", ["add", ...paths], { cwd });
|
|
597
899
|
}
|
|
598
900
|
async commit(cwd, message) {
|
|
599
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
708
|
-
await
|
|
709
|
-
await
|
|
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
|
|
1445
|
-
import { dirname as
|
|
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 (!
|
|
1485
|
-
return readdirSync2(versionsDir).filter((f) => f.endsWith(".py") && f !== "__init__.py").sort().map((f) => parseMigration(f,
|
|
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 =
|
|
1603
|
-
const instanceVersions =
|
|
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
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
2025
|
+
entries,
|
|
2026
|
+
instanceHead,
|
|
2027
|
+
skipped,
|
|
2028
|
+
recognised,
|
|
2029
|
+
declined,
|
|
2030
|
+
staleDeclines,
|
|
2031
|
+
divergedBodies
|
|
1899
2032
|
};
|
|
1900
2033
|
}
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
const
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
const
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
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
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1970
|
-
const
|
|
1971
|
-
|
|
1972
|
-
const
|
|
1973
|
-
if (
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
if (
|
|
1977
|
-
|
|
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
|
-
|
|
2009
|
-
|
|
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
|
-
|
|
2012
|
-
return { path, status: conflicted ? "conflict" : "merged", conflicted, content };
|
|
2097
|
+
return null;
|
|
2013
2098
|
}
|
|
2014
|
-
function
|
|
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 (
|
|
2020
|
-
|
|
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
|
|
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)").
|
|
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);
|