@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.
Files changed (63) hide show
  1. package/dist/scripts/github/index.cjs +21 -4
  2. package/dist/scripts/github/index.js +21 -4
  3. package/dist/scripts/github/pr.cli.cjs +21 -4
  4. package/dist/scripts/github/pr.cli.js +21 -4
  5. package/dist/scripts/index.cjs +1224 -1157
  6. package/dist/scripts/index.d.cts +1 -1
  7. package/dist/scripts/index.d.ts +1 -1
  8. package/dist/scripts/index.js +1223 -1157
  9. package/dist/scripts/lakebase/branch.cli.cjs +21 -4
  10. package/dist/scripts/lakebase/branch.cli.js +21 -4
  11. package/dist/scripts/lakebase/ci-app-endpoint.cli.cjs +21 -4
  12. package/dist/scripts/lakebase/ci-app-endpoint.cli.js +21 -4
  13. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +23 -6
  14. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +23 -6
  15. package/dist/scripts/lakebase/collapse-heads.cli.cjs +23 -6
  16. package/dist/scripts/lakebase/collapse-heads.cli.js +23 -6
  17. package/dist/scripts/lakebase/cut-backup.cli.cjs +21 -4
  18. package/dist/scripts/lakebase/cut-backup.cli.js +21 -4
  19. package/dist/scripts/lakebase/cut-tier.cli.cjs +21 -4
  20. package/dist/scripts/lakebase/cut-tier.cli.js +21 -4
  21. package/dist/scripts/lakebase/detect-language.cli.cjs +23 -6
  22. package/dist/scripts/lakebase/detect-language.cli.js +23 -6
  23. package/dist/scripts/lakebase/doctor.cli.cjs +24 -7
  24. package/dist/scripts/lakebase/doctor.cli.js +24 -7
  25. package/dist/scripts/lakebase/get-connection.cli.cjs +23 -6
  26. package/dist/scripts/lakebase/get-connection.cli.js +23 -6
  27. package/dist/scripts/lakebase/index.cjs +489 -391
  28. package/dist/scripts/lakebase/index.d.cts +33 -1
  29. package/dist/scripts/lakebase/index.d.ts +33 -1
  30. package/dist/scripts/lakebase/index.js +488 -391
  31. package/dist/scripts/lakebase/infra-runner.cli.cjs +23 -6
  32. package/dist/scripts/lakebase/infra-runner.cli.js +23 -6
  33. package/dist/scripts/lakebase/new-migration.cli.cjs +23 -6
  34. package/dist/scripts/lakebase/new-migration.cli.js +23 -6
  35. package/dist/scripts/lakebase/schema-diff.cli.cjs +23 -6
  36. package/dist/scripts/lakebase/schema-diff.cli.js +23 -6
  37. package/dist/scripts/lakebase/schema-migrate.cli.cjs +23 -6
  38. package/dist/scripts/lakebase/schema-migrate.cli.js +23 -6
  39. package/dist/scripts/lakebase/scm-abandon-feature.cli.cjs +21 -4
  40. package/dist/scripts/lakebase/scm-abandon-feature.cli.js +21 -4
  41. package/dist/scripts/lakebase/scm-adopt-state.cli.cjs +21 -4
  42. package/dist/scripts/lakebase/scm-adopt-state.cli.js +21 -4
  43. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +23 -6
  44. package/dist/scripts/lakebase/scm-claim-feature.cli.js +23 -6
  45. package/dist/scripts/lakebase/scm-cleanup.cli.cjs +21 -4
  46. package/dist/scripts/lakebase/scm-cleanup.cli.js +21 -4
  47. package/dist/scripts/lakebase/scm-doctor.cli.cjs +23 -6
  48. package/dist/scripts/lakebase/scm-doctor.cli.js +23 -6
  49. package/dist/scripts/lakebase/scm-merge.cli.cjs +2305 -2037
  50. package/dist/scripts/lakebase/scm-merge.cli.js +360 -92
  51. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +23 -6
  52. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +23 -6
  53. package/dist/scripts/lakebase/scm-recover-orphans.cli.cjs +21 -4
  54. package/dist/scripts/lakebase/scm-recover-orphans.cli.js +21 -4
  55. package/dist/scripts/util/index.cjs +23 -6
  56. package/dist/scripts/util/index.js +23 -6
  57. package/package.json +1 -1
  58. package/scripts/lakebase/databricks-cli.ts +44 -4
  59. package/scripts/lakebase/doctor.ts +1 -1
  60. package/scripts/lakebase/runner-setup.ts +18 -2
  61. package/scripts/lakebase/scm-merge.ts +80 -1
  62. package/scripts/lakebase/secret-auth.ts +9 -2
  63. package/templates/project/common/scripts/run-tests.sh +10 -0
@@ -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
- const { stdout } = await execFileP("databricks", argv, {
8655
- env,
8656
- timeout: opts.timeout ?? KIT_TIMEOUTS.cliDefault
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.43".length > 0) {
9706
- cached = "0.2.43";
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.execSync(
12973
- `./config.sh --url "https://github.com/${args.fullRepoName}" --token "${regToken}" --name "${name}" --labels self-hosted --unattended --replace`,
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
- async function reconcileTierToOrigin(args) {
15996
- const { cwd, tier } = args;
16024
+
16025
+ // scripts/git/sync.ts
16026
+ init_esm_shims();
16027
+ async function currentBranchName(cwd) {
15997
16028
  try {
15998
- const head = await getCurrentBranch({ cwd });
15999
- if (head === tier) {
16000
- await exec2(`git pull --ff-only`, { cwd, timeout: 3e4 });
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
- var ScmMergeError = class extends Error {
16010
- constructor(message, code) {
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 shaMigratePredicate(mergeCommitSha) {
16027
- return (run) => {
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 mergeFeature(args) {
16033
- const current = readWorkflowState(args.projectDir);
16034
- if (!current) {
16035
- throw new ScmMergeError(
16036
- "No SCM workflow state. wait-ci first.",
16037
- "no-state-file"
16038
- );
16039
- }
16040
- if (current.state !== "ci-green") {
16041
- throw new ScmMergeError(
16042
- `merge refuses state "${current.state}". Allowed predecessor: ci-green.`,
16043
- "bad-precondition"
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
- if (!current.pr_url) {
16047
- throw new ScmMergeError(
16048
- "ci-green row is missing pr_url; cannot resolve the PR to merge.",
16049
- "no-pr-url"
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
- if (!current.branch || !current.parent_branch) {
16053
- throw new ScmMergeError(
16054
- "ci-green row missing branch / parent_branch; refusing to merge.",
16055
- "bad-precondition"
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
- const ownerRepo = await getOwnerRepo(args.projectDir);
16059
- if (!ownerRepo) {
16060
- throw new ScmMergeError(
16061
- "No GitHub remote found at origin.",
16062
- "no-github-remote"
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 pullNumber = extractPullNumber(current.pr_url);
16066
- if (!pullNumber) {
16067
- throw new ScmMergeError(
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 instance = args.instance ?? current.project_id;
16073
- const gitBase = await resolveGitBase(current.parent_branch, args.projectDir);
16074
- const wantMigrate = args.waitMigrate !== false;
16075
- let authVerified = false;
16076
- if (wantMigrate && args.verifyMigrateAuth) {
16077
- const probe = await args.verifyMigrateAuth();
16078
- if (!probe.ok) {
16079
- throw new ScmMergeError(
16080
- `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.`,
16081
- "migrate-auth"
16082
- );
16083
- }
16084
- authVerified = true;
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
- paired = await mergePairedPullRequest({
16089
- ownerRepo,
16090
- pullNumber,
16091
- lakebaseInstance: instance,
16092
- method: args.method ?? "squash"
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 shellEscape2(s) {
16289
- return `'${s.replace(/'/g, "'\\''")}'`;
16191
+ function applyPlaceholders(content, version) {
16192
+ return content.replace(/\{\{LAKEBASE_SCM_UTILS_VERSION\}\}/g, version);
16290
16193
  }
16291
-
16292
- // scripts/lakebase/scm-recover-orphans.ts
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
- async function listLocalBranches(args) {
16305
- const { cwd } = args;
16306
- let raw;
16307
- try {
16308
- raw = await exec2(
16309
- 'git branch --format="%(refname:short)|%(upstream:short)|%(upstream:track)"',
16310
- { cwd }
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
- } catch {
16313
- return [];
16314
- }
16315
- if (!raw) return [];
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
- async function hasRemoteBranch(args) {
16361
- const { cwd, branch, remote = "origin" } = args;
16362
- try {
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
- // scripts/lakebase/scm-recover-orphans.ts
16374
- var ScmRecoverError = class extends Error {
16375
- constructor(message, code) {
16376
- super(message);
16377
- this.code = code;
16378
- this.name = "ScmRecoverError";
16379
- }
16380
- code;
16381
- };
16382
- var TIER_LEAFS = /* @__PURE__ */ new Set(["staging", "dev", "main", "master"]);
16383
- async function recoverOrphans(args) {
16384
- if (!args.instance) {
16385
- throw new ScmRecoverError(
16386
- "Lakebase project id required (--instance / LAKEBASE_PROJECT_ID).",
16387
- "missing-instance"
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
- orphans.push({
16421
- gitBranch: name,
16422
- sanitized,
16423
- isCurrent: gb.isCurrent === true,
16424
- reason: name.startsWith("feature/") ? "feature/<slug> branch with no Lakebase pair" : `non-tier git branch "${name}" with no Lakebase pair`
16425
- });
16426
- }
16427
- const result = {
16428
- tierTopology,
16429
- orphans,
16430
- skipped,
16431
- claimed: []
16432
- };
16433
- if (!args.claim || orphans.length === 0) {
16434
- return result;
16435
- }
16436
- const parentBranch = parentForTopology(tierTopology, defaultLeaf);
16437
- const currentState = readWorkflowState(args.projectDir);
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
- return result;
16503
- }
16504
- function leafName2(b) {
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
- return out;
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 leafOf2(b) {
16532
- return b.name.split("/").pop() ?? b.name;
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 worstOf(a, b) {
16535
- const order = ["ok", "warn", "fail"];
16536
- return order[Math.max(order.indexOf(a), order.indexOf(b))];
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
- async function runDoctor(args, deps = {}) {
16539
- const projectDir = args.projectDir;
16540
- const findings = [];
16541
- const env = readEnv(projectDir);
16542
- const instance = args.instance ?? env.get("LAKEBASE_PROJECT_ID");
16543
- const state = readWorkflowState(projectDir);
16544
- const workflowStatePresent = state !== null;
16545
- for (const stale of deps.findStaleBranches?.() ?? []) {
16546
- const where = stale.feature_id ? ` ${stale.feature_id}/${stale.story_id}` : "";
16547
- findings.push({
16548
- id: `stale-${stale.kind}`,
16549
- severity: "warn",
16550
- message: `Stale ${stale.kind}${where} "${stale.slug}"${stale.branch ? ` (branch ${stale.branch})` : ""}: ${stale.reason}.`,
16551
- 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"
16552
- });
16553
- }
16554
- if (!workflowStatePresent) {
16555
- findings.push({
16556
- id: "no-state-file",
16557
- severity: "fail",
16558
- message: "No .lakebase/workflow-state.json. Either the project pre-dates the SCM workflow or scaffold did not seed it.",
16559
- suggestion: "lakebase-scm-adopt-state"
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
- try {
16563
- const ownerRepo = await getOwnerRepo(projectDir);
16564
- if (ownerRepo) {
16565
- const enabled = await getActionsEnabled(ownerRepo);
16566
- if (enabled === false) {
16567
- findings.push({
16568
- id: "github-actions-disabled",
16569
- severity: "warn",
16570
- 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.`,
16571
- 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."
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
- } catch {
16339
+ if (!dryRun && outcome !== "unchanged") {
16340
+ fs29.writeFileSync(projectPath2, desired);
16341
+ }
16342
+ files.push({ name, outcome });
16576
16343
  }
16577
- for (const wf of ["pr.yml", "merge.yml"]) {
16578
- try {
16579
- const p = path29.join(projectDir, ".github", "workflows", wf);
16580
- if (!fs29.existsSync(p)) continue;
16581
- const body = fs29.readFileSync(p, "utf8");
16582
- if (/lakebase-scm-utils#v\d/.test(body)) {
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
- } catch {
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
- if (isGitTracked(projectDir, ".lakebase/workflow-state.json")) {
16595
- findings.push({
16596
- id: "scm-state-git-tracked",
16597
- severity: "warn",
16598
- 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).",
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
- } catch {
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
- if (!env.has("LAKEBASE_PROJECT_ID")) {
16605
- findings.push({
16606
- id: "env-missing-project-id",
16607
- severity: "fail",
16608
- message: ".env does not contain LAKEBASE_PROJECT_ID. The post-checkout hook will exit early; workflow CLIs will need an explicit --instance.",
16609
- suggestion: "Set LAKEBASE_PROJECT_ID=<your project id> in .env"
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
- if (!instance) {
16613
- return finalize({
16614
- projectDir,
16615
- workflowStatePresent,
16616
- state: state ?? void 0,
16617
- findings
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 lakebaseBranches = [];
16492
+ let paired;
16621
16493
  try {
16622
- lakebaseBranches = await listBranches({ instance });
16623
- } catch (err) {
16624
- findings.push({
16625
- id: "lakebase-unreachable",
16626
- severity: "fail",
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 headBranch = await getCurrentBranch({ cwd: projectDir });
16647
- if (state && state.state === "feature-claimed") {
16648
- if (state.branch && headBranch && headBranch !== state.branch) {
16649
- findings.push({
16650
- id: "head-branch-drift",
16651
- severity: "warn",
16652
- message: `workflow says feature-claimed for "${state.branch}", but HEAD is on "${headBranch}".`,
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
- pair = await getBranchByName(sanitized, { instance });
16661
- } catch {
16662
- pair = void 0;
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
- if (state && state.state === "feature-claimed" && state.branch) {
16682
- const envBranchId = env.get("LAKEBASE_BRANCH_ID");
16683
- const sanitized = sanitizeBranchName(state.branch);
16684
- if (envBranchId && envBranchId !== sanitized) {
16685
- findings.push({
16686
- id: "env-branch-drift",
16687
- severity: "warn",
16688
- message: `.env LAKEBASE_BRANCH_ID=${envBranchId} but workflow says ${sanitized}. The post-checkout hook may not have run since the last branch switch.`,
16689
- suggestion: `git checkout '${state.branch}' # re-fires post-checkout`
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
- try {
16706
- const heads = await collapseMigrationHeads({ projectDir, dryRun: true });
16707
- if (heads.headsBefore.length > 1) {
16708
- findings.push({
16709
- id: "multiple-migration-heads",
16710
- severity: "fail",
16711
- 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.`,
16712
- suggestion: "lakebase-collapse-heads"
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
- } catch {
16716
- }
16717
- if (instance && state?.branch) {
16718
- try {
16719
- const localIds = listSchemaMigrations({ projectDir }).map((m) => m.version);
16720
- let orphanRev = null;
16721
- try {
16722
- const status = await schemaMigrationStatus({ instance, branch: state.branch, projectDir });
16723
- if (dbRevisionOrphaned(status.current, localIds)) orphanRev = status.current ?? null;
16724
- } catch (e) {
16725
- orphanRev = parseAlembicMissingRevision(e instanceof Error ? e.message : String(e));
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
- if (orphanRev) {
16728
- findings.push({
16729
- id: "db-ahead-of-code",
16730
- severity: "fail",
16731
- 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".`,
16732
- 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"
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 finalize({
16739
- projectDir,
16740
- workflowStatePresent,
16741
- state: state ?? void 0,
16742
- inferredTierTopology: inferredTopology,
16743
- findings
16744
- });
16676
+ return {
16677
+ state: next,
16678
+ paired,
16679
+ localBranchDeleted,
16680
+ headAfter,
16681
+ migrate,
16682
+ warnings
16683
+ };
16745
16684
  }
16746
- function finalize(report) {
16747
- let worst = "ok";
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
- var ScmDoctorFixError = class extends Error {
16754
- constructor(message, code) {
16755
- super(message);
16756
- this.code = code;
16757
- this.name = "ScmDoctorFixError";
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
- code;
16760
- };
16761
- var FIXABLE_FINDING_IDS = [
16762
- "env-branch-drift",
16763
- "head-branch-drift",
16764
- "tier-topology-mismatch",
16765
- "orphan-current-branch",
16766
- "multiple-migration-heads",
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
- const report = args.report ?? await runDoctor({ projectDir: args.projectDir, instance: args.instance });
16777
- const present = report.findings.find((f) => f.id === args.findingId);
16778
- if (!present) {
16779
- throw new ScmDoctorFixError(
16780
- `Finding "${args.findingId}" is not present in the current report. Re-run lakebase-scm-doctor to see what needs fixing.`,
16781
- "finding-not-present"
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
- let action = "";
16765
+ }
16766
+ async function hasRemoteBranch(args) {
16767
+ const { cwd, branch, remote = "origin" } = args;
16785
16768
  try {
16786
- switch (args.findingId) {
16787
- case "env-branch-drift": {
16788
- const branch = report.state?.branch;
16789
- if (!branch) {
16790
- throw new ScmDoctorFixError(
16791
- "Cannot fix: workflow state has no branch field.",
16792
- "fix-failed"
16793
- );
16794
- }
16795
- const sanitized = sanitizeBranchName(branch);
16796
- const envFile = path29.join(args.projectDir, ".env");
16797
- updateEnvConnection({
16798
- envPath: envFile,
16799
- projectId: readEnvVar(envFile, "LAKEBASE_PROJECT_ID") ?? "",
16800
- branchId: sanitized,
16801
- username: readEnvVar(envFile, "DB_USERNAME") ?? "",
16802
- endpointHost: readEnvVar(envFile, "LAKEBASE_HOST")
16803
- });
16804
- action = `rewrote .env LAKEBASE_BRANCH_ID=${sanitized} (metadata only; the app mints its token at runtime)`;
16805
- break;
16806
- }
16807
- case "head-branch-drift": {
16808
- const branch = report.state?.branch;
16809
- if (!branch) {
16810
- throw new ScmDoctorFixError(
16811
- "Cannot fix: workflow state has no branch field.",
16812
- "fix-failed"
16813
- );
16814
- }
16815
- await exec2(`git checkout ${shellEscape3(branch)}`, {
16816
- cwd: args.projectDir,
16817
- timeout: 15e3
16818
- });
16819
- action = `git checkout ${branch} (re-fires post-checkout to resync HEAD)`;
16820
- break;
16821
- }
16822
- case "tier-topology-mismatch": {
16823
- const instance = args.instance ?? report.state?.project_id;
16824
- if (!instance) {
16825
- throw new ScmDoctorFixError(
16826
- "Cannot fix: missing Lakebase project id.",
16827
- "fix-failed"
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
- } catch (err) {
16880
- if (err instanceof ScmDoctorFixError) throw err;
16881
- throw new ScmDoctorFixError(
16882
- `Remediation failed: ${err instanceof Error ? err.message : String(err)}`,
16883
- "fix-failed"
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 postReport = await runDoctor({
16887
- projectDir: args.projectDir,
16888
- instance: args.instance
16889
- });
16890
- return { findingId: args.findingId, action, postReport };
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 shellEscape3(s) {
16893
- return `'${s.replace(/'/g, "'\\''")}'`;
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
- function findKitTemplatesDir(start) {
16907
- let dir = start;
16908
- for (let i = 0; i < 6; i++) {
16909
- const candidate = path30.join(
16910
- dir,
16911
- "templates",
16912
- "project",
16913
- "common",
16914
- ".github",
16915
- "workflows"
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
- throw new Error(
16923
- `Could not locate templates/project/common/.github/workflows/ relative to ${start}. Pass explicit kitDir.`
16924
- );
16935
+ return out;
16925
16936
  }
16926
- function unifiedDiff(name, projectContent, templateContent) {
16927
- if (projectContent === templateContent) return "";
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 detectWorkflowDrift(args) {
16942
- const projectWorkflowsDir = path30.join(
16943
- args.projectDir,
16944
- ".github",
16945
- "workflows"
16946
- );
16947
- const here = path30.dirname(new URL(import.meta.url).pathname);
16948
- const kitWorkflowsDir = args.kitDir ? path30.join(
16949
- args.kitDir,
16950
- "templates",
16951
- "project",
16952
- "common",
16953
- ".github",
16954
- "workflows"
16955
- ) : findKitTemplatesDir(here);
16956
- const templateFiles = fs30.existsSync(kitWorkflowsDir) ? fs30.readdirSync(kitWorkflowsDir).filter((f) => f.endsWith(".yml")) : [];
16957
- const projectFiles = fs30.existsSync(projectWorkflowsDir) ? fs30.readdirSync(projectWorkflowsDir).filter((f) => f.endsWith(".yml")) : [];
16958
- const version = readKitVersion(kitWorkflowsDir);
16959
- const seen = /* @__PURE__ */ new Set();
16960
- const files = [];
16961
- for (const name of templateFiles) {
16962
- seen.add(name);
16963
- const projectPath2 = path30.join(projectWorkflowsDir, name);
16964
- const templatePath = path30.join(kitWorkflowsDir, name);
16965
- if (!fs30.existsSync(projectPath2)) {
16966
- files.push({ name, status: "missing" });
16967
- continue;
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
- const projectContent = fs30.readFileSync(projectPath2, "utf8");
16970
- const templateContent = applyPlaceholders(fs30.readFileSync(templatePath, "utf8"), version);
16971
- if (projectContent === templateContent) {
16972
- files.push({ name, status: "unchanged" });
16973
- } else {
16974
- files.push({
16975
- name,
16976
- status: "drifted",
16977
- diff: unifiedDiff(name, projectContent, templateContent)
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
- for (const name of projectFiles) {
16982
- if (seen.has(name)) continue;
16983
- files.push({ name, status: "extra" });
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
- const order = {
16986
- drifted: 0,
16987
- missing: 1,
16988
- extra: 2,
16989
- unchanged: 3
16990
- };
16991
- files.sort((a, b) => order[a.status] - order[b.status] || a.name.localeCompare(b.name));
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
- const raw = fs30.readFileSync(path30.join(dir, "package.json"), "utf-8");
17005
- const pkg = JSON.parse(raw);
17006
- return typeof pkg.version === "string" ? pkg.version : "unknown";
17007
- } catch {
17008
- return "unknown";
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
- function applyPlaceholders(content, version) {
17012
- return content.replace(/\{\{LAKEBASE_SCM_UTILS_VERSION\}\}/g, version);
17013
- }
17014
- function applyCommandPlaceholders(content, version) {
17015
- return content.replace(/\$\{KIT_VERSION_AT_SCAFFOLD\}/g, version);
17016
- }
17017
- var COMMAND_HOOK_FILE_PATTERN = /\.(pre|post)-hook\.md$/;
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
- throw new Error(
17035
- `Could not locate templates/project/common/.claude/commands/ relative to ${start}. Pass explicit kitDir.`
17036
- );
17037
- }
17038
- function parsePinnedVersion(content) {
17039
- const m = content.match(/^\s*[*_>`\s]*pinned\s+to\s*:\s*[`*_]*([^\s`*_]+)[`*_]*\s*$/im);
17040
- return m ? m[1] : void 0;
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
- for (const name of projectFiles) {
17082
- if (seen.has(name)) continue;
17083
- files.push({ name, status: "extra", kit_version: kitVersion });
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
- const order = {
17086
- drifted: 0,
17087
- missing: 1,
17088
- extra: 2,
17089
- unchanged: 3
17090
- };
17091
- files.sort((a, b) => order[a.status] - order[b.status] || a.name.localeCompare(b.name));
17092
- const hasDrift = files.some((f) => f.status === "drifted" || f.status === "missing");
17093
- return { overall: hasDrift ? "drift" : "ok", files };
17094
- }
17095
- function readKitVersionFromCommandsDir(kitCommandsDir) {
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 raw = fs30.readFileSync(path30.join(dir, "package.json"), "utf-8");
17102
- const pkg = JSON.parse(raw);
17103
- return typeof pkg.version === "string" ? pkg.version : "unknown";
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 detectScaffoldedDrift(args) {
17109
- const workflows = detectWorkflowDrift(args);
17110
- const commands = detectCommandDrift(args);
17111
- return {
17112
- overall: workflows.overall === "drift" || commands.overall === "drift" ? "drift" : "ok",
17113
- workflows,
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
- function updateWorkflows(args) {
17118
- const projectWorkflowsDir = path30.join(
17119
- args.projectDir,
17120
- ".github",
17121
- "workflows"
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
- const version = substitute ? readKitVersion(kitWorkflowsDir) : "";
17141
- const seen = /* @__PURE__ */ new Set();
17142
- const files = [];
17143
- for (const name of templateFiles) {
17144
- seen.add(name);
17145
- const projectPath2 = path30.join(projectWorkflowsDir, name);
17146
- const templatePath = path30.join(kitWorkflowsDir, name);
17147
- const templateRaw = fs30.readFileSync(templatePath, "utf-8");
17148
- const desired = substitute ? applyPlaceholders(templateRaw, version) : templateRaw;
17149
- const existed = fs30.existsSync(projectPath2);
17150
- const current = existed ? fs30.readFileSync(projectPath2, "utf-8") : "";
17151
- let outcome;
17152
- if (!existed) {
17153
- outcome = "added";
17154
- } else if (current === desired) {
17155
- outcome = "unchanged";
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
- if (pruneExtras) {
17165
- for (const name of projectFiles) {
17166
- if (seen.has(name)) continue;
17167
- const projectPath2 = path30.join(projectWorkflowsDir, name);
17168
- if (!dryRun) {
17169
- fs30.unlinkSync(projectPath2);
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 order = {
17175
- added: 0,
17176
- updated: 1,
17177
- removed: 2,
17178
- unchanged: 3
17179
- };
17180
- files.sort((a, b) => order[a.outcome] - order[b.outcome] || a.name.localeCompare(b.name));
17181
- const changed = files.some((f) => f.outcome !== "unchanged");
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: "Inspect via the lakebase_workflow_drift MCP tool (or detectWorkflowDrift import). Refresh manually until updateWorkflows lands."
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", scopeName, keyName, "--string-value", pat], {
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,