@databricks-solutions/lakebase-scm-utils 0.2.43 → 0.2.45
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/scripts/github/index.cjs +21 -4
- package/dist/scripts/github/index.js +21 -4
- package/dist/scripts/github/pr.cli.cjs +21 -4
- package/dist/scripts/github/pr.cli.js +21 -4
- package/dist/scripts/index.cjs +1224 -1157
- package/dist/scripts/index.d.cts +1 -1
- package/dist/scripts/index.d.ts +1 -1
- package/dist/scripts/index.js +1223 -1157
- package/dist/scripts/lakebase/branch.cli.cjs +21 -4
- package/dist/scripts/lakebase/branch.cli.js +21 -4
- package/dist/scripts/lakebase/ci-app-endpoint.cli.cjs +21 -4
- package/dist/scripts/lakebase/ci-app-endpoint.cli.js +21 -4
- package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +23 -6
- package/dist/scripts/lakebase/ci-resolve-branch.cli.js +23 -6
- package/dist/scripts/lakebase/collapse-heads.cli.cjs +23 -6
- package/dist/scripts/lakebase/collapse-heads.cli.js +23 -6
- package/dist/scripts/lakebase/cut-backup.cli.cjs +21 -4
- package/dist/scripts/lakebase/cut-backup.cli.js +21 -4
- package/dist/scripts/lakebase/cut-tier.cli.cjs +21 -4
- package/dist/scripts/lakebase/cut-tier.cli.js +21 -4
- package/dist/scripts/lakebase/detect-language.cli.cjs +23 -6
- package/dist/scripts/lakebase/detect-language.cli.js +23 -6
- package/dist/scripts/lakebase/doctor.cli.cjs +24 -7
- package/dist/scripts/lakebase/doctor.cli.js +24 -7
- package/dist/scripts/lakebase/get-connection.cli.cjs +23 -6
- package/dist/scripts/lakebase/get-connection.cli.js +23 -6
- package/dist/scripts/lakebase/index.cjs +489 -391
- package/dist/scripts/lakebase/index.d.cts +33 -1
- package/dist/scripts/lakebase/index.d.ts +33 -1
- package/dist/scripts/lakebase/index.js +488 -391
- package/dist/scripts/lakebase/infra-runner.cli.cjs +23 -6
- package/dist/scripts/lakebase/infra-runner.cli.js +23 -6
- package/dist/scripts/lakebase/new-migration.cli.cjs +23 -6
- package/dist/scripts/lakebase/new-migration.cli.js +23 -6
- package/dist/scripts/lakebase/schema-diff.cli.cjs +23 -6
- package/dist/scripts/lakebase/schema-diff.cli.js +23 -6
- package/dist/scripts/lakebase/schema-migrate.cli.cjs +23 -6
- package/dist/scripts/lakebase/schema-migrate.cli.js +23 -6
- package/dist/scripts/lakebase/scm-abandon-feature.cli.cjs +21 -4
- package/dist/scripts/lakebase/scm-abandon-feature.cli.js +21 -4
- package/dist/scripts/lakebase/scm-adopt-state.cli.cjs +21 -4
- package/dist/scripts/lakebase/scm-adopt-state.cli.js +21 -4
- package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +23 -6
- package/dist/scripts/lakebase/scm-claim-feature.cli.js +23 -6
- package/dist/scripts/lakebase/scm-cleanup.cli.cjs +21 -4
- package/dist/scripts/lakebase/scm-cleanup.cli.js +21 -4
- package/dist/scripts/lakebase/scm-doctor.cli.cjs +23 -6
- package/dist/scripts/lakebase/scm-doctor.cli.js +23 -6
- package/dist/scripts/lakebase/scm-merge.cli.cjs +2305 -2037
- package/dist/scripts/lakebase/scm-merge.cli.js +360 -92
- package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +23 -6
- package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +23 -6
- package/dist/scripts/lakebase/scm-recover-orphans.cli.cjs +21 -4
- package/dist/scripts/lakebase/scm-recover-orphans.cli.js +21 -4
- package/dist/scripts/util/index.cjs +23 -6
- package/dist/scripts/util/index.js +23 -6
- package/package.json +1 -1
- package/scripts/lakebase/databricks-cli.ts +44 -4
- package/scripts/lakebase/doctor.ts +1 -1
- package/scripts/lakebase/runner-setup.ts +18 -2
- package/scripts/lakebase/scm-merge.ts +80 -1
- package/scripts/lakebase/secret-auth.ts +9 -2
- package/templates/project/common/scripts/run-tests.sh +10 -0
package/dist/scripts/index.js
CHANGED
|
@@ -8650,16 +8650,33 @@ stdout: ${stdout}` : killed || signal ? `
|
|
|
8650
8650
|
}
|
|
8651
8651
|
async function runDatabricks(args, opts = {}) {
|
|
8652
8652
|
const { argv, env, profile } = buildInvocation(args, opts);
|
|
8653
|
+
const timeout = opts.timeout ?? KIT_TIMEOUTS.cliDefault;
|
|
8653
8654
|
try {
|
|
8654
|
-
|
|
8655
|
-
env,
|
|
8656
|
-
|
|
8657
|
-
});
|
|
8655
|
+
if (opts.input !== void 0) {
|
|
8656
|
+
return await execDatabricksWithStdin(argv, opts.input, env, timeout);
|
|
8657
|
+
}
|
|
8658
|
+
const { stdout } = await execFileP("databricks", argv, { env, timeout });
|
|
8658
8659
|
return stdout.toString();
|
|
8659
8660
|
} catch (err) {
|
|
8660
8661
|
throw classifyDatabricksError(err, argv, profile);
|
|
8661
8662
|
}
|
|
8662
8663
|
}
|
|
8664
|
+
function execDatabricksWithStdin(argv, input, env, timeout) {
|
|
8665
|
+
return new Promise((resolve2, reject) => {
|
|
8666
|
+
const child = execFile("databricks", argv, { env, timeout }, (err, stdout, stderr) => {
|
|
8667
|
+
if (err) {
|
|
8668
|
+
err.stdout = String(stdout ?? "");
|
|
8669
|
+
err.stderr = String(stderr ?? "");
|
|
8670
|
+
reject(err);
|
|
8671
|
+
return;
|
|
8672
|
+
}
|
|
8673
|
+
resolve2(String(stdout ?? ""));
|
|
8674
|
+
});
|
|
8675
|
+
child.stdin?.on("error", () => {
|
|
8676
|
+
});
|
|
8677
|
+
child.stdin?.end(input);
|
|
8678
|
+
});
|
|
8679
|
+
}
|
|
8663
8680
|
function runDatabricksSync(args, opts = {}) {
|
|
8664
8681
|
const { argv, env, profile } = buildInvocation(args, opts);
|
|
8665
8682
|
try {
|
|
@@ -9702,8 +9719,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
|
|
|
9702
9719
|
var cached;
|
|
9703
9720
|
function substrateSelfVersion() {
|
|
9704
9721
|
if (cached !== void 0) return cached;
|
|
9705
|
-
if ("0.2.
|
|
9706
|
-
cached = "0.2.
|
|
9722
|
+
if ("0.2.45".length > 0) {
|
|
9723
|
+
cached = "0.2.45";
|
|
9707
9724
|
return cached;
|
|
9708
9725
|
}
|
|
9709
9726
|
cached = "unknown";
|
|
@@ -12969,8 +12986,20 @@ async function setupRunner(args) {
|
|
|
12969
12986
|
if (needsConfig) {
|
|
12970
12987
|
report("Registering runner with GitHub...");
|
|
12971
12988
|
const regToken = await createRegistrationToken(args.fullRepoName);
|
|
12972
|
-
cp4.
|
|
12973
|
-
|
|
12989
|
+
cp4.execFileSync(
|
|
12990
|
+
"./config.sh",
|
|
12991
|
+
[
|
|
12992
|
+
"--url",
|
|
12993
|
+
`https://github.com/${args.fullRepoName}`,
|
|
12994
|
+
"--token",
|
|
12995
|
+
regToken,
|
|
12996
|
+
"--name",
|
|
12997
|
+
name,
|
|
12998
|
+
"--labels",
|
|
12999
|
+
"self-hosted",
|
|
13000
|
+
"--unattended",
|
|
13001
|
+
"--replace"
|
|
13002
|
+
],
|
|
12974
13003
|
{ cwd: dir, timeout: KIT_TIMEOUTS.cliLong }
|
|
12975
13004
|
);
|
|
12976
13005
|
}
|
|
@@ -15992,1197 +16021,1289 @@ function pickRunUrl(pr) {
|
|
|
15992
16021
|
|
|
15993
16022
|
// scripts/lakebase/scm-merge.ts
|
|
15994
16023
|
init_esm_shims();
|
|
15995
|
-
|
|
15996
|
-
|
|
16024
|
+
|
|
16025
|
+
// scripts/git/sync.ts
|
|
16026
|
+
init_esm_shims();
|
|
16027
|
+
async function currentBranchName(cwd) {
|
|
15997
16028
|
try {
|
|
15998
|
-
|
|
15999
|
-
|
|
16000
|
-
|
|
16001
|
-
} else {
|
|
16002
|
-
await exec2(`git fetch origin ${shellEscape2(`${tier}:${tier}`)}`, { cwd, timeout: 3e4 });
|
|
16003
|
-
}
|
|
16004
|
-
return null;
|
|
16005
|
-
} catch (err) {
|
|
16006
|
-
return `local reconcile of ${tier} to origin/${tier} failed: ${err instanceof Error ? err.message : String(err)}. Reconcile it by hand (git checkout ${tier} && git pull --ff-only).`;
|
|
16029
|
+
return await exec2("git rev-parse --abbrev-ref HEAD", { cwd });
|
|
16030
|
+
} catch {
|
|
16031
|
+
return "";
|
|
16007
16032
|
}
|
|
16008
16033
|
}
|
|
16009
|
-
|
|
16010
|
-
|
|
16011
|
-
super(message);
|
|
16012
|
-
this.code = code;
|
|
16013
|
-
this.name = "ScmMergeError";
|
|
16014
|
-
}
|
|
16015
|
-
code;
|
|
16016
|
-
};
|
|
16017
|
-
var DEFAULT_MIGRATE_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
16018
|
-
var DEFAULT_MIGRATE_POLL_MS = 30 * 1e3;
|
|
16019
|
-
function defaultMigratePredicate(run, mergedAt) {
|
|
16020
|
-
if (!run.createdAt) return false;
|
|
16021
|
-
const created = Date.parse(run.createdAt);
|
|
16022
|
-
if (!Number.isFinite(created)) return false;
|
|
16023
|
-
if (run.event && run.event !== "push") return false;
|
|
16024
|
-
return created >= mergedAt.getTime() - 5e3;
|
|
16034
|
+
async function push(args) {
|
|
16035
|
+
await exec2("git push", { cwd: args.cwd });
|
|
16025
16036
|
}
|
|
16026
|
-
function
|
|
16027
|
-
|
|
16028
|
-
if (run.event && run.event !== "push") return false;
|
|
16029
|
-
return !!run.headSha && run.headSha === mergeCommitSha;
|
|
16030
|
-
};
|
|
16037
|
+
async function pull(args) {
|
|
16038
|
+
await exec2("git pull", { cwd: args.cwd });
|
|
16031
16039
|
}
|
|
16032
|
-
async function
|
|
16033
|
-
const
|
|
16034
|
-
|
|
16035
|
-
|
|
16036
|
-
|
|
16037
|
-
|
|
16038
|
-
|
|
16039
|
-
|
|
16040
|
-
|
|
16041
|
-
|
|
16042
|
-
|
|
16043
|
-
|
|
16044
|
-
|
|
16040
|
+
async function publishBranch(args) {
|
|
16041
|
+
const remote = args.remote ?? "origin";
|
|
16042
|
+
const branch = await currentBranchName(args.cwd);
|
|
16043
|
+
if (!branch) throw new Error("No current branch");
|
|
16044
|
+
await exec2(`git push -u ${remote} ${shq(branch)}`, {
|
|
16045
|
+
cwd: args.cwd
|
|
16046
|
+
});
|
|
16047
|
+
}
|
|
16048
|
+
async function pushCurrentBranchForPr(args) {
|
|
16049
|
+
const remote = args.remote ?? "origin";
|
|
16050
|
+
const branch = await currentBranchName(args.cwd);
|
|
16051
|
+
if (!branch) throw new Error("No current branch");
|
|
16052
|
+
const upstreamSet = await hasUpstream({ cwd: args.cwd });
|
|
16053
|
+
if (!upstreamSet) {
|
|
16054
|
+
await exec2(`git push -u ${remote} ${shq(branch)}`, {
|
|
16055
|
+
cwd: args.cwd
|
|
16056
|
+
});
|
|
16057
|
+
} else {
|
|
16058
|
+
await exec2("git push", { cwd: args.cwd });
|
|
16045
16059
|
}
|
|
16046
|
-
|
|
16047
|
-
|
|
16048
|
-
|
|
16049
|
-
|
|
16060
|
+
}
|
|
16061
|
+
async function fetch2(args) {
|
|
16062
|
+
const parts = ["git fetch"];
|
|
16063
|
+
if (args.prune) parts.push("--prune");
|
|
16064
|
+
if (args.all) parts.push("--all");
|
|
16065
|
+
await exec2(parts.join(" "), { cwd: args.cwd });
|
|
16066
|
+
}
|
|
16067
|
+
async function pullFrom(args) {
|
|
16068
|
+
await exec2(`git pull ${shq(args.remote)} ${shq(args.branch)}`, {
|
|
16069
|
+
cwd: args.cwd
|
|
16070
|
+
});
|
|
16071
|
+
}
|
|
16072
|
+
async function pushTo(args) {
|
|
16073
|
+
await exec2(`git push ${shq(args.remote)} ${shq(args.branch)}`, {
|
|
16074
|
+
cwd: args.cwd
|
|
16075
|
+
});
|
|
16076
|
+
}
|
|
16077
|
+
async function sync(args) {
|
|
16078
|
+
await exec2("git pull", { cwd: args.cwd });
|
|
16079
|
+
await exec2("git push", { cwd: args.cwd });
|
|
16080
|
+
}
|
|
16081
|
+
|
|
16082
|
+
// scripts/lakebase/workflow-drift.ts
|
|
16083
|
+
init_esm_shims();
|
|
16084
|
+
import * as fs29 from "fs";
|
|
16085
|
+
import * as path29 from "path";
|
|
16086
|
+
function findKitTemplatesDir(start) {
|
|
16087
|
+
let dir = start;
|
|
16088
|
+
for (let i = 0; i < 6; i++) {
|
|
16089
|
+
const candidate = path29.join(
|
|
16090
|
+
dir,
|
|
16091
|
+
"templates",
|
|
16092
|
+
"project",
|
|
16093
|
+
"common",
|
|
16094
|
+
".github",
|
|
16095
|
+
"workflows"
|
|
16050
16096
|
);
|
|
16097
|
+
if (fs29.existsSync(candidate)) return candidate;
|
|
16098
|
+
const parent = path29.dirname(dir);
|
|
16099
|
+
if (parent === dir) break;
|
|
16100
|
+
dir = parent;
|
|
16051
16101
|
}
|
|
16052
|
-
|
|
16053
|
-
|
|
16054
|
-
|
|
16055
|
-
|
|
16056
|
-
|
|
16102
|
+
throw new Error(
|
|
16103
|
+
`Could not locate templates/project/common/.github/workflows/ relative to ${start}. Pass explicit kitDir.`
|
|
16104
|
+
);
|
|
16105
|
+
}
|
|
16106
|
+
function unifiedDiff(name, projectContent, templateContent) {
|
|
16107
|
+
if (projectContent === templateContent) return "";
|
|
16108
|
+
const a = projectContent.split("\n");
|
|
16109
|
+
const b = templateContent.split("\n");
|
|
16110
|
+
const out = [`--- project/${name}`, `+++ template/${name}`];
|
|
16111
|
+
const max = Math.max(a.length, b.length);
|
|
16112
|
+
for (let i = 0; i < max; i++) {
|
|
16113
|
+
const av = a[i];
|
|
16114
|
+
const bv = b[i];
|
|
16115
|
+
if (av === bv) continue;
|
|
16116
|
+
if (av !== void 0) out.push(`-${i + 1}: ${av}`);
|
|
16117
|
+
if (bv !== void 0) out.push(`+${i + 1}: ${bv}`);
|
|
16057
16118
|
}
|
|
16058
|
-
|
|
16059
|
-
|
|
16060
|
-
|
|
16061
|
-
|
|
16062
|
-
|
|
16063
|
-
|
|
16119
|
+
return out.join("\n");
|
|
16120
|
+
}
|
|
16121
|
+
function detectWorkflowDrift(args) {
|
|
16122
|
+
const projectWorkflowsDir = path29.join(
|
|
16123
|
+
args.projectDir,
|
|
16124
|
+
".github",
|
|
16125
|
+
"workflows"
|
|
16126
|
+
);
|
|
16127
|
+
const here = path29.dirname(new URL(import.meta.url).pathname);
|
|
16128
|
+
const kitWorkflowsDir = args.kitDir ? path29.join(
|
|
16129
|
+
args.kitDir,
|
|
16130
|
+
"templates",
|
|
16131
|
+
"project",
|
|
16132
|
+
"common",
|
|
16133
|
+
".github",
|
|
16134
|
+
"workflows"
|
|
16135
|
+
) : findKitTemplatesDir(here);
|
|
16136
|
+
const templateFiles = fs29.existsSync(kitWorkflowsDir) ? fs29.readdirSync(kitWorkflowsDir).filter((f) => f.endsWith(".yml")) : [];
|
|
16137
|
+
const projectFiles = fs29.existsSync(projectWorkflowsDir) ? fs29.readdirSync(projectWorkflowsDir).filter((f) => f.endsWith(".yml")) : [];
|
|
16138
|
+
const version = readKitVersion(kitWorkflowsDir);
|
|
16139
|
+
const seen = /* @__PURE__ */ new Set();
|
|
16140
|
+
const files = [];
|
|
16141
|
+
for (const name of templateFiles) {
|
|
16142
|
+
seen.add(name);
|
|
16143
|
+
const projectPath2 = path29.join(projectWorkflowsDir, name);
|
|
16144
|
+
const templatePath = path29.join(kitWorkflowsDir, name);
|
|
16145
|
+
if (!fs29.existsSync(projectPath2)) {
|
|
16146
|
+
files.push({ name, status: "missing" });
|
|
16147
|
+
continue;
|
|
16148
|
+
}
|
|
16149
|
+
const projectContent = fs29.readFileSync(projectPath2, "utf8");
|
|
16150
|
+
const templateContent = applyPlaceholders(fs29.readFileSync(templatePath, "utf8"), version);
|
|
16151
|
+
if (projectContent === templateContent) {
|
|
16152
|
+
files.push({ name, status: "unchanged" });
|
|
16153
|
+
} else {
|
|
16154
|
+
files.push({
|
|
16155
|
+
name,
|
|
16156
|
+
status: "drifted",
|
|
16157
|
+
diff: unifiedDiff(name, projectContent, templateContent)
|
|
16158
|
+
});
|
|
16159
|
+
}
|
|
16064
16160
|
}
|
|
16065
|
-
const
|
|
16066
|
-
|
|
16067
|
-
|
|
16068
|
-
`Could not extract PR number from URL: ${current.pr_url}`,
|
|
16069
|
-
"bad-pr-url"
|
|
16070
|
-
);
|
|
16161
|
+
for (const name of projectFiles) {
|
|
16162
|
+
if (seen.has(name)) continue;
|
|
16163
|
+
files.push({ name, status: "extra" });
|
|
16071
16164
|
}
|
|
16072
|
-
const
|
|
16073
|
-
|
|
16074
|
-
|
|
16075
|
-
|
|
16076
|
-
|
|
16077
|
-
|
|
16078
|
-
|
|
16079
|
-
|
|
16080
|
-
|
|
16081
|
-
|
|
16082
|
-
|
|
16083
|
-
|
|
16084
|
-
|
|
16165
|
+
const order = {
|
|
16166
|
+
drifted: 0,
|
|
16167
|
+
missing: 1,
|
|
16168
|
+
extra: 2,
|
|
16169
|
+
unchanged: 3
|
|
16170
|
+
};
|
|
16171
|
+
files.sort((a, b) => order[a.status] - order[b.status] || a.name.localeCompare(b.name));
|
|
16172
|
+
const hasDrift = files.some((f) => f.status === "drifted" || f.status === "missing");
|
|
16173
|
+
return {
|
|
16174
|
+
overall: hasDrift ? "drift" : "ok",
|
|
16175
|
+
files
|
|
16176
|
+
};
|
|
16177
|
+
}
|
|
16178
|
+
function readKitVersion(kitWorkflowsDir) {
|
|
16179
|
+
let dir = kitWorkflowsDir;
|
|
16180
|
+
for (let i = 0; i < 5; i++) {
|
|
16181
|
+
dir = path29.dirname(dir);
|
|
16085
16182
|
}
|
|
16086
|
-
let paired;
|
|
16087
16183
|
try {
|
|
16088
|
-
|
|
16089
|
-
|
|
16090
|
-
|
|
16091
|
-
|
|
16092
|
-
|
|
16093
|
-
});
|
|
16094
|
-
} catch (err) {
|
|
16095
|
-
throw new ScmMergeError(
|
|
16096
|
-
`mergePairedPullRequest failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
16097
|
-
"merge-failed"
|
|
16098
|
-
);
|
|
16184
|
+
const raw = fs29.readFileSync(path29.join(dir, "package.json"), "utf-8");
|
|
16185
|
+
const pkg = JSON.parse(raw);
|
|
16186
|
+
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
16187
|
+
} catch {
|
|
16188
|
+
return "unknown";
|
|
16099
16189
|
}
|
|
16100
|
-
const warnings = [...paired.warnings];
|
|
16101
|
-
let localBranchDeleted = false;
|
|
16102
|
-
let headAfter = current.branch;
|
|
16103
|
-
if (!args.skipLocalCleanup) {
|
|
16104
|
-
const switchTo = args.switchTo ?? gitBase;
|
|
16105
|
-
const head = await getCurrentBranch({ cwd: args.projectDir });
|
|
16106
|
-
if (head === current.branch) {
|
|
16107
|
-
try {
|
|
16108
|
-
await exec2(`git checkout ${shellEscape2(switchTo)}`, {
|
|
16109
|
-
cwd: args.projectDir,
|
|
16110
|
-
timeout: 1e4
|
|
16111
|
-
});
|
|
16112
|
-
headAfter = switchTo;
|
|
16113
|
-
try {
|
|
16114
|
-
await exec2(`git fetch origin ${shellEscape2(switchTo)}`, {
|
|
16115
|
-
cwd: args.projectDir,
|
|
16116
|
-
timeout: 3e4
|
|
16117
|
-
});
|
|
16118
|
-
await exec2(`git merge --ff-only ${shellEscape2(`origin/${switchTo}`)}`, {
|
|
16119
|
-
cwd: args.projectDir,
|
|
16120
|
-
timeout: 1e4
|
|
16121
|
-
});
|
|
16122
|
-
} catch (err) {
|
|
16123
|
-
warnings.push(
|
|
16124
|
-
`local fast-forward of ${switchTo} to origin/${switchTo} failed: ${err instanceof Error ? err.message : String(err)}. The PR merged remotely; your local ${switchTo} may be stale, run \`git pull --ff-only\`.`
|
|
16125
|
-
);
|
|
16126
|
-
}
|
|
16127
|
-
} catch (err) {
|
|
16128
|
-
warnings.push(
|
|
16129
|
-
`git checkout ${switchTo} failed: ${err instanceof Error ? err.message : String(err)}. Local branch was NOT deleted.`
|
|
16130
|
-
);
|
|
16131
|
-
}
|
|
16132
|
-
} else {
|
|
16133
|
-
headAfter = head || current.branch;
|
|
16134
|
-
const reconcileWarning = await reconcileTierToOrigin({ cwd: args.projectDir, tier: switchTo });
|
|
16135
|
-
if (reconcileWarning) warnings.push(reconcileWarning);
|
|
16136
|
-
}
|
|
16137
|
-
if (headAfter !== current.branch) {
|
|
16138
|
-
try {
|
|
16139
|
-
await exec2(
|
|
16140
|
-
`git branch -D ${shellEscape2(current.branch)}`,
|
|
16141
|
-
{ cwd: args.projectDir, timeout: 1e4 }
|
|
16142
|
-
);
|
|
16143
|
-
localBranchDeleted = true;
|
|
16144
|
-
} catch (err) {
|
|
16145
|
-
warnings.push(
|
|
16146
|
-
`git branch -D ${current.branch} failed: ${err instanceof Error ? err.message : String(err)}.`
|
|
16147
|
-
);
|
|
16148
|
-
}
|
|
16149
|
-
}
|
|
16150
|
-
}
|
|
16151
|
-
const nowFn = args.now ?? (() => /* @__PURE__ */ new Date());
|
|
16152
|
-
const mergedAt = nowFn();
|
|
16153
|
-
let next = {
|
|
16154
|
-
...current,
|
|
16155
|
-
state: "merged",
|
|
16156
|
-
merged_at: mergedAt.toISOString()
|
|
16157
|
-
};
|
|
16158
|
-
writeWorkflowState(args.projectDir, next);
|
|
16159
|
-
let migrate;
|
|
16160
|
-
const waitMigrate = args.waitMigrate !== false;
|
|
16161
|
-
if (waitMigrate) {
|
|
16162
|
-
const timeoutMs = args.migrateTimeoutMs ?? DEFAULT_MIGRATE_TIMEOUT_MS;
|
|
16163
|
-
const pollMs = args.migratePollMs ?? DEFAULT_MIGRATE_POLL_MS;
|
|
16164
|
-
const fetchRuns = args.fetchRuns ?? listWorkflowRuns;
|
|
16165
|
-
const predicate = args.migrateRunPredicate ?? (paired.mergeCommitSha ? shaMigratePredicate(paired.mergeCommitSha) : defaultMigratePredicate);
|
|
16166
|
-
const elapsedSinceMerge = nowFn().getTime() - mergedAt.getTime();
|
|
16167
|
-
const remainingTimeoutMs = Math.max(0, timeoutMs - elapsedSinceMerge);
|
|
16168
|
-
let polls = 0;
|
|
16169
|
-
let matched;
|
|
16170
|
-
let lastSeen;
|
|
16171
|
-
try {
|
|
16172
|
-
const result = await pollUntil({
|
|
16173
|
-
timeoutMs: remainingTimeoutMs,
|
|
16174
|
-
intervalMs: pollMs,
|
|
16175
|
-
now: nowFn,
|
|
16176
|
-
sleep: args.sleep,
|
|
16177
|
-
probe: async () => {
|
|
16178
|
-
const runs = await fetchRuns(ownerRepo, 20);
|
|
16179
|
-
const candidates = runs.filter((r) => r.branch === gitBase).filter((r) => predicate(r, mergedAt));
|
|
16180
|
-
if (candidates.length === 0) {
|
|
16181
|
-
return { done: false };
|
|
16182
|
-
}
|
|
16183
|
-
candidates.sort(
|
|
16184
|
-
(a, b) => Date.parse(b.createdAt ?? "0") - Date.parse(a.createdAt ?? "0")
|
|
16185
|
-
);
|
|
16186
|
-
lastSeen = candidates[0];
|
|
16187
|
-
const status = (lastSeen.status ?? "").toLowerCase();
|
|
16188
|
-
return status === "completed" ? { done: true, value: lastSeen } : { done: false };
|
|
16189
|
-
}
|
|
16190
|
-
});
|
|
16191
|
-
polls = result.polls;
|
|
16192
|
-
if (result.outcome === "done") {
|
|
16193
|
-
matched = result.value;
|
|
16194
|
-
}
|
|
16195
|
-
} catch (err) {
|
|
16196
|
-
warnings.push(
|
|
16197
|
-
`Downstream migrate poll errored: ${err instanceof Error ? err.message : String(err)}. Treating as advisory.`
|
|
16198
|
-
);
|
|
16199
|
-
}
|
|
16200
|
-
const applyLocalFallback = async (reason) => {
|
|
16201
|
-
if (!args.localMigrateFallback) return false;
|
|
16202
|
-
const r = await args.localMigrateFallback();
|
|
16203
|
-
if (!r.ok) {
|
|
16204
|
-
warnings.push(
|
|
16205
|
-
`Local-migrate fallback FAILED after ${reason}: ${r.detail ?? "unknown error"}. git promoted but the parent schema is NOT applied (partial promotion); apply it manually.`
|
|
16206
|
-
);
|
|
16207
|
-
return false;
|
|
16208
|
-
}
|
|
16209
|
-
next = { ...next, migrate_completed_at: nowFn().toISOString() };
|
|
16210
|
-
writeWorkflowState(args.projectDir, next);
|
|
16211
|
-
migrate = { ...migrate ?? { waited: true, polls }, appliedLocally: true, authVerified };
|
|
16212
|
-
warnings.push(
|
|
16213
|
-
`Downstream migrate did not confirm (${reason}); applied the parent migrations LOCALLY instead. git and Lakebase schema are in sync${r.detail ? ` (${r.detail})` : ""}.`
|
|
16214
|
-
);
|
|
16215
|
-
return true;
|
|
16216
|
-
};
|
|
16217
|
-
if (matched) {
|
|
16218
|
-
const runUrl = workflowRunUrl(ownerRepo, matched);
|
|
16219
|
-
const conclusion = (matched.conclusion ?? "").toLowerCase();
|
|
16220
|
-
migrate = {
|
|
16221
|
-
waited: true,
|
|
16222
|
-
runUrl,
|
|
16223
|
-
conclusion,
|
|
16224
|
-
polls,
|
|
16225
|
-
authVerified
|
|
16226
|
-
};
|
|
16227
|
-
if (conclusion === "success") {
|
|
16228
|
-
next = {
|
|
16229
|
-
...next,
|
|
16230
|
-
migrate_run_url: runUrl,
|
|
16231
|
-
migrate_completed_at: nowFn().toISOString()
|
|
16232
|
-
};
|
|
16233
|
-
writeWorkflowState(args.projectDir, next);
|
|
16234
|
-
} else {
|
|
16235
|
-
const applied = await applyLocalFallback(`the downstream migrate finished conclusion=${conclusion} (${runUrl})`);
|
|
16236
|
-
if (!applied) {
|
|
16237
|
-
throw new ScmMergeError(
|
|
16238
|
-
`Downstream migrate workflow finished with conclusion=${conclusion}. Run ${runUrl} for details.`,
|
|
16239
|
-
"migrate-failed"
|
|
16240
|
-
);
|
|
16241
|
-
}
|
|
16242
|
-
}
|
|
16243
|
-
} else {
|
|
16244
|
-
const budgetSec = Math.round(
|
|
16245
|
-
(args.migrateTimeoutMs ?? DEFAULT_MIGRATE_TIMEOUT_MS) / 1e3
|
|
16246
|
-
);
|
|
16247
|
-
const lastStatus = lastSeen?.status ?? "(no matching run)";
|
|
16248
|
-
const timeoutFatal = args.migrateTimeoutFatal !== false;
|
|
16249
|
-
if (timeoutFatal) {
|
|
16250
|
-
migrate = { waited: true, polls, authVerified };
|
|
16251
|
-
const applied = await applyLocalFallback(
|
|
16252
|
-
`the downstream migrate did not complete within ${budgetSec}s (last seen: ${lastStatus})`
|
|
16253
|
-
);
|
|
16254
|
-
if (!applied) {
|
|
16255
|
-
throw new ScmMergeError(
|
|
16256
|
-
`Timed out after ${budgetSec}s waiting for the downstream migrate workflow on "${gitBase}". Last seen status: ${lastStatus}.`,
|
|
16257
|
-
"migrate-timeout"
|
|
16258
|
-
);
|
|
16259
|
-
}
|
|
16260
|
-
} else {
|
|
16261
|
-
migrate = { waited: true, polls, timedOut: true, authVerified };
|
|
16262
|
-
warnings.push(
|
|
16263
|
-
`Downstream migrate workflow on "${gitBase}" was not confirmed within ${budgetSec}s (last seen status: ${lastStatus}). The PR merged and your local ${gitBase} is synced; the migrate run may still be pending or running. Confirm it later via the Actions tab or re-run with --wait-migrate.`
|
|
16264
|
-
);
|
|
16265
|
-
}
|
|
16266
|
-
}
|
|
16267
|
-
} else {
|
|
16268
|
-
migrate = { waited: false, polls: 0 };
|
|
16269
|
-
}
|
|
16270
|
-
return {
|
|
16271
|
-
state: next,
|
|
16272
|
-
paired,
|
|
16273
|
-
localBranchDeleted,
|
|
16274
|
-
headAfter,
|
|
16275
|
-
migrate,
|
|
16276
|
-
warnings
|
|
16277
|
-
};
|
|
16278
|
-
}
|
|
16279
|
-
function workflowRunUrl(ownerRepo, run) {
|
|
16280
|
-
return `https://github.com/${ownerRepo}/actions/runs/${run.id}`;
|
|
16281
|
-
}
|
|
16282
|
-
function extractPullNumber(prUrl) {
|
|
16283
|
-
const m = prUrl.match(/\/pull\/(\d+)(?:[\/?#].*)?$/);
|
|
16284
|
-
if (!m) return void 0;
|
|
16285
|
-
const n = Number.parseInt(m[1], 10);
|
|
16286
|
-
return Number.isFinite(n) ? n : void 0;
|
|
16287
16190
|
}
|
|
16288
|
-
function
|
|
16289
|
-
return
|
|
16191
|
+
function applyPlaceholders(content, version) {
|
|
16192
|
+
return content.replace(/\{\{LAKEBASE_SCM_UTILS_VERSION\}\}/g, version);
|
|
16290
16193
|
}
|
|
16291
|
-
|
|
16292
|
-
|
|
16293
|
-
init_esm_shims();
|
|
16294
|
-
|
|
16295
|
-
// scripts/git/branches.ts
|
|
16296
|
-
init_esm_shims();
|
|
16297
|
-
async function currentBranchName(cwd) {
|
|
16298
|
-
try {
|
|
16299
|
-
return await exec2("git rev-parse --abbrev-ref HEAD", { cwd });
|
|
16300
|
-
} catch {
|
|
16301
|
-
return "";
|
|
16302
|
-
}
|
|
16194
|
+
function applyCommandPlaceholders(content, version) {
|
|
16195
|
+
return content.replace(/\$\{KIT_VERSION_AT_SCAFFOLD\}/g, version);
|
|
16303
16196
|
}
|
|
16304
|
-
|
|
16305
|
-
|
|
16306
|
-
let
|
|
16307
|
-
|
|
16308
|
-
|
|
16309
|
-
|
|
16310
|
-
|
|
16197
|
+
var COMMAND_HOOK_FILE_PATTERN = /\.(pre|post)-hook\.md$/;
|
|
16198
|
+
function findKitCommandsDir(start) {
|
|
16199
|
+
let dir = start;
|
|
16200
|
+
for (let i = 0; i < 6; i++) {
|
|
16201
|
+
const candidate = path29.join(
|
|
16202
|
+
dir,
|
|
16203
|
+
"templates",
|
|
16204
|
+
"project",
|
|
16205
|
+
"common",
|
|
16206
|
+
".claude",
|
|
16207
|
+
"commands"
|
|
16311
16208
|
);
|
|
16312
|
-
|
|
16313
|
-
|
|
16314
|
-
|
|
16315
|
-
|
|
16316
|
-
const current = await currentBranchName(cwd);
|
|
16317
|
-
return raw.split("\n").filter(Boolean).map((line) => {
|
|
16318
|
-
const [name, tracking, trackInfo] = line.split("|");
|
|
16319
|
-
let ahead = 0;
|
|
16320
|
-
let behind = 0;
|
|
16321
|
-
if (trackInfo) {
|
|
16322
|
-
const aheadMatch = trackInfo.match(/ahead (\d+)/);
|
|
16323
|
-
const behindMatch = trackInfo.match(/behind (\d+)/);
|
|
16324
|
-
if (aheadMatch) ahead = parseInt(aheadMatch[1], 10);
|
|
16325
|
-
if (behindMatch) behind = parseInt(behindMatch[1], 10);
|
|
16326
|
-
}
|
|
16327
|
-
return {
|
|
16328
|
-
name,
|
|
16329
|
-
isCurrent: name === current,
|
|
16330
|
-
isRemote: false,
|
|
16331
|
-
tracking: tracking || void 0,
|
|
16332
|
-
ahead,
|
|
16333
|
-
behind
|
|
16334
|
-
};
|
|
16335
|
-
});
|
|
16336
|
-
}
|
|
16337
|
-
async function listRemoteBranches(args) {
|
|
16338
|
-
const { cwd, remote = "origin" } = args;
|
|
16339
|
-
try {
|
|
16340
|
-
const localBranches = await listLocalBranches({ cwd });
|
|
16341
|
-
const localNames = new Set(localBranches.map((b) => b.name));
|
|
16342
|
-
const raw = await exec2(`git branch -r --format="%(refname:short)"`, {
|
|
16343
|
-
cwd
|
|
16344
|
-
});
|
|
16345
|
-
if (!raw) return [];
|
|
16346
|
-
const remotePrefix = `${remote}/`;
|
|
16347
|
-
return raw.split("\n").filter(Boolean).filter((name) => !name.includes("HEAD")).map((name) => {
|
|
16348
|
-
const shortName = name.startsWith(remotePrefix) ? name.slice(remotePrefix.length) : name;
|
|
16349
|
-
return { fullName: name, shortName };
|
|
16350
|
-
}).filter(({ shortName }) => !localNames.has(shortName)).map(({ fullName, shortName }) => ({
|
|
16351
|
-
name: shortName,
|
|
16352
|
-
isCurrent: false,
|
|
16353
|
-
isRemote: true,
|
|
16354
|
-
tracking: fullName
|
|
16355
|
-
}));
|
|
16356
|
-
} catch {
|
|
16357
|
-
return [];
|
|
16209
|
+
if (fs29.existsSync(candidate)) return candidate;
|
|
16210
|
+
const parent = path29.dirname(dir);
|
|
16211
|
+
if (parent === dir) break;
|
|
16212
|
+
dir = parent;
|
|
16358
16213
|
}
|
|
16214
|
+
throw new Error(
|
|
16215
|
+
`Could not locate templates/project/common/.claude/commands/ relative to ${start}. Pass explicit kitDir.`
|
|
16216
|
+
);
|
|
16359
16217
|
}
|
|
16360
|
-
|
|
16361
|
-
const
|
|
16362
|
-
|
|
16363
|
-
const out = await exec2(
|
|
16364
|
-
`git ls-remote --heads "${remote}" "${branch}"`,
|
|
16365
|
-
{ cwd }
|
|
16366
|
-
);
|
|
16367
|
-
return out.trim().length > 0;
|
|
16368
|
-
} catch {
|
|
16369
|
-
return false;
|
|
16370
|
-
}
|
|
16218
|
+
function parsePinnedVersion(content) {
|
|
16219
|
+
const m = content.match(/^\s*[*_>`\s]*pinned\s+to\s*:\s*[`*_]*([^\s`*_]+)[`*_]*\s*$/im);
|
|
16220
|
+
return m ? m[1] : void 0;
|
|
16371
16221
|
}
|
|
16372
|
-
|
|
16373
|
-
|
|
16374
|
-
|
|
16375
|
-
|
|
16376
|
-
|
|
16377
|
-
|
|
16378
|
-
|
|
16379
|
-
|
|
16380
|
-
|
|
16381
|
-
|
|
16382
|
-
|
|
16383
|
-
|
|
16384
|
-
|
|
16385
|
-
|
|
16386
|
-
|
|
16387
|
-
"missing
|
|
16388
|
-
);
|
|
16389
|
-
}
|
|
16390
|
-
const lakebaseBranches = await listBranches({ instance: args.instance });
|
|
16391
|
-
const tierTopology = inferTierTopology(lakebaseBranches);
|
|
16392
|
-
const lakebaseLeafs = new Set(
|
|
16393
|
-
lakebaseBranches.map((b) => leafName2(b))
|
|
16394
|
-
);
|
|
16395
|
-
const defaultLeaf = leafName2(
|
|
16396
|
-
lakebaseBranches.find((b) => b.isDefault === true)
|
|
16397
|
-
);
|
|
16398
|
-
const gitBranches = await listLocalBranches({ cwd: args.projectDir });
|
|
16399
|
-
const orphans = [];
|
|
16400
|
-
const skipped = [];
|
|
16401
|
-
for (const gb of gitBranches) {
|
|
16402
|
-
if (gb.isRemote) continue;
|
|
16403
|
-
const name = gb.name;
|
|
16404
|
-
if (TIER_LEAFS.has(name)) {
|
|
16405
|
-
skipped.push({ gitBranch: name, reason: "tier branch" });
|
|
16406
|
-
continue;
|
|
16407
|
-
}
|
|
16408
|
-
if (defaultLeaf && name === defaultLeaf) {
|
|
16409
|
-
skipped.push({ gitBranch: name, reason: "default branch" });
|
|
16410
|
-
continue;
|
|
16411
|
-
}
|
|
16412
|
-
const sanitized = sanitizeBranchName(name);
|
|
16413
|
-
if (lakebaseLeafs.has(sanitized)) {
|
|
16414
|
-
skipped.push({
|
|
16415
|
-
gitBranch: name,
|
|
16416
|
-
reason: `paired Lakebase branch "${sanitized}" exists`
|
|
16417
|
-
});
|
|
16222
|
+
function detectCommandDrift(args) {
|
|
16223
|
+
const projectCommandsDir = path29.join(args.projectDir, ".claude", "commands");
|
|
16224
|
+
const here = path29.dirname(new URL(import.meta.url).pathname);
|
|
16225
|
+
const kitCommandsDir = args.kitDir ? path29.join(args.kitDir, "templates", "project", "common", ".claude", "commands") : findKitCommandsDir(here);
|
|
16226
|
+
const kitVersion = readKitVersionFromCommandsDir(kitCommandsDir);
|
|
16227
|
+
const templateFiles = fs29.existsSync(kitCommandsDir) ? fs29.readdirSync(kitCommandsDir).filter((f) => f.endsWith(".md") && !COMMAND_HOOK_FILE_PATTERN.test(f)) : [];
|
|
16228
|
+
const projectFiles = fs29.existsSync(projectCommandsDir) ? fs29.readdirSync(projectCommandsDir).filter((f) => f.endsWith(".md") && !COMMAND_HOOK_FILE_PATTERN.test(f)) : [];
|
|
16229
|
+
const seen = /* @__PURE__ */ new Set();
|
|
16230
|
+
const files = [];
|
|
16231
|
+
for (const name of templateFiles) {
|
|
16232
|
+
seen.add(name);
|
|
16233
|
+
const projectPath2 = path29.join(projectCommandsDir, name);
|
|
16234
|
+
const templatePath = path29.join(kitCommandsDir, name);
|
|
16235
|
+
const templateRaw = fs29.readFileSync(templatePath, "utf8");
|
|
16236
|
+
if (!fs29.existsSync(projectPath2)) {
|
|
16237
|
+
files.push({ name, status: "missing", kit_version: kitVersion });
|
|
16418
16238
|
continue;
|
|
16419
16239
|
}
|
|
16420
|
-
|
|
16421
|
-
|
|
16422
|
-
|
|
16423
|
-
|
|
16424
|
-
|
|
16425
|
-
|
|
16426
|
-
|
|
16427
|
-
|
|
16428
|
-
|
|
16429
|
-
|
|
16430
|
-
|
|
16431
|
-
|
|
16432
|
-
|
|
16433
|
-
|
|
16434
|
-
|
|
16435
|
-
|
|
16436
|
-
|
|
16437
|
-
|
|
16438
|
-
const candidates = args.onlyBranch ? orphans.filter((o) => o.gitBranch === args.onlyBranch) : orphans;
|
|
16439
|
-
if (args.onlyBranch && candidates.length === 0) {
|
|
16440
|
-
throw new ScmRecoverError(
|
|
16441
|
-
`No orphan found for --only-branch ${args.onlyBranch}.`,
|
|
16442
|
-
"claim-conflict"
|
|
16443
|
-
);
|
|
16444
|
-
}
|
|
16445
|
-
const headOrphan = candidates.find((o) => o.isCurrent);
|
|
16446
|
-
const stateTargetOrphan = headOrphan ?? candidates[0];
|
|
16447
|
-
for (const orphan of candidates) {
|
|
16448
|
-
try {
|
|
16449
|
-
const paired = await createFeaturePairedBranch({
|
|
16450
|
-
instance: args.instance,
|
|
16451
|
-
branch: orphan.gitBranch,
|
|
16452
|
-
parentBranch,
|
|
16453
|
-
cwd: args.projectDir
|
|
16454
|
-
// The git branch already exists on disk; the substrate primitive
|
|
16455
|
-
// is idempotent on the git side (it'll checkout the existing
|
|
16456
|
-
// branch rather than fail) but if the project is not on this
|
|
16457
|
-
// branch, we want a no-op git side. Leaving the default true is
|
|
16458
|
-
// OK: if the git branch already matches, the checkout is a
|
|
16459
|
-
// no-op; if the branch isn't HEAD, the substrate switches to it
|
|
16460
|
-
// which is what the user implicitly asked for by including the
|
|
16461
|
-
// branch.
|
|
16462
|
-
});
|
|
16463
|
-
let stateUpdated = false;
|
|
16464
|
-
if (orphan === stateTargetOrphan) {
|
|
16465
|
-
const next = {
|
|
16466
|
-
...currentState ?? {
|
|
16467
|
-
$schema: "./scm-workflow-state.schema.json",
|
|
16468
|
-
version: 1,
|
|
16469
|
-
state: "scaffold-complete",
|
|
16470
|
-
tier_topology: tierTopology,
|
|
16471
|
-
project_id: args.instance
|
|
16472
|
-
},
|
|
16473
|
-
state: "feature-claimed",
|
|
16474
|
-
feature_id: orphan.gitBranch.replace(/^feature\//, ""),
|
|
16475
|
-
branch: paired.gitBranch,
|
|
16476
|
-
parent_branch: parentBranch,
|
|
16477
|
-
lakebase_branch_uid: paired.branch.uid,
|
|
16478
|
-
claimed_at: (args.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
16479
|
-
pr_url: void 0,
|
|
16480
|
-
pushed_at: void 0,
|
|
16481
|
-
ci_run_url: void 0,
|
|
16482
|
-
ci_green_at: void 0,
|
|
16483
|
-
merged_at: void 0
|
|
16484
|
-
};
|
|
16485
|
-
writeWorkflowState(args.projectDir, next);
|
|
16486
|
-
stateUpdated = true;
|
|
16487
|
-
result.stateUpdatedFor = orphan.gitBranch;
|
|
16488
|
-
}
|
|
16489
|
-
result.claimed.push({
|
|
16490
|
-
candidate: orphan,
|
|
16491
|
-
lakebaseBranchUid: paired.branch.uid,
|
|
16492
|
-
stateUpdated,
|
|
16493
|
-
warnings: paired.warnings
|
|
16240
|
+
const projectContent = fs29.readFileSync(projectPath2, "utf8");
|
|
16241
|
+
const pinned = parsePinnedVersion(projectContent);
|
|
16242
|
+
const versionForCompare = pinned ?? kitVersion;
|
|
16243
|
+
const templateContent = applyCommandPlaceholders(templateRaw, versionForCompare);
|
|
16244
|
+
if (projectContent === templateContent) {
|
|
16245
|
+
files.push({
|
|
16246
|
+
name,
|
|
16247
|
+
status: "unchanged",
|
|
16248
|
+
pinned_version: pinned,
|
|
16249
|
+
kit_version: kitVersion
|
|
16250
|
+
});
|
|
16251
|
+
} else {
|
|
16252
|
+
files.push({
|
|
16253
|
+
name,
|
|
16254
|
+
status: "drifted",
|
|
16255
|
+
pinned_version: pinned,
|
|
16256
|
+
kit_version: kitVersion,
|
|
16257
|
+
diff: unifiedDiff(name, projectContent, templateContent)
|
|
16494
16258
|
});
|
|
16495
|
-
} catch (err) {
|
|
16496
|
-
throw new ScmRecoverError(
|
|
16497
|
-
`Substrate claim failed for ${orphan.gitBranch}: ${err instanceof Error ? err.message : String(err)}`,
|
|
16498
|
-
"substrate-failure"
|
|
16499
|
-
);
|
|
16500
16259
|
}
|
|
16501
16260
|
}
|
|
16502
|
-
|
|
16503
|
-
|
|
16504
|
-
|
|
16505
|
-
if (!b) return "";
|
|
16506
|
-
return b.name.split("/").pop() ?? b.name;
|
|
16507
|
-
}
|
|
16508
|
-
function parentForTopology(t, defaultLeaf) {
|
|
16509
|
-
if (t === 3) return "dev";
|
|
16510
|
-
if (t === 2) return "staging";
|
|
16511
|
-
return defaultLeaf || "main";
|
|
16512
|
-
}
|
|
16513
|
-
|
|
16514
|
-
// scripts/lakebase/scm-doctor.ts
|
|
16515
|
-
init_esm_shims();
|
|
16516
|
-
import * as fs29 from "fs";
|
|
16517
|
-
import * as path29 from "path";
|
|
16518
|
-
var FEATURE_PREFIX = "feature/";
|
|
16519
|
-
var TIER_LEAFS2 = DEFAULT_PROTECTED_TIER_NAMES;
|
|
16520
|
-
function readEnv(projectDir) {
|
|
16521
|
-
const envPath = path29.join(projectDir, ".env");
|
|
16522
|
-
const out = /* @__PURE__ */ new Map();
|
|
16523
|
-
if (!fs29.existsSync(envPath)) return out;
|
|
16524
|
-
const lines = fs29.readFileSync(envPath, "utf8").split("\n");
|
|
16525
|
-
for (const line of lines) {
|
|
16526
|
-
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
|
|
16527
|
-
if (m) out.set(m[1], m[2].replace(/^["']|["']$/g, ""));
|
|
16261
|
+
for (const name of projectFiles) {
|
|
16262
|
+
if (seen.has(name)) continue;
|
|
16263
|
+
files.push({ name, status: "extra", kit_version: kitVersion });
|
|
16528
16264
|
}
|
|
16529
|
-
|
|
16265
|
+
const order = {
|
|
16266
|
+
drifted: 0,
|
|
16267
|
+
missing: 1,
|
|
16268
|
+
extra: 2,
|
|
16269
|
+
unchanged: 3
|
|
16270
|
+
};
|
|
16271
|
+
files.sort((a, b) => order[a.status] - order[b.status] || a.name.localeCompare(b.name));
|
|
16272
|
+
const hasDrift = files.some((f) => f.status === "drifted" || f.status === "missing");
|
|
16273
|
+
return { overall: hasDrift ? "drift" : "ok", files };
|
|
16530
16274
|
}
|
|
16531
|
-
function
|
|
16532
|
-
|
|
16275
|
+
function readKitVersionFromCommandsDir(kitCommandsDir) {
|
|
16276
|
+
let dir = kitCommandsDir;
|
|
16277
|
+
for (let i = 0; i < 5; i++) {
|
|
16278
|
+
dir = path29.dirname(dir);
|
|
16279
|
+
}
|
|
16280
|
+
try {
|
|
16281
|
+
const raw = fs29.readFileSync(path29.join(dir, "package.json"), "utf-8");
|
|
16282
|
+
const pkg = JSON.parse(raw);
|
|
16283
|
+
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
16284
|
+
} catch {
|
|
16285
|
+
return "unknown";
|
|
16286
|
+
}
|
|
16533
16287
|
}
|
|
16534
|
-
function
|
|
16535
|
-
const
|
|
16536
|
-
|
|
16288
|
+
function detectScaffoldedDrift(args) {
|
|
16289
|
+
const workflows = detectWorkflowDrift(args);
|
|
16290
|
+
const commands = detectCommandDrift(args);
|
|
16291
|
+
return {
|
|
16292
|
+
overall: workflows.overall === "drift" || commands.overall === "drift" ? "drift" : "ok",
|
|
16293
|
+
workflows,
|
|
16294
|
+
commands
|
|
16295
|
+
};
|
|
16537
16296
|
}
|
|
16538
|
-
|
|
16539
|
-
const
|
|
16540
|
-
|
|
16541
|
-
|
|
16542
|
-
|
|
16543
|
-
|
|
16544
|
-
const
|
|
16545
|
-
|
|
16546
|
-
|
|
16547
|
-
|
|
16548
|
-
|
|
16549
|
-
|
|
16550
|
-
|
|
16551
|
-
|
|
16552
|
-
|
|
16553
|
-
|
|
16554
|
-
|
|
16555
|
-
|
|
16556
|
-
|
|
16557
|
-
|
|
16558
|
-
|
|
16559
|
-
|
|
16560
|
-
});
|
|
16297
|
+
function updateWorkflows(args) {
|
|
16298
|
+
const projectWorkflowsDir = path29.join(
|
|
16299
|
+
args.projectDir,
|
|
16300
|
+
".github",
|
|
16301
|
+
"workflows"
|
|
16302
|
+
);
|
|
16303
|
+
const here = path29.dirname(new URL(import.meta.url).pathname);
|
|
16304
|
+
const kitWorkflowsDir = args.kitDir ? path29.join(
|
|
16305
|
+
args.kitDir,
|
|
16306
|
+
"templates",
|
|
16307
|
+
"project",
|
|
16308
|
+
"common",
|
|
16309
|
+
".github",
|
|
16310
|
+
"workflows"
|
|
16311
|
+
) : findKitTemplatesDir(here);
|
|
16312
|
+
const substitute = args.substitute !== false;
|
|
16313
|
+
const dryRun = args.dryRun === true;
|
|
16314
|
+
const pruneExtras = args.pruneExtras === true;
|
|
16315
|
+
const templateFiles = fs29.existsSync(kitWorkflowsDir) ? fs29.readdirSync(kitWorkflowsDir).filter((f) => f.endsWith(".yml")) : [];
|
|
16316
|
+
const projectFiles = fs29.existsSync(projectWorkflowsDir) ? fs29.readdirSync(projectWorkflowsDir).filter((f) => f.endsWith(".yml")) : [];
|
|
16317
|
+
if (!dryRun && templateFiles.length > 0 && !fs29.existsSync(projectWorkflowsDir)) {
|
|
16318
|
+
fs29.mkdirSync(projectWorkflowsDir, { recursive: true });
|
|
16561
16319
|
}
|
|
16562
|
-
|
|
16563
|
-
|
|
16564
|
-
|
|
16565
|
-
|
|
16566
|
-
|
|
16567
|
-
|
|
16568
|
-
|
|
16569
|
-
|
|
16570
|
-
|
|
16571
|
-
|
|
16572
|
-
|
|
16573
|
-
|
|
16320
|
+
const version = substitute ? readKitVersion(kitWorkflowsDir) : "";
|
|
16321
|
+
const seen = /* @__PURE__ */ new Set();
|
|
16322
|
+
const files = [];
|
|
16323
|
+
for (const name of templateFiles) {
|
|
16324
|
+
seen.add(name);
|
|
16325
|
+
const projectPath2 = path29.join(projectWorkflowsDir, name);
|
|
16326
|
+
const templatePath = path29.join(kitWorkflowsDir, name);
|
|
16327
|
+
const templateRaw = fs29.readFileSync(templatePath, "utf-8");
|
|
16328
|
+
const desired = substitute ? applyPlaceholders(templateRaw, version) : templateRaw;
|
|
16329
|
+
const existed = fs29.existsSync(projectPath2);
|
|
16330
|
+
const current = existed ? fs29.readFileSync(projectPath2, "utf-8") : "";
|
|
16331
|
+
let outcome;
|
|
16332
|
+
if (!existed) {
|
|
16333
|
+
outcome = "added";
|
|
16334
|
+
} else if (current === desired) {
|
|
16335
|
+
outcome = "unchanged";
|
|
16336
|
+
} else {
|
|
16337
|
+
outcome = "updated";
|
|
16574
16338
|
}
|
|
16575
|
-
|
|
16339
|
+
if (!dryRun && outcome !== "unchanged") {
|
|
16340
|
+
fs29.writeFileSync(projectPath2, desired);
|
|
16341
|
+
}
|
|
16342
|
+
files.push({ name, outcome });
|
|
16576
16343
|
}
|
|
16577
|
-
|
|
16578
|
-
|
|
16579
|
-
|
|
16580
|
-
|
|
16581
|
-
|
|
16582
|
-
|
|
16583
|
-
findings.push({
|
|
16584
|
-
id: "ci-workflow-substrate-pin",
|
|
16585
|
-
severity: "warn",
|
|
16586
|
-
message: `.github/workflows/${wf} hardcodes a substrate version pin (github:databricks-solutions/lakebase-scm-utils#v<ver>) instead of resolving .lakebase/scm-utils-ref at runtime, so bumping .lakebase/scm-utils-ref does NOT change the substrate CI actually runs.`,
|
|
16587
|
-
suggestion: "Re-emit the workflows from the current substrate templates so they resolve SCM_UTILS_REF from .lakebase/scm-utils-ref at CI time (updateWorkflows in scripts/lakebase/workflow-drift.ts; lakebase-doctor reports this as workflow-drift). Until then a scm-utils-ref bump will not reach CI."
|
|
16588
|
-
});
|
|
16344
|
+
if (pruneExtras) {
|
|
16345
|
+
for (const name of projectFiles) {
|
|
16346
|
+
if (seen.has(name)) continue;
|
|
16347
|
+
const projectPath2 = path29.join(projectWorkflowsDir, name);
|
|
16348
|
+
if (!dryRun) {
|
|
16349
|
+
fs29.unlinkSync(projectPath2);
|
|
16589
16350
|
}
|
|
16590
|
-
|
|
16351
|
+
files.push({ name, outcome: "removed" });
|
|
16591
16352
|
}
|
|
16592
16353
|
}
|
|
16354
|
+
const order = {
|
|
16355
|
+
added: 0,
|
|
16356
|
+
updated: 1,
|
|
16357
|
+
removed: 2,
|
|
16358
|
+
unchanged: 3
|
|
16359
|
+
};
|
|
16360
|
+
files.sort((a, b) => order[a.outcome] - order[b.outcome] || a.name.localeCompare(b.name));
|
|
16361
|
+
const changed = files.some((f) => f.outcome !== "unchanged");
|
|
16362
|
+
return { files, changed };
|
|
16363
|
+
}
|
|
16364
|
+
|
|
16365
|
+
// scripts/lakebase/scm-merge.ts
|
|
16366
|
+
async function reconcileTierToOrigin(args) {
|
|
16367
|
+
const { cwd, tier } = args;
|
|
16593
16368
|
try {
|
|
16594
|
-
|
|
16595
|
-
|
|
16596
|
-
|
|
16597
|
-
|
|
16598
|
-
|
|
16599
|
-
suggestion: "If a wrong-feature-claim refusal follows a checkout, run lakebase-scm-adopt-state (or abandon + reclaim) to re-establish the live claim. The kit-ref run pin (.lakebase/kit-ref.local) already protects the kit version from the same checkout revert."
|
|
16600
|
-
});
|
|
16369
|
+
const head = await getCurrentBranch({ cwd });
|
|
16370
|
+
if (head === tier) {
|
|
16371
|
+
await exec2(`git pull --ff-only`, { cwd, timeout: 3e4 });
|
|
16372
|
+
} else {
|
|
16373
|
+
await exec2(`git fetch origin ${shellEscape2(`${tier}:${tier}`)}`, { cwd, timeout: 3e4 });
|
|
16601
16374
|
}
|
|
16602
|
-
|
|
16375
|
+
return null;
|
|
16376
|
+
} catch (err) {
|
|
16377
|
+
return `local reconcile of ${tier} to origin/${tier} failed: ${err instanceof Error ? err.message : String(err)}. Reconcile it by hand (git checkout ${tier} && git pull --ff-only).`;
|
|
16603
16378
|
}
|
|
16604
|
-
|
|
16605
|
-
|
|
16606
|
-
|
|
16607
|
-
|
|
16608
|
-
|
|
16609
|
-
|
|
16610
|
-
|
|
16379
|
+
}
|
|
16380
|
+
var ScmMergeError = class extends Error {
|
|
16381
|
+
constructor(message, code) {
|
|
16382
|
+
super(message);
|
|
16383
|
+
this.code = code;
|
|
16384
|
+
this.name = "ScmMergeError";
|
|
16385
|
+
}
|
|
16386
|
+
code;
|
|
16387
|
+
};
|
|
16388
|
+
var DEFAULT_MIGRATE_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
16389
|
+
var DEFAULT_MIGRATE_POLL_MS = 30 * 1e3;
|
|
16390
|
+
function defaultMigratePredicate(run, mergedAt) {
|
|
16391
|
+
if (!run.createdAt) return false;
|
|
16392
|
+
const created = Date.parse(run.createdAt);
|
|
16393
|
+
if (!Number.isFinite(created)) return false;
|
|
16394
|
+
if (run.event && run.event !== "push") return false;
|
|
16395
|
+
return created >= mergedAt.getTime() - 5e3;
|
|
16396
|
+
}
|
|
16397
|
+
function shaMigratePredicate(mergeCommitSha) {
|
|
16398
|
+
return (run) => {
|
|
16399
|
+
if (run.event && run.event !== "push") return false;
|
|
16400
|
+
return !!run.headSha && run.headSha === mergeCommitSha;
|
|
16401
|
+
};
|
|
16402
|
+
}
|
|
16403
|
+
async function defaultRefreshPromoteWorkflows(projectDir) {
|
|
16404
|
+
const drift = detectWorkflowDrift({ projectDir });
|
|
16405
|
+
const touched = drift.files.filter((f) => f.status === "drifted").map((f) => f.name);
|
|
16406
|
+
if (touched.length === 0) return { refreshed: false };
|
|
16407
|
+
const result = updateWorkflows({ projectDir });
|
|
16408
|
+
if (!result.changed) return { refreshed: false };
|
|
16409
|
+
await exec2(`git add ${shellEscape2(".github/workflows")}`, { cwd: projectDir, timeout: 1e4 });
|
|
16410
|
+
const staged = await exec2("git diff --cached --name-only -- .github/workflows", {
|
|
16411
|
+
cwd: projectDir,
|
|
16412
|
+
timeout: 1e4
|
|
16413
|
+
});
|
|
16414
|
+
if (!staged.trim()) return { refreshed: false };
|
|
16415
|
+
await exec2(
|
|
16416
|
+
`git commit -m ${shellEscape2(
|
|
16417
|
+
"chore(ci): refresh scaffolded workflows to pinned substrate (promote uses apply-tier)"
|
|
16418
|
+
)} -- .github/workflows`,
|
|
16419
|
+
{ cwd: projectDir, timeout: 15e3 }
|
|
16420
|
+
);
|
|
16421
|
+
await pushCurrentBranchForPr({ cwd: projectDir });
|
|
16422
|
+
return { refreshed: true, detail: `refreshed + pushed ${touched.join(", ") || "workflow(s)"} onto the PR head` };
|
|
16423
|
+
}
|
|
16424
|
+
async function mergeFeature(args) {
|
|
16425
|
+
const current = readWorkflowState(args.projectDir);
|
|
16426
|
+
if (!current) {
|
|
16427
|
+
throw new ScmMergeError(
|
|
16428
|
+
"No SCM workflow state. wait-ci first.",
|
|
16429
|
+
"no-state-file"
|
|
16430
|
+
);
|
|
16431
|
+
}
|
|
16432
|
+
if (current.state !== "ci-green") {
|
|
16433
|
+
throw new ScmMergeError(
|
|
16434
|
+
`merge refuses state "${current.state}". Allowed predecessor: ci-green.`,
|
|
16435
|
+
"bad-precondition"
|
|
16436
|
+
);
|
|
16437
|
+
}
|
|
16438
|
+
if (!current.pr_url) {
|
|
16439
|
+
throw new ScmMergeError(
|
|
16440
|
+
"ci-green row is missing pr_url; cannot resolve the PR to merge.",
|
|
16441
|
+
"no-pr-url"
|
|
16442
|
+
);
|
|
16443
|
+
}
|
|
16444
|
+
if (!current.branch || !current.parent_branch) {
|
|
16445
|
+
throw new ScmMergeError(
|
|
16446
|
+
"ci-green row missing branch / parent_branch; refusing to merge.",
|
|
16447
|
+
"bad-precondition"
|
|
16448
|
+
);
|
|
16449
|
+
}
|
|
16450
|
+
const ownerRepo = await getOwnerRepo(args.projectDir);
|
|
16451
|
+
if (!ownerRepo) {
|
|
16452
|
+
throw new ScmMergeError(
|
|
16453
|
+
"No GitHub remote found at origin.",
|
|
16454
|
+
"no-github-remote"
|
|
16455
|
+
);
|
|
16456
|
+
}
|
|
16457
|
+
const pullNumber = extractPullNumber(current.pr_url);
|
|
16458
|
+
if (!pullNumber) {
|
|
16459
|
+
throw new ScmMergeError(
|
|
16460
|
+
`Could not extract PR number from URL: ${current.pr_url}`,
|
|
16461
|
+
"bad-pr-url"
|
|
16462
|
+
);
|
|
16463
|
+
}
|
|
16464
|
+
const instance = args.instance ?? current.project_id;
|
|
16465
|
+
const gitBase = await resolveGitBase(current.parent_branch, args.projectDir);
|
|
16466
|
+
const wantMigrate = args.waitMigrate !== false;
|
|
16467
|
+
let authVerified = false;
|
|
16468
|
+
if (wantMigrate && args.verifyMigrateAuth) {
|
|
16469
|
+
const probe = await args.verifyMigrateAuth();
|
|
16470
|
+
if (!probe.ok) {
|
|
16471
|
+
throw new ScmMergeError(
|
|
16472
|
+
`Migrate-auth precondition failed: ${probe.detail ?? "the Databricks credential is not usable"}. Refusing to merge: the downstream migrate would promote git without applying the schema. Refresh your credential (databricks auth login), then re-run.`,
|
|
16473
|
+
"migrate-auth"
|
|
16474
|
+
);
|
|
16475
|
+
}
|
|
16476
|
+
authVerified = true;
|
|
16611
16477
|
}
|
|
16612
|
-
|
|
16613
|
-
|
|
16614
|
-
|
|
16615
|
-
|
|
16616
|
-
|
|
16617
|
-
|
|
16618
|
-
|
|
16478
|
+
const preflightNotes = [];
|
|
16479
|
+
{
|
|
16480
|
+
const refresh = args.refreshWorkflows ?? (() => defaultRefreshPromoteWorkflows(args.projectDir));
|
|
16481
|
+
try {
|
|
16482
|
+
const r = await refresh();
|
|
16483
|
+
if (r.refreshed) {
|
|
16484
|
+
preflightNotes.push(`Refreshed stale CI workflows before merge${r.detail ? ` (${r.detail})` : ""}.`);
|
|
16485
|
+
}
|
|
16486
|
+
} catch (err) {
|
|
16487
|
+
preflightNotes.push(
|
|
16488
|
+
`Workflow self-heal skipped (${err instanceof Error ? err.message : String(err)}); if the promote's migrate-target fails on the tier guard, refresh .github/workflows (merge.yml must call 'lakebase-schema-migrate apply-tier').`
|
|
16489
|
+
);
|
|
16490
|
+
}
|
|
16619
16491
|
}
|
|
16620
|
-
let
|
|
16492
|
+
let paired;
|
|
16621
16493
|
try {
|
|
16622
|
-
|
|
16623
|
-
|
|
16624
|
-
|
|
16625
|
-
|
|
16626
|
-
|
|
16627
|
-
message: `Could not list Lakebase branches for instance ${instance}: ${err instanceof Error ? err.message : String(err)}`,
|
|
16628
|
-
suggestion: "databricks auth login (or check DATABRICKS_CONFIG_PROFILE)."
|
|
16629
|
-
});
|
|
16630
|
-
return finalize({
|
|
16631
|
-
projectDir,
|
|
16632
|
-
workflowStatePresent,
|
|
16633
|
-
state: state ?? void 0,
|
|
16634
|
-
findings
|
|
16635
|
-
});
|
|
16636
|
-
}
|
|
16637
|
-
const inferredTopology = inferTierTopology(lakebaseBranches);
|
|
16638
|
-
if (state && state.tier_topology !== inferredTopology) {
|
|
16639
|
-
findings.push({
|
|
16640
|
-
id: "tier-topology-mismatch",
|
|
16641
|
-
severity: "warn",
|
|
16642
|
-
message: `workflow-state records tier_topology=${state.tier_topology}, but the Lakebase tier inventory suggests ${inferredTopology}.`,
|
|
16643
|
-
suggestion: "lakebase-scm-adopt-state --force"
|
|
16494
|
+
paired = await mergePairedPullRequest({
|
|
16495
|
+
ownerRepo,
|
|
16496
|
+
pullNumber,
|
|
16497
|
+
lakebaseInstance: instance,
|
|
16498
|
+
method: args.method ?? "squash"
|
|
16644
16499
|
});
|
|
16500
|
+
} catch (err) {
|
|
16501
|
+
throw new ScmMergeError(
|
|
16502
|
+
`mergePairedPullRequest failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
16503
|
+
"merge-failed"
|
|
16504
|
+
);
|
|
16645
16505
|
}
|
|
16646
|
-
const
|
|
16647
|
-
|
|
16648
|
-
|
|
16649
|
-
|
|
16650
|
-
|
|
16651
|
-
|
|
16652
|
-
|
|
16653
|
-
suggestion: `git checkout '${state.branch}'`
|
|
16654
|
-
});
|
|
16655
|
-
}
|
|
16656
|
-
if (state.branch) {
|
|
16657
|
-
const sanitized = sanitizeBranchName(state.branch);
|
|
16658
|
-
let pair;
|
|
16506
|
+
const warnings = [...preflightNotes, ...paired.warnings];
|
|
16507
|
+
let localBranchDeleted = false;
|
|
16508
|
+
let headAfter = current.branch;
|
|
16509
|
+
if (!args.skipLocalCleanup) {
|
|
16510
|
+
const switchTo = args.switchTo ?? gitBase;
|
|
16511
|
+
const head = await getCurrentBranch({ cwd: args.projectDir });
|
|
16512
|
+
if (head === current.branch) {
|
|
16659
16513
|
try {
|
|
16660
|
-
|
|
16661
|
-
|
|
16662
|
-
|
|
16663
|
-
}
|
|
16664
|
-
if (!pair) {
|
|
16665
|
-
findings.push({
|
|
16666
|
-
id: "lakebase-pair-missing",
|
|
16667
|
-
severity: "fail",
|
|
16668
|
-
message: `workflow says feature-claimed for "${state.branch}", but no Lakebase branch "${sanitized}" exists.`,
|
|
16669
|
-
suggestion: `lakebase-scm-abandon-feature # reset state; re-claim if needed`
|
|
16670
|
-
});
|
|
16671
|
-
} else if (state.lakebase_branch_uid && pair.uid !== state.lakebase_branch_uid) {
|
|
16672
|
-
findings.push({
|
|
16673
|
-
id: "lakebase-uid-drift",
|
|
16674
|
-
severity: "warn",
|
|
16675
|
-
message: `workflow records lakebase_branch_uid=${state.lakebase_branch_uid}, but the live branch reports ${pair.uid}.`,
|
|
16676
|
-
suggestion: "lakebase-scm-adopt-state --force"
|
|
16514
|
+
await exec2(`git checkout ${shellEscape2(switchTo)}`, {
|
|
16515
|
+
cwd: args.projectDir,
|
|
16516
|
+
timeout: 1e4
|
|
16677
16517
|
});
|
|
16518
|
+
headAfter = switchTo;
|
|
16519
|
+
try {
|
|
16520
|
+
await exec2(`git fetch origin ${shellEscape2(switchTo)}`, {
|
|
16521
|
+
cwd: args.projectDir,
|
|
16522
|
+
timeout: 3e4
|
|
16523
|
+
});
|
|
16524
|
+
await exec2(`git merge --ff-only ${shellEscape2(`origin/${switchTo}`)}`, {
|
|
16525
|
+
cwd: args.projectDir,
|
|
16526
|
+
timeout: 1e4
|
|
16527
|
+
});
|
|
16528
|
+
} catch (err) {
|
|
16529
|
+
warnings.push(
|
|
16530
|
+
`local fast-forward of ${switchTo} to origin/${switchTo} failed: ${err instanceof Error ? err.message : String(err)}. The PR merged remotely; your local ${switchTo} may be stale, run \`git pull --ff-only\`.`
|
|
16531
|
+
);
|
|
16532
|
+
}
|
|
16533
|
+
} catch (err) {
|
|
16534
|
+
warnings.push(
|
|
16535
|
+
`git checkout ${switchTo} failed: ${err instanceof Error ? err.message : String(err)}. Local branch was NOT deleted.`
|
|
16536
|
+
);
|
|
16678
16537
|
}
|
|
16538
|
+
} else {
|
|
16539
|
+
headAfter = head || current.branch;
|
|
16540
|
+
const reconcileWarning = await reconcileTierToOrigin({ cwd: args.projectDir, tier: switchTo });
|
|
16541
|
+
if (reconcileWarning) warnings.push(reconcileWarning);
|
|
16679
16542
|
}
|
|
16680
|
-
|
|
16681
|
-
|
|
16682
|
-
|
|
16683
|
-
|
|
16684
|
-
|
|
16685
|
-
|
|
16686
|
-
|
|
16687
|
-
|
|
16688
|
-
|
|
16689
|
-
|
|
16690
|
-
|
|
16691
|
-
|
|
16692
|
-
}
|
|
16693
|
-
if (headBranch && !TIER_LEAFS2.has(headBranch) && headBranch.startsWith(FEATURE_PREFIX)) {
|
|
16694
|
-
const sanitized = sanitizeBranchName(headBranch);
|
|
16695
|
-
const paired = lakebaseBranches.some((b) => leafOf2(b) === sanitized);
|
|
16696
|
-
if (!paired) {
|
|
16697
|
-
findings.push({
|
|
16698
|
-
id: "orphan-current-branch",
|
|
16699
|
-
severity: "fail",
|
|
16700
|
-
message: `Current git branch "${headBranch}" has no Lakebase pair (post-checkout fallback retired in phase C).`,
|
|
16701
|
-
suggestion: `lakebase-scm-recover-orphans --claim --only-branch '${headBranch}'`
|
|
16702
|
-
});
|
|
16543
|
+
if (headAfter !== current.branch) {
|
|
16544
|
+
try {
|
|
16545
|
+
await exec2(
|
|
16546
|
+
`git branch -D ${shellEscape2(current.branch)}`,
|
|
16547
|
+
{ cwd: args.projectDir, timeout: 1e4 }
|
|
16548
|
+
);
|
|
16549
|
+
localBranchDeleted = true;
|
|
16550
|
+
} catch (err) {
|
|
16551
|
+
warnings.push(
|
|
16552
|
+
`git branch -D ${current.branch} failed: ${err instanceof Error ? err.message : String(err)}.`
|
|
16553
|
+
);
|
|
16554
|
+
}
|
|
16703
16555
|
}
|
|
16704
16556
|
}
|
|
16705
|
-
|
|
16706
|
-
|
|
16707
|
-
|
|
16708
|
-
|
|
16709
|
-
|
|
16710
|
-
|
|
16711
|
-
|
|
16712
|
-
|
|
16557
|
+
const nowFn = args.now ?? (() => /* @__PURE__ */ new Date());
|
|
16558
|
+
const mergedAt = nowFn();
|
|
16559
|
+
let next = {
|
|
16560
|
+
...current,
|
|
16561
|
+
state: "merged",
|
|
16562
|
+
merged_at: mergedAt.toISOString()
|
|
16563
|
+
};
|
|
16564
|
+
writeWorkflowState(args.projectDir, next);
|
|
16565
|
+
let migrate;
|
|
16566
|
+
const waitMigrate = args.waitMigrate !== false;
|
|
16567
|
+
if (waitMigrate) {
|
|
16568
|
+
const timeoutMs = args.migrateTimeoutMs ?? DEFAULT_MIGRATE_TIMEOUT_MS;
|
|
16569
|
+
const pollMs = args.migratePollMs ?? DEFAULT_MIGRATE_POLL_MS;
|
|
16570
|
+
const fetchRuns = args.fetchRuns ?? listWorkflowRuns;
|
|
16571
|
+
const predicate = args.migrateRunPredicate ?? (paired.mergeCommitSha ? shaMigratePredicate(paired.mergeCommitSha) : defaultMigratePredicate);
|
|
16572
|
+
const elapsedSinceMerge = nowFn().getTime() - mergedAt.getTime();
|
|
16573
|
+
const remainingTimeoutMs = Math.max(0, timeoutMs - elapsedSinceMerge);
|
|
16574
|
+
let polls = 0;
|
|
16575
|
+
let matched;
|
|
16576
|
+
let lastSeen;
|
|
16577
|
+
try {
|
|
16578
|
+
const result = await pollUntil({
|
|
16579
|
+
timeoutMs: remainingTimeoutMs,
|
|
16580
|
+
intervalMs: pollMs,
|
|
16581
|
+
now: nowFn,
|
|
16582
|
+
sleep: args.sleep,
|
|
16583
|
+
probe: async () => {
|
|
16584
|
+
const runs = await fetchRuns(ownerRepo, 20);
|
|
16585
|
+
const candidates = runs.filter((r) => r.branch === gitBase).filter((r) => predicate(r, mergedAt));
|
|
16586
|
+
if (candidates.length === 0) {
|
|
16587
|
+
return { done: false };
|
|
16588
|
+
}
|
|
16589
|
+
candidates.sort(
|
|
16590
|
+
(a, b) => Date.parse(b.createdAt ?? "0") - Date.parse(a.createdAt ?? "0")
|
|
16591
|
+
);
|
|
16592
|
+
lastSeen = candidates[0];
|
|
16593
|
+
const status = (lastSeen.status ?? "").toLowerCase();
|
|
16594
|
+
return status === "completed" ? { done: true, value: lastSeen } : { done: false };
|
|
16595
|
+
}
|
|
16713
16596
|
});
|
|
16597
|
+
polls = result.polls;
|
|
16598
|
+
if (result.outcome === "done") {
|
|
16599
|
+
matched = result.value;
|
|
16600
|
+
}
|
|
16601
|
+
} catch (err) {
|
|
16602
|
+
warnings.push(
|
|
16603
|
+
`Downstream migrate poll errored: ${err instanceof Error ? err.message : String(err)}. Treating as advisory.`
|
|
16604
|
+
);
|
|
16714
16605
|
}
|
|
16715
|
-
|
|
16716
|
-
|
|
16717
|
-
|
|
16718
|
-
|
|
16719
|
-
|
|
16720
|
-
|
|
16721
|
-
|
|
16722
|
-
|
|
16723
|
-
|
|
16724
|
-
|
|
16725
|
-
|
|
16606
|
+
const applyLocalFallback = async (reason) => {
|
|
16607
|
+
if (!args.localMigrateFallback) return false;
|
|
16608
|
+
const r = await args.localMigrateFallback();
|
|
16609
|
+
if (!r.ok) {
|
|
16610
|
+
warnings.push(
|
|
16611
|
+
`Local-migrate fallback FAILED after ${reason}: ${r.detail ?? "unknown error"}. git promoted but the parent schema is NOT applied (partial promotion); apply it manually.`
|
|
16612
|
+
);
|
|
16613
|
+
return false;
|
|
16614
|
+
}
|
|
16615
|
+
next = { ...next, migrate_completed_at: nowFn().toISOString() };
|
|
16616
|
+
writeWorkflowState(args.projectDir, next);
|
|
16617
|
+
migrate = { ...migrate ?? { waited: true, polls }, appliedLocally: true, authVerified };
|
|
16618
|
+
warnings.push(
|
|
16619
|
+
`Downstream migrate did not confirm (${reason}); applied the parent migrations LOCALLY instead. git and Lakebase schema are in sync${r.detail ? ` (${r.detail})` : ""}.`
|
|
16620
|
+
);
|
|
16621
|
+
return true;
|
|
16622
|
+
};
|
|
16623
|
+
if (matched) {
|
|
16624
|
+
const runUrl = workflowRunUrl(ownerRepo, matched);
|
|
16625
|
+
const conclusion = (matched.conclusion ?? "").toLowerCase();
|
|
16626
|
+
migrate = {
|
|
16627
|
+
waited: true,
|
|
16628
|
+
runUrl,
|
|
16629
|
+
conclusion,
|
|
16630
|
+
polls,
|
|
16631
|
+
authVerified
|
|
16632
|
+
};
|
|
16633
|
+
if (conclusion === "success") {
|
|
16634
|
+
next = {
|
|
16635
|
+
...next,
|
|
16636
|
+
migrate_run_url: runUrl,
|
|
16637
|
+
migrate_completed_at: nowFn().toISOString()
|
|
16638
|
+
};
|
|
16639
|
+
writeWorkflowState(args.projectDir, next);
|
|
16640
|
+
} else {
|
|
16641
|
+
const applied = await applyLocalFallback(`the downstream migrate finished conclusion=${conclusion} (${runUrl})`);
|
|
16642
|
+
if (!applied) {
|
|
16643
|
+
throw new ScmMergeError(
|
|
16644
|
+
`Downstream migrate workflow finished with conclusion=${conclusion}. Run ${runUrl} for details.`,
|
|
16645
|
+
"migrate-failed"
|
|
16646
|
+
);
|
|
16647
|
+
}
|
|
16726
16648
|
}
|
|
16727
|
-
|
|
16728
|
-
|
|
16729
|
-
|
|
16730
|
-
|
|
16731
|
-
|
|
16732
|
-
|
|
16733
|
-
|
|
16649
|
+
} else {
|
|
16650
|
+
const budgetSec = Math.round(
|
|
16651
|
+
(args.migrateTimeoutMs ?? DEFAULT_MIGRATE_TIMEOUT_MS) / 1e3
|
|
16652
|
+
);
|
|
16653
|
+
const lastStatus = lastSeen?.status ?? "(no matching run)";
|
|
16654
|
+
const timeoutFatal = args.migrateTimeoutFatal !== false;
|
|
16655
|
+
if (timeoutFatal) {
|
|
16656
|
+
migrate = { waited: true, polls, authVerified };
|
|
16657
|
+
const applied = await applyLocalFallback(
|
|
16658
|
+
`the downstream migrate did not complete within ${budgetSec}s (last seen: ${lastStatus})`
|
|
16659
|
+
);
|
|
16660
|
+
if (!applied) {
|
|
16661
|
+
throw new ScmMergeError(
|
|
16662
|
+
`Timed out after ${budgetSec}s waiting for the downstream migrate workflow on "${gitBase}". Last seen status: ${lastStatus}.`,
|
|
16663
|
+
"migrate-timeout"
|
|
16664
|
+
);
|
|
16665
|
+
}
|
|
16666
|
+
} else {
|
|
16667
|
+
migrate = { waited: true, polls, timedOut: true, authVerified };
|
|
16668
|
+
warnings.push(
|
|
16669
|
+
`Downstream migrate workflow on "${gitBase}" was not confirmed within ${budgetSec}s (last seen status: ${lastStatus}). The PR merged and your local ${gitBase} is synced; the migrate run may still be pending or running. Confirm it later via the Actions tab or re-run with --wait-migrate.`
|
|
16670
|
+
);
|
|
16734
16671
|
}
|
|
16735
|
-
} catch {
|
|
16736
16672
|
}
|
|
16673
|
+
} else {
|
|
16674
|
+
migrate = { waited: false, polls: 0 };
|
|
16737
16675
|
}
|
|
16738
|
-
return
|
|
16739
|
-
|
|
16740
|
-
|
|
16741
|
-
|
|
16742
|
-
|
|
16743
|
-
|
|
16744
|
-
|
|
16676
|
+
return {
|
|
16677
|
+
state: next,
|
|
16678
|
+
paired,
|
|
16679
|
+
localBranchDeleted,
|
|
16680
|
+
headAfter,
|
|
16681
|
+
migrate,
|
|
16682
|
+
warnings
|
|
16683
|
+
};
|
|
16745
16684
|
}
|
|
16746
|
-
function
|
|
16747
|
-
|
|
16748
|
-
for (const f of report.findings) {
|
|
16749
|
-
worst = worstOf(worst, f.severity);
|
|
16750
|
-
}
|
|
16751
|
-
return { ...report, worstSeverity: worst };
|
|
16685
|
+
function workflowRunUrl(ownerRepo, run) {
|
|
16686
|
+
return `https://github.com/${ownerRepo}/actions/runs/${run.id}`;
|
|
16752
16687
|
}
|
|
16753
|
-
|
|
16754
|
-
|
|
16755
|
-
|
|
16756
|
-
|
|
16757
|
-
|
|
16688
|
+
function extractPullNumber(prUrl) {
|
|
16689
|
+
const m = prUrl.match(/\/pull\/(\d+)(?:[\/?#].*)?$/);
|
|
16690
|
+
if (!m) return void 0;
|
|
16691
|
+
const n = Number.parseInt(m[1], 10);
|
|
16692
|
+
return Number.isFinite(n) ? n : void 0;
|
|
16693
|
+
}
|
|
16694
|
+
function shellEscape2(s) {
|
|
16695
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
16696
|
+
}
|
|
16697
|
+
|
|
16698
|
+
// scripts/lakebase/scm-recover-orphans.ts
|
|
16699
|
+
init_esm_shims();
|
|
16700
|
+
|
|
16701
|
+
// scripts/git/branches.ts
|
|
16702
|
+
init_esm_shims();
|
|
16703
|
+
async function currentBranchName2(cwd) {
|
|
16704
|
+
try {
|
|
16705
|
+
return await exec2("git rev-parse --abbrev-ref HEAD", { cwd });
|
|
16706
|
+
} catch {
|
|
16707
|
+
return "";
|
|
16758
16708
|
}
|
|
16759
|
-
|
|
16760
|
-
|
|
16761
|
-
|
|
16762
|
-
|
|
16763
|
-
|
|
16764
|
-
|
|
16765
|
-
|
|
16766
|
-
|
|
16767
|
-
"db-ahead-of-code"
|
|
16768
|
-
];
|
|
16769
|
-
async function fixFinding(args) {
|
|
16770
|
-
if (!FIXABLE_FINDING_IDS.includes(args.findingId)) {
|
|
16771
|
-
throw new ScmDoctorFixError(
|
|
16772
|
-
`Finding "${args.findingId}" is not supported by --fix. Supported: ${FIXABLE_FINDING_IDS.join(", ")}.`,
|
|
16773
|
-
"unsupported-finding"
|
|
16709
|
+
}
|
|
16710
|
+
async function listLocalBranches(args) {
|
|
16711
|
+
const { cwd } = args;
|
|
16712
|
+
let raw;
|
|
16713
|
+
try {
|
|
16714
|
+
raw = await exec2(
|
|
16715
|
+
'git branch --format="%(refname:short)|%(upstream:short)|%(upstream:track)"',
|
|
16716
|
+
{ cwd }
|
|
16774
16717
|
);
|
|
16718
|
+
} catch {
|
|
16719
|
+
return [];
|
|
16775
16720
|
}
|
|
16776
|
-
|
|
16777
|
-
const
|
|
16778
|
-
|
|
16779
|
-
|
|
16780
|
-
|
|
16781
|
-
|
|
16782
|
-
)
|
|
16721
|
+
if (!raw) return [];
|
|
16722
|
+
const current = await currentBranchName2(cwd);
|
|
16723
|
+
return raw.split("\n").filter(Boolean).map((line) => {
|
|
16724
|
+
const [name, tracking, trackInfo] = line.split("|");
|
|
16725
|
+
let ahead = 0;
|
|
16726
|
+
let behind = 0;
|
|
16727
|
+
if (trackInfo) {
|
|
16728
|
+
const aheadMatch = trackInfo.match(/ahead (\d+)/);
|
|
16729
|
+
const behindMatch = trackInfo.match(/behind (\d+)/);
|
|
16730
|
+
if (aheadMatch) ahead = parseInt(aheadMatch[1], 10);
|
|
16731
|
+
if (behindMatch) behind = parseInt(behindMatch[1], 10);
|
|
16732
|
+
}
|
|
16733
|
+
return {
|
|
16734
|
+
name,
|
|
16735
|
+
isCurrent: name === current,
|
|
16736
|
+
isRemote: false,
|
|
16737
|
+
tracking: tracking || void 0,
|
|
16738
|
+
ahead,
|
|
16739
|
+
behind
|
|
16740
|
+
};
|
|
16741
|
+
});
|
|
16742
|
+
}
|
|
16743
|
+
async function listRemoteBranches(args) {
|
|
16744
|
+
const { cwd, remote = "origin" } = args;
|
|
16745
|
+
try {
|
|
16746
|
+
const localBranches = await listLocalBranches({ cwd });
|
|
16747
|
+
const localNames = new Set(localBranches.map((b) => b.name));
|
|
16748
|
+
const raw = await exec2(`git branch -r --format="%(refname:short)"`, {
|
|
16749
|
+
cwd
|
|
16750
|
+
});
|
|
16751
|
+
if (!raw) return [];
|
|
16752
|
+
const remotePrefix = `${remote}/`;
|
|
16753
|
+
return raw.split("\n").filter(Boolean).filter((name) => !name.includes("HEAD")).map((name) => {
|
|
16754
|
+
const shortName = name.startsWith(remotePrefix) ? name.slice(remotePrefix.length) : name;
|
|
16755
|
+
return { fullName: name, shortName };
|
|
16756
|
+
}).filter(({ shortName }) => !localNames.has(shortName)).map(({ fullName, shortName }) => ({
|
|
16757
|
+
name: shortName,
|
|
16758
|
+
isCurrent: false,
|
|
16759
|
+
isRemote: true,
|
|
16760
|
+
tracking: fullName
|
|
16761
|
+
}));
|
|
16762
|
+
} catch {
|
|
16763
|
+
return [];
|
|
16783
16764
|
}
|
|
16784
|
-
|
|
16765
|
+
}
|
|
16766
|
+
async function hasRemoteBranch(args) {
|
|
16767
|
+
const { cwd, branch, remote = "origin" } = args;
|
|
16785
16768
|
try {
|
|
16786
|
-
|
|
16787
|
-
|
|
16788
|
-
|
|
16789
|
-
|
|
16790
|
-
|
|
16791
|
-
|
|
16792
|
-
|
|
16793
|
-
|
|
16794
|
-
|
|
16795
|
-
|
|
16796
|
-
|
|
16797
|
-
|
|
16798
|
-
|
|
16799
|
-
|
|
16800
|
-
|
|
16801
|
-
|
|
16802
|
-
|
|
16803
|
-
|
|
16804
|
-
|
|
16805
|
-
|
|
16806
|
-
|
|
16807
|
-
|
|
16808
|
-
|
|
16809
|
-
|
|
16810
|
-
|
|
16811
|
-
|
|
16812
|
-
|
|
16813
|
-
|
|
16814
|
-
|
|
16815
|
-
|
|
16816
|
-
|
|
16817
|
-
|
|
16818
|
-
|
|
16819
|
-
|
|
16820
|
-
|
|
16821
|
-
|
|
16822
|
-
|
|
16823
|
-
|
|
16824
|
-
|
|
16825
|
-
|
|
16826
|
-
|
|
16827
|
-
|
|
16828
|
-
|
|
16829
|
-
|
|
16830
|
-
await adoptScmState({
|
|
16831
|
-
projectDir: args.projectDir,
|
|
16832
|
-
instance,
|
|
16833
|
-
force: true
|
|
16834
|
-
});
|
|
16835
|
-
action = `adopted state with --force to re-infer tier_topology`;
|
|
16836
|
-
break;
|
|
16837
|
-
}
|
|
16838
|
-
case "orphan-current-branch": {
|
|
16839
|
-
const instance = args.instance ?? report.state?.project_id;
|
|
16840
|
-
if (!instance) {
|
|
16841
|
-
throw new ScmDoctorFixError(
|
|
16842
|
-
"Cannot fix: missing Lakebase project id.",
|
|
16843
|
-
"fix-failed"
|
|
16844
|
-
);
|
|
16845
|
-
}
|
|
16846
|
-
const headBranch = await getCurrentBranch({ cwd: args.projectDir });
|
|
16847
|
-
if (!headBranch) {
|
|
16848
|
-
throw new ScmDoctorFixError(
|
|
16849
|
-
"Cannot fix: detached HEAD or no current branch.",
|
|
16850
|
-
"fix-failed"
|
|
16851
|
-
);
|
|
16852
|
-
}
|
|
16853
|
-
await recoverOrphans({
|
|
16854
|
-
projectDir: args.projectDir,
|
|
16855
|
-
instance,
|
|
16856
|
-
claim: true,
|
|
16857
|
-
onlyBranch: headBranch
|
|
16858
|
-
});
|
|
16859
|
-
action = `recovered orphan ${headBranch} via createFeaturePairedBranch`;
|
|
16860
|
-
break;
|
|
16861
|
-
}
|
|
16862
|
-
case "multiple-migration-heads": {
|
|
16863
|
-
const r = await collapseMigrationHeads({ projectDir: args.projectDir });
|
|
16864
|
-
if (r.status !== "ok" || !r.mergeRevision) {
|
|
16865
|
-
throw new ScmDoctorFixError(
|
|
16866
|
-
`Expected to create a merge revision but got status="${r.status}".`,
|
|
16867
|
-
"fix-failed"
|
|
16868
|
-
);
|
|
16869
|
-
}
|
|
16870
|
-
action = `collapsed ${r.headsBefore.length} heads into merge revision ${r.mergeRevision} (commit it)`;
|
|
16871
|
-
break;
|
|
16872
|
-
}
|
|
16873
|
-
case "db-ahead-of-code": {
|
|
16874
|
-
const r = await abandonFeatureBranch({ projectDir: args.projectDir, instance: args.instance, force: true });
|
|
16875
|
-
action = `abandoned the feature (deleted the polluted paired branch${r.lakebaseDeleted ? "" : " , Lakebase delete reported not-deleted"}) and reset state to '${r.state.state}'; re-claim to re-fork a clean branch from the tier`;
|
|
16876
|
-
break;
|
|
16877
|
-
}
|
|
16769
|
+
const out = await exec2(
|
|
16770
|
+
`git ls-remote --heads "${remote}" "${branch}"`,
|
|
16771
|
+
{ cwd }
|
|
16772
|
+
);
|
|
16773
|
+
return out.trim().length > 0;
|
|
16774
|
+
} catch {
|
|
16775
|
+
return false;
|
|
16776
|
+
}
|
|
16777
|
+
}
|
|
16778
|
+
|
|
16779
|
+
// scripts/lakebase/scm-recover-orphans.ts
|
|
16780
|
+
var ScmRecoverError = class extends Error {
|
|
16781
|
+
constructor(message, code) {
|
|
16782
|
+
super(message);
|
|
16783
|
+
this.code = code;
|
|
16784
|
+
this.name = "ScmRecoverError";
|
|
16785
|
+
}
|
|
16786
|
+
code;
|
|
16787
|
+
};
|
|
16788
|
+
var TIER_LEAFS = /* @__PURE__ */ new Set(["staging", "dev", "main", "master"]);
|
|
16789
|
+
async function recoverOrphans(args) {
|
|
16790
|
+
if (!args.instance) {
|
|
16791
|
+
throw new ScmRecoverError(
|
|
16792
|
+
"Lakebase project id required (--instance / LAKEBASE_PROJECT_ID).",
|
|
16793
|
+
"missing-instance"
|
|
16794
|
+
);
|
|
16795
|
+
}
|
|
16796
|
+
const lakebaseBranches = await listBranches({ instance: args.instance });
|
|
16797
|
+
const tierTopology = inferTierTopology(lakebaseBranches);
|
|
16798
|
+
const lakebaseLeafs = new Set(
|
|
16799
|
+
lakebaseBranches.map((b) => leafName2(b))
|
|
16800
|
+
);
|
|
16801
|
+
const defaultLeaf = leafName2(
|
|
16802
|
+
lakebaseBranches.find((b) => b.isDefault === true)
|
|
16803
|
+
);
|
|
16804
|
+
const gitBranches = await listLocalBranches({ cwd: args.projectDir });
|
|
16805
|
+
const orphans = [];
|
|
16806
|
+
const skipped = [];
|
|
16807
|
+
for (const gb of gitBranches) {
|
|
16808
|
+
if (gb.isRemote) continue;
|
|
16809
|
+
const name = gb.name;
|
|
16810
|
+
if (TIER_LEAFS.has(name)) {
|
|
16811
|
+
skipped.push({ gitBranch: name, reason: "tier branch" });
|
|
16812
|
+
continue;
|
|
16878
16813
|
}
|
|
16879
|
-
|
|
16880
|
-
|
|
16881
|
-
|
|
16882
|
-
|
|
16883
|
-
|
|
16814
|
+
if (defaultLeaf && name === defaultLeaf) {
|
|
16815
|
+
skipped.push({ gitBranch: name, reason: "default branch" });
|
|
16816
|
+
continue;
|
|
16817
|
+
}
|
|
16818
|
+
const sanitized = sanitizeBranchName(name);
|
|
16819
|
+
if (lakebaseLeafs.has(sanitized)) {
|
|
16820
|
+
skipped.push({
|
|
16821
|
+
gitBranch: name,
|
|
16822
|
+
reason: `paired Lakebase branch "${sanitized}" exists`
|
|
16823
|
+
});
|
|
16824
|
+
continue;
|
|
16825
|
+
}
|
|
16826
|
+
orphans.push({
|
|
16827
|
+
gitBranch: name,
|
|
16828
|
+
sanitized,
|
|
16829
|
+
isCurrent: gb.isCurrent === true,
|
|
16830
|
+
reason: name.startsWith("feature/") ? "feature/<slug> branch with no Lakebase pair" : `non-tier git branch "${name}" with no Lakebase pair`
|
|
16831
|
+
});
|
|
16832
|
+
}
|
|
16833
|
+
const result = {
|
|
16834
|
+
tierTopology,
|
|
16835
|
+
orphans,
|
|
16836
|
+
skipped,
|
|
16837
|
+
claimed: []
|
|
16838
|
+
};
|
|
16839
|
+
if (!args.claim || orphans.length === 0) {
|
|
16840
|
+
return result;
|
|
16841
|
+
}
|
|
16842
|
+
const parentBranch = parentForTopology(tierTopology, defaultLeaf);
|
|
16843
|
+
const currentState = readWorkflowState(args.projectDir);
|
|
16844
|
+
const candidates = args.onlyBranch ? orphans.filter((o) => o.gitBranch === args.onlyBranch) : orphans;
|
|
16845
|
+
if (args.onlyBranch && candidates.length === 0) {
|
|
16846
|
+
throw new ScmRecoverError(
|
|
16847
|
+
`No orphan found for --only-branch ${args.onlyBranch}.`,
|
|
16848
|
+
"claim-conflict"
|
|
16884
16849
|
);
|
|
16885
16850
|
}
|
|
16886
|
-
const
|
|
16887
|
-
|
|
16888
|
-
|
|
16889
|
-
|
|
16890
|
-
|
|
16851
|
+
const headOrphan = candidates.find((o) => o.isCurrent);
|
|
16852
|
+
const stateTargetOrphan = headOrphan ?? candidates[0];
|
|
16853
|
+
for (const orphan of candidates) {
|
|
16854
|
+
try {
|
|
16855
|
+
const paired = await createFeaturePairedBranch({
|
|
16856
|
+
instance: args.instance,
|
|
16857
|
+
branch: orphan.gitBranch,
|
|
16858
|
+
parentBranch,
|
|
16859
|
+
cwd: args.projectDir
|
|
16860
|
+
// The git branch already exists on disk; the substrate primitive
|
|
16861
|
+
// is idempotent on the git side (it'll checkout the existing
|
|
16862
|
+
// branch rather than fail) but if the project is not on this
|
|
16863
|
+
// branch, we want a no-op git side. Leaving the default true is
|
|
16864
|
+
// OK: if the git branch already matches, the checkout is a
|
|
16865
|
+
// no-op; if the branch isn't HEAD, the substrate switches to it
|
|
16866
|
+
// which is what the user implicitly asked for by including the
|
|
16867
|
+
// branch.
|
|
16868
|
+
});
|
|
16869
|
+
let stateUpdated = false;
|
|
16870
|
+
if (orphan === stateTargetOrphan) {
|
|
16871
|
+
const next = {
|
|
16872
|
+
...currentState ?? {
|
|
16873
|
+
$schema: "./scm-workflow-state.schema.json",
|
|
16874
|
+
version: 1,
|
|
16875
|
+
state: "scaffold-complete",
|
|
16876
|
+
tier_topology: tierTopology,
|
|
16877
|
+
project_id: args.instance
|
|
16878
|
+
},
|
|
16879
|
+
state: "feature-claimed",
|
|
16880
|
+
feature_id: orphan.gitBranch.replace(/^feature\//, ""),
|
|
16881
|
+
branch: paired.gitBranch,
|
|
16882
|
+
parent_branch: parentBranch,
|
|
16883
|
+
lakebase_branch_uid: paired.branch.uid,
|
|
16884
|
+
claimed_at: (args.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
16885
|
+
pr_url: void 0,
|
|
16886
|
+
pushed_at: void 0,
|
|
16887
|
+
ci_run_url: void 0,
|
|
16888
|
+
ci_green_at: void 0,
|
|
16889
|
+
merged_at: void 0
|
|
16890
|
+
};
|
|
16891
|
+
writeWorkflowState(args.projectDir, next);
|
|
16892
|
+
stateUpdated = true;
|
|
16893
|
+
result.stateUpdatedFor = orphan.gitBranch;
|
|
16894
|
+
}
|
|
16895
|
+
result.claimed.push({
|
|
16896
|
+
candidate: orphan,
|
|
16897
|
+
lakebaseBranchUid: paired.branch.uid,
|
|
16898
|
+
stateUpdated,
|
|
16899
|
+
warnings: paired.warnings
|
|
16900
|
+
});
|
|
16901
|
+
} catch (err) {
|
|
16902
|
+
throw new ScmRecoverError(
|
|
16903
|
+
`Substrate claim failed for ${orphan.gitBranch}: ${err instanceof Error ? err.message : String(err)}`,
|
|
16904
|
+
"substrate-failure"
|
|
16905
|
+
);
|
|
16906
|
+
}
|
|
16907
|
+
}
|
|
16908
|
+
return result;
|
|
16891
16909
|
}
|
|
16892
|
-
function
|
|
16893
|
-
|
|
16910
|
+
function leafName2(b) {
|
|
16911
|
+
if (!b) return "";
|
|
16912
|
+
return b.name.split("/").pop() ?? b.name;
|
|
16913
|
+
}
|
|
16914
|
+
function parentForTopology(t, defaultLeaf) {
|
|
16915
|
+
if (t === 3) return "dev";
|
|
16916
|
+
if (t === 2) return "staging";
|
|
16917
|
+
return defaultLeaf || "main";
|
|
16894
16918
|
}
|
|
16895
16919
|
|
|
16896
|
-
// scripts/lakebase/doctor.ts
|
|
16897
|
-
init_esm_shims();
|
|
16898
|
-
import * as fs31 from "fs";
|
|
16899
|
-
import * as path31 from "path";
|
|
16900
|
-
import * as cp6 from "child_process";
|
|
16901
|
-
|
|
16902
|
-
// scripts/lakebase/workflow-drift.ts
|
|
16920
|
+
// scripts/lakebase/scm-doctor.ts
|
|
16903
16921
|
init_esm_shims();
|
|
16904
16922
|
import * as fs30 from "fs";
|
|
16905
16923
|
import * as path30 from "path";
|
|
16906
|
-
|
|
16907
|
-
|
|
16908
|
-
|
|
16909
|
-
|
|
16910
|
-
|
|
16911
|
-
|
|
16912
|
-
|
|
16913
|
-
|
|
16914
|
-
|
|
16915
|
-
|
|
16916
|
-
);
|
|
16917
|
-
if (fs30.existsSync(candidate)) return candidate;
|
|
16918
|
-
const parent = path30.dirname(dir);
|
|
16919
|
-
if (parent === dir) break;
|
|
16920
|
-
dir = parent;
|
|
16924
|
+
var FEATURE_PREFIX = "feature/";
|
|
16925
|
+
var TIER_LEAFS2 = DEFAULT_PROTECTED_TIER_NAMES;
|
|
16926
|
+
function readEnv(projectDir) {
|
|
16927
|
+
const envPath = path30.join(projectDir, ".env");
|
|
16928
|
+
const out = /* @__PURE__ */ new Map();
|
|
16929
|
+
if (!fs30.existsSync(envPath)) return out;
|
|
16930
|
+
const lines = fs30.readFileSync(envPath, "utf8").split("\n");
|
|
16931
|
+
for (const line of lines) {
|
|
16932
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
|
|
16933
|
+
if (m) out.set(m[1], m[2].replace(/^["']|["']$/g, ""));
|
|
16921
16934
|
}
|
|
16922
|
-
|
|
16923
|
-
`Could not locate templates/project/common/.github/workflows/ relative to ${start}. Pass explicit kitDir.`
|
|
16924
|
-
);
|
|
16935
|
+
return out;
|
|
16925
16936
|
}
|
|
16926
|
-
function
|
|
16927
|
-
|
|
16928
|
-
const a = projectContent.split("\n");
|
|
16929
|
-
const b = templateContent.split("\n");
|
|
16930
|
-
const out = [`--- project/${name}`, `+++ template/${name}`];
|
|
16931
|
-
const max = Math.max(a.length, b.length);
|
|
16932
|
-
for (let i = 0; i < max; i++) {
|
|
16933
|
-
const av = a[i];
|
|
16934
|
-
const bv = b[i];
|
|
16935
|
-
if (av === bv) continue;
|
|
16936
|
-
if (av !== void 0) out.push(`-${i + 1}: ${av}`);
|
|
16937
|
-
if (bv !== void 0) out.push(`+${i + 1}: ${bv}`);
|
|
16938
|
-
}
|
|
16939
|
-
return out.join("\n");
|
|
16937
|
+
function leafOf2(b) {
|
|
16938
|
+
return b.name.split("/").pop() ?? b.name;
|
|
16940
16939
|
}
|
|
16941
|
-
function
|
|
16942
|
-
const
|
|
16943
|
-
|
|
16944
|
-
|
|
16945
|
-
|
|
16946
|
-
|
|
16947
|
-
const
|
|
16948
|
-
const
|
|
16949
|
-
|
|
16950
|
-
|
|
16951
|
-
|
|
16952
|
-
|
|
16953
|
-
|
|
16954
|
-
|
|
16955
|
-
|
|
16956
|
-
|
|
16957
|
-
|
|
16958
|
-
|
|
16959
|
-
|
|
16960
|
-
|
|
16961
|
-
|
|
16962
|
-
|
|
16963
|
-
|
|
16964
|
-
|
|
16965
|
-
|
|
16966
|
-
|
|
16967
|
-
|
|
16940
|
+
function worstOf(a, b) {
|
|
16941
|
+
const order = ["ok", "warn", "fail"];
|
|
16942
|
+
return order[Math.max(order.indexOf(a), order.indexOf(b))];
|
|
16943
|
+
}
|
|
16944
|
+
async function runDoctor(args, deps = {}) {
|
|
16945
|
+
const projectDir = args.projectDir;
|
|
16946
|
+
const findings = [];
|
|
16947
|
+
const env = readEnv(projectDir);
|
|
16948
|
+
const instance = args.instance ?? env.get("LAKEBASE_PROJECT_ID");
|
|
16949
|
+
const state = readWorkflowState(projectDir);
|
|
16950
|
+
const workflowStatePresent = state !== null;
|
|
16951
|
+
for (const stale of deps.findStaleBranches?.() ?? []) {
|
|
16952
|
+
const where = stale.feature_id ? ` ${stale.feature_id}/${stale.story_id}` : "";
|
|
16953
|
+
findings.push({
|
|
16954
|
+
id: `stale-${stale.kind}`,
|
|
16955
|
+
severity: "warn",
|
|
16956
|
+
message: `Stale ${stale.kind}${where} "${stale.slug}"${stale.branch ? ` (branch ${stale.branch})` : ""}: ${stale.reason}.`,
|
|
16957
|
+
suggestion: stale.kind === "experiment" ? `consort-experiment discard --feature ${stale.feature_id} --story ${stale.story_id} --slug ${stale.slug} --instance <id> --approver <you> --reason "doctor: stale experiment"` : "consort-spike teardown (or delete the spike's paired branch) once its learning has carried forward"
|
|
16958
|
+
});
|
|
16959
|
+
}
|
|
16960
|
+
if (!workflowStatePresent) {
|
|
16961
|
+
findings.push({
|
|
16962
|
+
id: "no-state-file",
|
|
16963
|
+
severity: "fail",
|
|
16964
|
+
message: "No .lakebase/workflow-state.json. Either the project pre-dates the SCM workflow or scaffold did not seed it.",
|
|
16965
|
+
suggestion: "lakebase-scm-adopt-state"
|
|
16966
|
+
});
|
|
16967
|
+
}
|
|
16968
|
+
try {
|
|
16969
|
+
const ownerRepo = await getOwnerRepo(projectDir);
|
|
16970
|
+
if (ownerRepo) {
|
|
16971
|
+
const enabled = await getActionsEnabled(ownerRepo);
|
|
16972
|
+
if (enabled === false) {
|
|
16973
|
+
findings.push({
|
|
16974
|
+
id: "github-actions-disabled",
|
|
16975
|
+
severity: "warn",
|
|
16976
|
+
message: `GitHub Actions is disabled for ${ownerRepo}, so the kit's CI workflows (pr.yml / merge.yml) will never run , the PR branch's migrations, tests, and schema-diff comment are all skipped. This is commonly an org-level policy on EMU repos, which a repo admin cannot override.`,
|
|
16977
|
+
suggestion: "Have an org owner enable Actions for this repo (repo Settings -> Actions -> General; if it says 'disabled by the organization', it must be enabled in the org's Actions policy). Until then, run the workflow steps locally: scripts/run-tests.sh against a Lakebase branch."
|
|
16978
|
+
});
|
|
16979
|
+
}
|
|
16968
16980
|
}
|
|
16969
|
-
|
|
16970
|
-
|
|
16971
|
-
|
|
16972
|
-
|
|
16973
|
-
|
|
16974
|
-
|
|
16975
|
-
|
|
16976
|
-
|
|
16977
|
-
|
|
16981
|
+
} catch {
|
|
16982
|
+
}
|
|
16983
|
+
for (const wf of ["pr.yml", "merge.yml"]) {
|
|
16984
|
+
try {
|
|
16985
|
+
const p = path30.join(projectDir, ".github", "workflows", wf);
|
|
16986
|
+
if (!fs30.existsSync(p)) continue;
|
|
16987
|
+
const body = fs30.readFileSync(p, "utf8");
|
|
16988
|
+
if (/lakebase-scm-utils#v\d/.test(body)) {
|
|
16989
|
+
findings.push({
|
|
16990
|
+
id: "ci-workflow-substrate-pin",
|
|
16991
|
+
severity: "warn",
|
|
16992
|
+
message: `.github/workflows/${wf} hardcodes a substrate version pin (github:databricks-solutions/lakebase-scm-utils#v<ver>) instead of resolving .lakebase/scm-utils-ref at runtime, so bumping .lakebase/scm-utils-ref does NOT change the substrate CI actually runs.`,
|
|
16993
|
+
suggestion: "Re-emit the workflows from the current substrate templates so they resolve SCM_UTILS_REF from .lakebase/scm-utils-ref at CI time (updateWorkflows in scripts/lakebase/workflow-drift.ts; lakebase-doctor reports this as workflow-drift). Until then a scm-utils-ref bump will not reach CI."
|
|
16994
|
+
});
|
|
16995
|
+
}
|
|
16996
|
+
} catch {
|
|
16997
|
+
}
|
|
16998
|
+
}
|
|
16999
|
+
try {
|
|
17000
|
+
if (isGitTracked(projectDir, ".lakebase/workflow-state.json")) {
|
|
17001
|
+
findings.push({
|
|
17002
|
+
id: "scm-state-git-tracked",
|
|
17003
|
+
severity: "warn",
|
|
17004
|
+
message: ".lakebase/workflow-state.json (the per-working-tree SCM claim state) is git-tracked, so a branch checkout or `git reset --hard origin/<tier>` can restore a stale committed claim over the live one (the foreign-claim guard then refuses to drive until you abandon + reclaim).",
|
|
17005
|
+
suggestion: "If a wrong-feature-claim refusal follows a checkout, run lakebase-scm-adopt-state (or abandon + reclaim) to re-establish the live claim. The kit-ref run pin (.lakebase/kit-ref.local) already protects the kit version from the same checkout revert."
|
|
16978
17006
|
});
|
|
16979
17007
|
}
|
|
17008
|
+
} catch {
|
|
16980
17009
|
}
|
|
16981
|
-
|
|
16982
|
-
|
|
16983
|
-
|
|
17010
|
+
if (!env.has("LAKEBASE_PROJECT_ID")) {
|
|
17011
|
+
findings.push({
|
|
17012
|
+
id: "env-missing-project-id",
|
|
17013
|
+
severity: "fail",
|
|
17014
|
+
message: ".env does not contain LAKEBASE_PROJECT_ID. The post-checkout hook will exit early; workflow CLIs will need an explicit --instance.",
|
|
17015
|
+
suggestion: "Set LAKEBASE_PROJECT_ID=<your project id> in .env"
|
|
17016
|
+
});
|
|
16984
17017
|
}
|
|
16985
|
-
|
|
16986
|
-
|
|
16987
|
-
|
|
16988
|
-
|
|
16989
|
-
|
|
16990
|
-
|
|
16991
|
-
|
|
16992
|
-
const hasDrift = files.some((f) => f.status === "drifted" || f.status === "missing");
|
|
16993
|
-
return {
|
|
16994
|
-
overall: hasDrift ? "drift" : "ok",
|
|
16995
|
-
files
|
|
16996
|
-
};
|
|
16997
|
-
}
|
|
16998
|
-
function readKitVersion(kitWorkflowsDir) {
|
|
16999
|
-
let dir = kitWorkflowsDir;
|
|
17000
|
-
for (let i = 0; i < 5; i++) {
|
|
17001
|
-
dir = path30.dirname(dir);
|
|
17018
|
+
if (!instance) {
|
|
17019
|
+
return finalize({
|
|
17020
|
+
projectDir,
|
|
17021
|
+
workflowStatePresent,
|
|
17022
|
+
state: state ?? void 0,
|
|
17023
|
+
findings
|
|
17024
|
+
});
|
|
17002
17025
|
}
|
|
17026
|
+
let lakebaseBranches = [];
|
|
17003
17027
|
try {
|
|
17004
|
-
|
|
17005
|
-
|
|
17006
|
-
|
|
17007
|
-
|
|
17008
|
-
|
|
17028
|
+
lakebaseBranches = await listBranches({ instance });
|
|
17029
|
+
} catch (err) {
|
|
17030
|
+
findings.push({
|
|
17031
|
+
id: "lakebase-unreachable",
|
|
17032
|
+
severity: "fail",
|
|
17033
|
+
message: `Could not list Lakebase branches for instance ${instance}: ${err instanceof Error ? err.message : String(err)}`,
|
|
17034
|
+
suggestion: "databricks auth login (or check DATABRICKS_CONFIG_PROFILE)."
|
|
17035
|
+
});
|
|
17036
|
+
return finalize({
|
|
17037
|
+
projectDir,
|
|
17038
|
+
workflowStatePresent,
|
|
17039
|
+
state: state ?? void 0,
|
|
17040
|
+
findings
|
|
17041
|
+
});
|
|
17009
17042
|
}
|
|
17010
|
-
|
|
17011
|
-
|
|
17012
|
-
|
|
17013
|
-
|
|
17014
|
-
|
|
17015
|
-
|
|
17016
|
-
|
|
17017
|
-
|
|
17018
|
-
function findKitCommandsDir(start) {
|
|
17019
|
-
let dir = start;
|
|
17020
|
-
for (let i = 0; i < 6; i++) {
|
|
17021
|
-
const candidate = path30.join(
|
|
17022
|
-
dir,
|
|
17023
|
-
"templates",
|
|
17024
|
-
"project",
|
|
17025
|
-
"common",
|
|
17026
|
-
".claude",
|
|
17027
|
-
"commands"
|
|
17028
|
-
);
|
|
17029
|
-
if (fs30.existsSync(candidate)) return candidate;
|
|
17030
|
-
const parent = path30.dirname(dir);
|
|
17031
|
-
if (parent === dir) break;
|
|
17032
|
-
dir = parent;
|
|
17043
|
+
const inferredTopology = inferTierTopology(lakebaseBranches);
|
|
17044
|
+
if (state && state.tier_topology !== inferredTopology) {
|
|
17045
|
+
findings.push({
|
|
17046
|
+
id: "tier-topology-mismatch",
|
|
17047
|
+
severity: "warn",
|
|
17048
|
+
message: `workflow-state records tier_topology=${state.tier_topology}, but the Lakebase tier inventory suggests ${inferredTopology}.`,
|
|
17049
|
+
suggestion: "lakebase-scm-adopt-state --force"
|
|
17050
|
+
});
|
|
17033
17051
|
}
|
|
17034
|
-
|
|
17035
|
-
|
|
17036
|
-
|
|
17037
|
-
|
|
17038
|
-
|
|
17039
|
-
|
|
17040
|
-
|
|
17041
|
-
}
|
|
17042
|
-
function detectCommandDrift(args) {
|
|
17043
|
-
const projectCommandsDir = path30.join(args.projectDir, ".claude", "commands");
|
|
17044
|
-
const here = path30.dirname(new URL(import.meta.url).pathname);
|
|
17045
|
-
const kitCommandsDir = args.kitDir ? path30.join(args.kitDir, "templates", "project", "common", ".claude", "commands") : findKitCommandsDir(here);
|
|
17046
|
-
const kitVersion = readKitVersionFromCommandsDir(kitCommandsDir);
|
|
17047
|
-
const templateFiles = fs30.existsSync(kitCommandsDir) ? fs30.readdirSync(kitCommandsDir).filter((f) => f.endsWith(".md") && !COMMAND_HOOK_FILE_PATTERN.test(f)) : [];
|
|
17048
|
-
const projectFiles = fs30.existsSync(projectCommandsDir) ? fs30.readdirSync(projectCommandsDir).filter((f) => f.endsWith(".md") && !COMMAND_HOOK_FILE_PATTERN.test(f)) : [];
|
|
17049
|
-
const seen = /* @__PURE__ */ new Set();
|
|
17050
|
-
const files = [];
|
|
17051
|
-
for (const name of templateFiles) {
|
|
17052
|
-
seen.add(name);
|
|
17053
|
-
const projectPath2 = path30.join(projectCommandsDir, name);
|
|
17054
|
-
const templatePath = path30.join(kitCommandsDir, name);
|
|
17055
|
-
const templateRaw = fs30.readFileSync(templatePath, "utf8");
|
|
17056
|
-
if (!fs30.existsSync(projectPath2)) {
|
|
17057
|
-
files.push({ name, status: "missing", kit_version: kitVersion });
|
|
17058
|
-
continue;
|
|
17059
|
-
}
|
|
17060
|
-
const projectContent = fs30.readFileSync(projectPath2, "utf8");
|
|
17061
|
-
const pinned = parsePinnedVersion(projectContent);
|
|
17062
|
-
const versionForCompare = pinned ?? kitVersion;
|
|
17063
|
-
const templateContent = applyCommandPlaceholders(templateRaw, versionForCompare);
|
|
17064
|
-
if (projectContent === templateContent) {
|
|
17065
|
-
files.push({
|
|
17066
|
-
name,
|
|
17067
|
-
status: "unchanged",
|
|
17068
|
-
pinned_version: pinned,
|
|
17069
|
-
kit_version: kitVersion
|
|
17070
|
-
});
|
|
17071
|
-
} else {
|
|
17072
|
-
files.push({
|
|
17073
|
-
name,
|
|
17074
|
-
status: "drifted",
|
|
17075
|
-
pinned_version: pinned,
|
|
17076
|
-
kit_version: kitVersion,
|
|
17077
|
-
diff: unifiedDiff(name, projectContent, templateContent)
|
|
17052
|
+
const headBranch = await getCurrentBranch({ cwd: projectDir });
|
|
17053
|
+
if (state && state.state === "feature-claimed") {
|
|
17054
|
+
if (state.branch && headBranch && headBranch !== state.branch) {
|
|
17055
|
+
findings.push({
|
|
17056
|
+
id: "head-branch-drift",
|
|
17057
|
+
severity: "warn",
|
|
17058
|
+
message: `workflow says feature-claimed for "${state.branch}", but HEAD is on "${headBranch}".`,
|
|
17059
|
+
suggestion: `git checkout '${state.branch}'`
|
|
17078
17060
|
});
|
|
17079
17061
|
}
|
|
17062
|
+
if (state.branch) {
|
|
17063
|
+
const sanitized = sanitizeBranchName(state.branch);
|
|
17064
|
+
let pair;
|
|
17065
|
+
try {
|
|
17066
|
+
pair = await getBranchByName(sanitized, { instance });
|
|
17067
|
+
} catch {
|
|
17068
|
+
pair = void 0;
|
|
17069
|
+
}
|
|
17070
|
+
if (!pair) {
|
|
17071
|
+
findings.push({
|
|
17072
|
+
id: "lakebase-pair-missing",
|
|
17073
|
+
severity: "fail",
|
|
17074
|
+
message: `workflow says feature-claimed for "${state.branch}", but no Lakebase branch "${sanitized}" exists.`,
|
|
17075
|
+
suggestion: `lakebase-scm-abandon-feature # reset state; re-claim if needed`
|
|
17076
|
+
});
|
|
17077
|
+
} else if (state.lakebase_branch_uid && pair.uid !== state.lakebase_branch_uid) {
|
|
17078
|
+
findings.push({
|
|
17079
|
+
id: "lakebase-uid-drift",
|
|
17080
|
+
severity: "warn",
|
|
17081
|
+
message: `workflow records lakebase_branch_uid=${state.lakebase_branch_uid}, but the live branch reports ${pair.uid}.`,
|
|
17082
|
+
suggestion: "lakebase-scm-adopt-state --force"
|
|
17083
|
+
});
|
|
17084
|
+
}
|
|
17085
|
+
}
|
|
17080
17086
|
}
|
|
17081
|
-
|
|
17082
|
-
|
|
17083
|
-
|
|
17087
|
+
if (state && state.state === "feature-claimed" && state.branch) {
|
|
17088
|
+
const envBranchId = env.get("LAKEBASE_BRANCH_ID");
|
|
17089
|
+
const sanitized = sanitizeBranchName(state.branch);
|
|
17090
|
+
if (envBranchId && envBranchId !== sanitized) {
|
|
17091
|
+
findings.push({
|
|
17092
|
+
id: "env-branch-drift",
|
|
17093
|
+
severity: "warn",
|
|
17094
|
+
message: `.env LAKEBASE_BRANCH_ID=${envBranchId} but workflow says ${sanitized}. The post-checkout hook may not have run since the last branch switch.`,
|
|
17095
|
+
suggestion: `git checkout '${state.branch}' # re-fires post-checkout`
|
|
17096
|
+
});
|
|
17097
|
+
}
|
|
17084
17098
|
}
|
|
17085
|
-
|
|
17086
|
-
|
|
17087
|
-
|
|
17088
|
-
|
|
17089
|
-
|
|
17090
|
-
|
|
17091
|
-
|
|
17092
|
-
|
|
17093
|
-
|
|
17094
|
-
}
|
|
17095
|
-
|
|
17096
|
-
let dir = kitCommandsDir;
|
|
17097
|
-
for (let i = 0; i < 5; i++) {
|
|
17098
|
-
dir = path30.dirname(dir);
|
|
17099
|
+
if (headBranch && !TIER_LEAFS2.has(headBranch) && headBranch.startsWith(FEATURE_PREFIX)) {
|
|
17100
|
+
const sanitized = sanitizeBranchName(headBranch);
|
|
17101
|
+
const paired = lakebaseBranches.some((b) => leafOf2(b) === sanitized);
|
|
17102
|
+
if (!paired) {
|
|
17103
|
+
findings.push({
|
|
17104
|
+
id: "orphan-current-branch",
|
|
17105
|
+
severity: "fail",
|
|
17106
|
+
message: `Current git branch "${headBranch}" has no Lakebase pair (post-checkout fallback retired in phase C).`,
|
|
17107
|
+
suggestion: `lakebase-scm-recover-orphans --claim --only-branch '${headBranch}'`
|
|
17108
|
+
});
|
|
17109
|
+
}
|
|
17099
17110
|
}
|
|
17100
17111
|
try {
|
|
17101
|
-
const
|
|
17102
|
-
|
|
17103
|
-
|
|
17112
|
+
const heads = await collapseMigrationHeads({ projectDir, dryRun: true });
|
|
17113
|
+
if (heads.headsBefore.length > 1) {
|
|
17114
|
+
findings.push({
|
|
17115
|
+
id: "multiple-migration-heads",
|
|
17116
|
+
severity: "fail",
|
|
17117
|
+
message: `Migrations have ${heads.headsBefore.length} heads (${heads.headsBefore.join(", ")}); a sibling-feature merge left them un-collapsed. \`upgrade head\` will refuse until they are unified.`,
|
|
17118
|
+
suggestion: "lakebase-collapse-heads"
|
|
17119
|
+
});
|
|
17120
|
+
}
|
|
17104
17121
|
} catch {
|
|
17105
|
-
return "unknown";
|
|
17106
17122
|
}
|
|
17123
|
+
if (instance && state?.branch) {
|
|
17124
|
+
try {
|
|
17125
|
+
const localIds = listSchemaMigrations({ projectDir }).map((m) => m.version);
|
|
17126
|
+
let orphanRev = null;
|
|
17127
|
+
try {
|
|
17128
|
+
const status = await schemaMigrationStatus({ instance, branch: state.branch, projectDir });
|
|
17129
|
+
if (dbRevisionOrphaned(status.current, localIds)) orphanRev = status.current ?? null;
|
|
17130
|
+
} catch (e) {
|
|
17131
|
+
orphanRev = parseAlembicMissingRevision(e instanceof Error ? e.message : String(e));
|
|
17132
|
+
}
|
|
17133
|
+
if (orphanRev) {
|
|
17134
|
+
findings.push({
|
|
17135
|
+
id: "db-ahead-of-code",
|
|
17136
|
+
severity: "fail",
|
|
17137
|
+
message: `The paired branch DB is AHEAD of code: applied revision '${orphanRev}' has no local migration file. An aborted build likely migrated this branch and a git reset removed the migration file; alembic accept/deploy/promote will fail "Can't locate revision".`,
|
|
17138
|
+
suggestion: "reset the paired branch DB to the code head (downgrade/stamp + drop reset-created tables), or delete the branch and re-cut it clean from its tier"
|
|
17139
|
+
});
|
|
17140
|
+
}
|
|
17141
|
+
} catch {
|
|
17142
|
+
}
|
|
17143
|
+
}
|
|
17144
|
+
return finalize({
|
|
17145
|
+
projectDir,
|
|
17146
|
+
workflowStatePresent,
|
|
17147
|
+
state: state ?? void 0,
|
|
17148
|
+
inferredTierTopology: inferredTopology,
|
|
17149
|
+
findings
|
|
17150
|
+
});
|
|
17107
17151
|
}
|
|
17108
|
-
function
|
|
17109
|
-
|
|
17110
|
-
const
|
|
17111
|
-
|
|
17112
|
-
|
|
17113
|
-
|
|
17114
|
-
commands
|
|
17115
|
-
};
|
|
17152
|
+
function finalize(report) {
|
|
17153
|
+
let worst = "ok";
|
|
17154
|
+
for (const f of report.findings) {
|
|
17155
|
+
worst = worstOf(worst, f.severity);
|
|
17156
|
+
}
|
|
17157
|
+
return { ...report, worstSeverity: worst };
|
|
17116
17158
|
}
|
|
17117
|
-
|
|
17118
|
-
|
|
17119
|
-
|
|
17120
|
-
|
|
17121
|
-
"
|
|
17122
|
-
);
|
|
17123
|
-
const here = path30.dirname(new URL(import.meta.url).pathname);
|
|
17124
|
-
const kitWorkflowsDir = args.kitDir ? path30.join(
|
|
17125
|
-
args.kitDir,
|
|
17126
|
-
"templates",
|
|
17127
|
-
"project",
|
|
17128
|
-
"common",
|
|
17129
|
-
".github",
|
|
17130
|
-
"workflows"
|
|
17131
|
-
) : findKitTemplatesDir(here);
|
|
17132
|
-
const substitute = args.substitute !== false;
|
|
17133
|
-
const dryRun = args.dryRun === true;
|
|
17134
|
-
const pruneExtras = args.pruneExtras === true;
|
|
17135
|
-
const templateFiles = fs30.existsSync(kitWorkflowsDir) ? fs30.readdirSync(kitWorkflowsDir).filter((f) => f.endsWith(".yml")) : [];
|
|
17136
|
-
const projectFiles = fs30.existsSync(projectWorkflowsDir) ? fs30.readdirSync(projectWorkflowsDir).filter((f) => f.endsWith(".yml")) : [];
|
|
17137
|
-
if (!dryRun && templateFiles.length > 0 && !fs30.existsSync(projectWorkflowsDir)) {
|
|
17138
|
-
fs30.mkdirSync(projectWorkflowsDir, { recursive: true });
|
|
17159
|
+
var ScmDoctorFixError = class extends Error {
|
|
17160
|
+
constructor(message, code) {
|
|
17161
|
+
super(message);
|
|
17162
|
+
this.code = code;
|
|
17163
|
+
this.name = "ScmDoctorFixError";
|
|
17139
17164
|
}
|
|
17140
|
-
|
|
17141
|
-
|
|
17142
|
-
|
|
17143
|
-
|
|
17144
|
-
|
|
17145
|
-
|
|
17146
|
-
|
|
17147
|
-
|
|
17148
|
-
|
|
17149
|
-
|
|
17150
|
-
|
|
17151
|
-
|
|
17152
|
-
|
|
17153
|
-
|
|
17154
|
-
|
|
17155
|
-
|
|
17156
|
-
} else {
|
|
17157
|
-
outcome = "updated";
|
|
17158
|
-
}
|
|
17159
|
-
if (!dryRun && outcome !== "unchanged") {
|
|
17160
|
-
fs30.writeFileSync(projectPath2, desired);
|
|
17161
|
-
}
|
|
17162
|
-
files.push({ name, outcome });
|
|
17165
|
+
code;
|
|
17166
|
+
};
|
|
17167
|
+
var FIXABLE_FINDING_IDS = [
|
|
17168
|
+
"env-branch-drift",
|
|
17169
|
+
"head-branch-drift",
|
|
17170
|
+
"tier-topology-mismatch",
|
|
17171
|
+
"orphan-current-branch",
|
|
17172
|
+
"multiple-migration-heads",
|
|
17173
|
+
"db-ahead-of-code"
|
|
17174
|
+
];
|
|
17175
|
+
async function fixFinding(args) {
|
|
17176
|
+
if (!FIXABLE_FINDING_IDS.includes(args.findingId)) {
|
|
17177
|
+
throw new ScmDoctorFixError(
|
|
17178
|
+
`Finding "${args.findingId}" is not supported by --fix. Supported: ${FIXABLE_FINDING_IDS.join(", ")}.`,
|
|
17179
|
+
"unsupported-finding"
|
|
17180
|
+
);
|
|
17163
17181
|
}
|
|
17164
|
-
|
|
17165
|
-
|
|
17166
|
-
|
|
17167
|
-
|
|
17168
|
-
|
|
17169
|
-
|
|
17182
|
+
const report = args.report ?? await runDoctor({ projectDir: args.projectDir, instance: args.instance });
|
|
17183
|
+
const present = report.findings.find((f) => f.id === args.findingId);
|
|
17184
|
+
if (!present) {
|
|
17185
|
+
throw new ScmDoctorFixError(
|
|
17186
|
+
`Finding "${args.findingId}" is not present in the current report. Re-run lakebase-scm-doctor to see what needs fixing.`,
|
|
17187
|
+
"finding-not-present"
|
|
17188
|
+
);
|
|
17189
|
+
}
|
|
17190
|
+
let action = "";
|
|
17191
|
+
try {
|
|
17192
|
+
switch (args.findingId) {
|
|
17193
|
+
case "env-branch-drift": {
|
|
17194
|
+
const branch = report.state?.branch;
|
|
17195
|
+
if (!branch) {
|
|
17196
|
+
throw new ScmDoctorFixError(
|
|
17197
|
+
"Cannot fix: workflow state has no branch field.",
|
|
17198
|
+
"fix-failed"
|
|
17199
|
+
);
|
|
17200
|
+
}
|
|
17201
|
+
const sanitized = sanitizeBranchName(branch);
|
|
17202
|
+
const envFile = path30.join(args.projectDir, ".env");
|
|
17203
|
+
updateEnvConnection({
|
|
17204
|
+
envPath: envFile,
|
|
17205
|
+
projectId: readEnvVar(envFile, "LAKEBASE_PROJECT_ID") ?? "",
|
|
17206
|
+
branchId: sanitized,
|
|
17207
|
+
username: readEnvVar(envFile, "DB_USERNAME") ?? "",
|
|
17208
|
+
endpointHost: readEnvVar(envFile, "LAKEBASE_HOST")
|
|
17209
|
+
});
|
|
17210
|
+
action = `rewrote .env LAKEBASE_BRANCH_ID=${sanitized} (metadata only; the app mints its token at runtime)`;
|
|
17211
|
+
break;
|
|
17212
|
+
}
|
|
17213
|
+
case "head-branch-drift": {
|
|
17214
|
+
const branch = report.state?.branch;
|
|
17215
|
+
if (!branch) {
|
|
17216
|
+
throw new ScmDoctorFixError(
|
|
17217
|
+
"Cannot fix: workflow state has no branch field.",
|
|
17218
|
+
"fix-failed"
|
|
17219
|
+
);
|
|
17220
|
+
}
|
|
17221
|
+
await exec2(`git checkout ${shellEscape3(branch)}`, {
|
|
17222
|
+
cwd: args.projectDir,
|
|
17223
|
+
timeout: 15e3
|
|
17224
|
+
});
|
|
17225
|
+
action = `git checkout ${branch} (re-fires post-checkout to resync HEAD)`;
|
|
17226
|
+
break;
|
|
17227
|
+
}
|
|
17228
|
+
case "tier-topology-mismatch": {
|
|
17229
|
+
const instance = args.instance ?? report.state?.project_id;
|
|
17230
|
+
if (!instance) {
|
|
17231
|
+
throw new ScmDoctorFixError(
|
|
17232
|
+
"Cannot fix: missing Lakebase project id.",
|
|
17233
|
+
"fix-failed"
|
|
17234
|
+
);
|
|
17235
|
+
}
|
|
17236
|
+
await adoptScmState({
|
|
17237
|
+
projectDir: args.projectDir,
|
|
17238
|
+
instance,
|
|
17239
|
+
force: true
|
|
17240
|
+
});
|
|
17241
|
+
action = `adopted state with --force to re-infer tier_topology`;
|
|
17242
|
+
break;
|
|
17243
|
+
}
|
|
17244
|
+
case "orphan-current-branch": {
|
|
17245
|
+
const instance = args.instance ?? report.state?.project_id;
|
|
17246
|
+
if (!instance) {
|
|
17247
|
+
throw new ScmDoctorFixError(
|
|
17248
|
+
"Cannot fix: missing Lakebase project id.",
|
|
17249
|
+
"fix-failed"
|
|
17250
|
+
);
|
|
17251
|
+
}
|
|
17252
|
+
const headBranch = await getCurrentBranch({ cwd: args.projectDir });
|
|
17253
|
+
if (!headBranch) {
|
|
17254
|
+
throw new ScmDoctorFixError(
|
|
17255
|
+
"Cannot fix: detached HEAD or no current branch.",
|
|
17256
|
+
"fix-failed"
|
|
17257
|
+
);
|
|
17258
|
+
}
|
|
17259
|
+
await recoverOrphans({
|
|
17260
|
+
projectDir: args.projectDir,
|
|
17261
|
+
instance,
|
|
17262
|
+
claim: true,
|
|
17263
|
+
onlyBranch: headBranch
|
|
17264
|
+
});
|
|
17265
|
+
action = `recovered orphan ${headBranch} via createFeaturePairedBranch`;
|
|
17266
|
+
break;
|
|
17267
|
+
}
|
|
17268
|
+
case "multiple-migration-heads": {
|
|
17269
|
+
const r = await collapseMigrationHeads({ projectDir: args.projectDir });
|
|
17270
|
+
if (r.status !== "ok" || !r.mergeRevision) {
|
|
17271
|
+
throw new ScmDoctorFixError(
|
|
17272
|
+
`Expected to create a merge revision but got status="${r.status}".`,
|
|
17273
|
+
"fix-failed"
|
|
17274
|
+
);
|
|
17275
|
+
}
|
|
17276
|
+
action = `collapsed ${r.headsBefore.length} heads into merge revision ${r.mergeRevision} (commit it)`;
|
|
17277
|
+
break;
|
|
17278
|
+
}
|
|
17279
|
+
case "db-ahead-of-code": {
|
|
17280
|
+
const r = await abandonFeatureBranch({ projectDir: args.projectDir, instance: args.instance, force: true });
|
|
17281
|
+
action = `abandoned the feature (deleted the polluted paired branch${r.lakebaseDeleted ? "" : " , Lakebase delete reported not-deleted"}) and reset state to '${r.state.state}'; re-claim to re-fork a clean branch from the tier`;
|
|
17282
|
+
break;
|
|
17170
17283
|
}
|
|
17171
|
-
files.push({ name, outcome: "removed" });
|
|
17172
17284
|
}
|
|
17285
|
+
} catch (err) {
|
|
17286
|
+
if (err instanceof ScmDoctorFixError) throw err;
|
|
17287
|
+
throw new ScmDoctorFixError(
|
|
17288
|
+
`Remediation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
17289
|
+
"fix-failed"
|
|
17290
|
+
);
|
|
17173
17291
|
}
|
|
17174
|
-
const
|
|
17175
|
-
|
|
17176
|
-
|
|
17177
|
-
|
|
17178
|
-
|
|
17179
|
-
|
|
17180
|
-
|
|
17181
|
-
|
|
17182
|
-
return { files, changed };
|
|
17292
|
+
const postReport = await runDoctor({
|
|
17293
|
+
projectDir: args.projectDir,
|
|
17294
|
+
instance: args.instance
|
|
17295
|
+
});
|
|
17296
|
+
return { findingId: args.findingId, action, postReport };
|
|
17297
|
+
}
|
|
17298
|
+
function shellEscape3(s) {
|
|
17299
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
17183
17300
|
}
|
|
17184
17301
|
|
|
17185
17302
|
// scripts/lakebase/doctor.ts
|
|
17303
|
+
init_esm_shims();
|
|
17304
|
+
import * as fs31 from "fs";
|
|
17305
|
+
import * as path31 from "path";
|
|
17306
|
+
import * as cp6 from "child_process";
|
|
17186
17307
|
function readEnvFile(projectDir) {
|
|
17187
17308
|
const envPath = path31.join(projectDir, ".env");
|
|
17188
17309
|
if (!fs31.existsSync(envPath)) return {};
|
|
@@ -17615,7 +17736,7 @@ function checkWorkflowDrift(projectDir) {
|
|
|
17615
17736
|
status: "warn",
|
|
17616
17737
|
message: `Scaffolded workflows drift from kit: ${drifted} drifted, ${missing} missing`,
|
|
17617
17738
|
detail: { files: report.files.map((f) => ({ name: f.name, status: f.status })) },
|
|
17618
|
-
hint: "
|
|
17739
|
+
hint: "Refresh with updateWorkflows (or the lakebase_workflow_drift MCP tool). A stale merge.yml calling 'lakebase-schema-migrate apply' (not 'apply-tier') makes the promote's migrate-target fail on the tier guard; the scm-merge promote also self-heals drifted workflows onto the PR head before merging."
|
|
17619
17740
|
};
|
|
17620
17741
|
} catch (err) {
|
|
17621
17742
|
return {
|
|
@@ -17726,9 +17847,10 @@ async function ensureLakebaseSecretAuth(args) {
|
|
|
17726
17847
|
if (typeof pat !== "string" || !pat) {
|
|
17727
17848
|
throw new Error("databricks tokens create returned no token_value");
|
|
17728
17849
|
}
|
|
17729
|
-
await runDatabricks(["secrets", "put-secret",
|
|
17850
|
+
await runDatabricks(["secrets", "put-secret", "--json", "@/dev/stdin"], {
|
|
17730
17851
|
profile,
|
|
17731
|
-
timeout: timeoutMs
|
|
17852
|
+
timeout: timeoutMs,
|
|
17853
|
+
input: JSON.stringify({ scope: scopeName, key: keyName, string_value: pat })
|
|
17732
17854
|
});
|
|
17733
17855
|
let aclGranted = false;
|
|
17734
17856
|
if (servicePrincipalClientId) {
|
|
@@ -18090,63 +18212,6 @@ async function discardAllChanges(args) {
|
|
|
18090
18212
|
await exec2("git clean -fd", { cwd: args.cwd });
|
|
18091
18213
|
}
|
|
18092
18214
|
|
|
18093
|
-
// scripts/git/sync.ts
|
|
18094
|
-
init_esm_shims();
|
|
18095
|
-
async function currentBranchName2(cwd) {
|
|
18096
|
-
try {
|
|
18097
|
-
return await exec2("git rev-parse --abbrev-ref HEAD", { cwd });
|
|
18098
|
-
} catch {
|
|
18099
|
-
return "";
|
|
18100
|
-
}
|
|
18101
|
-
}
|
|
18102
|
-
async function push(args) {
|
|
18103
|
-
await exec2("git push", { cwd: args.cwd });
|
|
18104
|
-
}
|
|
18105
|
-
async function pull(args) {
|
|
18106
|
-
await exec2("git pull", { cwd: args.cwd });
|
|
18107
|
-
}
|
|
18108
|
-
async function publishBranch(args) {
|
|
18109
|
-
const remote = args.remote ?? "origin";
|
|
18110
|
-
const branch = await currentBranchName2(args.cwd);
|
|
18111
|
-
if (!branch) throw new Error("No current branch");
|
|
18112
|
-
await exec2(`git push -u ${remote} ${shq(branch)}`, {
|
|
18113
|
-
cwd: args.cwd
|
|
18114
|
-
});
|
|
18115
|
-
}
|
|
18116
|
-
async function pushCurrentBranchForPr(args) {
|
|
18117
|
-
const remote = args.remote ?? "origin";
|
|
18118
|
-
const branch = await currentBranchName2(args.cwd);
|
|
18119
|
-
if (!branch) throw new Error("No current branch");
|
|
18120
|
-
const upstreamSet = await hasUpstream({ cwd: args.cwd });
|
|
18121
|
-
if (!upstreamSet) {
|
|
18122
|
-
await exec2(`git push -u ${remote} ${shq(branch)}`, {
|
|
18123
|
-
cwd: args.cwd
|
|
18124
|
-
});
|
|
18125
|
-
} else {
|
|
18126
|
-
await exec2("git push", { cwd: args.cwd });
|
|
18127
|
-
}
|
|
18128
|
-
}
|
|
18129
|
-
async function fetch2(args) {
|
|
18130
|
-
const parts = ["git fetch"];
|
|
18131
|
-
if (args.prune) parts.push("--prune");
|
|
18132
|
-
if (args.all) parts.push("--all");
|
|
18133
|
-
await exec2(parts.join(" "), { cwd: args.cwd });
|
|
18134
|
-
}
|
|
18135
|
-
async function pullFrom(args) {
|
|
18136
|
-
await exec2(`git pull ${shq(args.remote)} ${shq(args.branch)}`, {
|
|
18137
|
-
cwd: args.cwd
|
|
18138
|
-
});
|
|
18139
|
-
}
|
|
18140
|
-
async function pushTo(args) {
|
|
18141
|
-
await exec2(`git push ${shq(args.remote)} ${shq(args.branch)}`, {
|
|
18142
|
-
cwd: args.cwd
|
|
18143
|
-
});
|
|
18144
|
-
}
|
|
18145
|
-
async function sync(args) {
|
|
18146
|
-
await exec2("git pull", { cwd: args.cwd });
|
|
18147
|
-
await exec2("git push", { cwd: args.cwd });
|
|
18148
|
-
}
|
|
18149
|
-
|
|
18150
18215
|
// scripts/git/branch-tag.ts
|
|
18151
18216
|
init_esm_shims();
|
|
18152
18217
|
var PROTECTED_BRANCHES = /* @__PURE__ */ new Set(["production", "main", "master"]);
|
|
@@ -18551,6 +18616,7 @@ export {
|
|
|
18551
18616
|
createWorktree,
|
|
18552
18617
|
cutBackup,
|
|
18553
18618
|
databricksAuthPrereqMessage,
|
|
18619
|
+
defaultRefreshPromoteWorkflows,
|
|
18554
18620
|
delay,
|
|
18555
18621
|
deleteAppEndpoint,
|
|
18556
18622
|
deleteBranch,
|