@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
@@ -79021,6 +79021,7 @@ __export(scripts_exports, {
79021
79021
  createWorktree: () => createWorktree,
79022
79022
  cutBackup: () => cutBackup,
79023
79023
  databricksAuthPrereqMessage: () => databricksAuthPrereqMessage,
79024
+ defaultRefreshPromoteWorkflows: () => defaultRefreshPromoteWorkflows,
79024
79025
  delay: () => delay,
79025
79026
  deleteAppEndpoint: () => deleteAppEndpoint,
79026
79027
  deleteBranch: () => deleteBranch,
@@ -86478,16 +86479,33 @@ stdout: ${stdout}` : killed || signal ? `
86478
86479
  }
86479
86480
  async function runDatabricks(args, opts = {}) {
86480
86481
  const { argv, env, profile } = buildInvocation(args, opts);
86482
+ const timeout = opts.timeout ?? KIT_TIMEOUTS.cliDefault;
86481
86483
  try {
86482
- const { stdout } = await execFileP("databricks", argv, {
86483
- env,
86484
- timeout: opts.timeout ?? KIT_TIMEOUTS.cliDefault
86485
- });
86484
+ if (opts.input !== void 0) {
86485
+ return await execDatabricksWithStdin(argv, opts.input, env, timeout);
86486
+ }
86487
+ const { stdout } = await execFileP("databricks", argv, { env, timeout });
86486
86488
  return stdout.toString();
86487
86489
  } catch (err) {
86488
86490
  throw classifyDatabricksError(err, argv, profile);
86489
86491
  }
86490
86492
  }
86493
+ function execDatabricksWithStdin(argv, input, env, timeout) {
86494
+ return new Promise((resolve2, reject) => {
86495
+ const child = (0, import_node_child_process3.execFile)("databricks", argv, { env, timeout }, (err, stdout, stderr) => {
86496
+ if (err) {
86497
+ err.stdout = String(stdout ?? "");
86498
+ err.stderr = String(stderr ?? "");
86499
+ reject(err);
86500
+ return;
86501
+ }
86502
+ resolve2(String(stdout ?? ""));
86503
+ });
86504
+ child.stdin?.on("error", () => {
86505
+ });
86506
+ child.stdin?.end(input);
86507
+ });
86508
+ }
86491
86509
  function runDatabricksSync(args, opts = {}) {
86492
86510
  const { argv, env, profile } = buildInvocation(args, opts);
86493
86511
  try {
@@ -88899,8 +88917,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
88899
88917
  var cached;
88900
88918
  function substrateSelfVersion() {
88901
88919
  if (cached !== void 0) return cached;
88902
- if ("0.2.43".length > 0) {
88903
- cached = "0.2.43";
88920
+ if ("0.2.45".length > 0) {
88921
+ cached = "0.2.45";
88904
88922
  return cached;
88905
88923
  }
88906
88924
  cached = "unknown";
@@ -92166,8 +92184,20 @@ async function setupRunner(args) {
92166
92184
  if (needsConfig) {
92167
92185
  report("Registering runner with GitHub...");
92168
92186
  const regToken = await createRegistrationToken(args.fullRepoName);
92169
- cp4.execSync(
92170
- `./config.sh --url "https://github.com/${args.fullRepoName}" --token "${regToken}" --name "${name}" --labels self-hosted --unattended --replace`,
92187
+ cp4.execFileSync(
92188
+ "./config.sh",
92189
+ [
92190
+ "--url",
92191
+ `https://github.com/${args.fullRepoName}`,
92192
+ "--token",
92193
+ regToken,
92194
+ "--name",
92195
+ name,
92196
+ "--labels",
92197
+ "self-hosted",
92198
+ "--unattended",
92199
+ "--replace"
92200
+ ],
92171
92201
  { cwd: dir, timeout: KIT_TIMEOUTS.cliLong }
92172
92202
  );
92173
92203
  }
@@ -95189,1197 +95219,1289 @@ function pickRunUrl(pr) {
95189
95219
 
95190
95220
  // scripts/lakebase/scm-merge.ts
95191
95221
  init_cjs_shims();
95192
- async function reconcileTierToOrigin(args) {
95193
- const { cwd, tier } = args;
95222
+
95223
+ // scripts/git/sync.ts
95224
+ init_cjs_shims();
95225
+ async function currentBranchName(cwd) {
95194
95226
  try {
95195
- const head = await getCurrentBranch({ cwd });
95196
- if (head === tier) {
95197
- await exec2(`git pull --ff-only`, { cwd, timeout: 3e4 });
95198
- } else {
95199
- await exec2(`git fetch origin ${shellEscape2(`${tier}:${tier}`)}`, { cwd, timeout: 3e4 });
95200
- }
95201
- return null;
95202
- } catch (err) {
95203
- 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).`;
95227
+ return await exec2("git rev-parse --abbrev-ref HEAD", { cwd });
95228
+ } catch {
95229
+ return "";
95204
95230
  }
95205
95231
  }
95206
- var ScmMergeError = class extends Error {
95207
- constructor(message, code) {
95208
- super(message);
95209
- this.code = code;
95210
- this.name = "ScmMergeError";
95211
- }
95212
- code;
95213
- };
95214
- var DEFAULT_MIGRATE_TIMEOUT_MS = 30 * 60 * 1e3;
95215
- var DEFAULT_MIGRATE_POLL_MS = 30 * 1e3;
95216
- function defaultMigratePredicate(run, mergedAt) {
95217
- if (!run.createdAt) return false;
95218
- const created = Date.parse(run.createdAt);
95219
- if (!Number.isFinite(created)) return false;
95220
- if (run.event && run.event !== "push") return false;
95221
- return created >= mergedAt.getTime() - 5e3;
95232
+ async function push(args) {
95233
+ await exec2("git push", { cwd: args.cwd });
95222
95234
  }
95223
- function shaMigratePredicate(mergeCommitSha) {
95224
- return (run) => {
95225
- if (run.event && run.event !== "push") return false;
95226
- return !!run.headSha && run.headSha === mergeCommitSha;
95227
- };
95235
+ async function pull(args) {
95236
+ await exec2("git pull", { cwd: args.cwd });
95228
95237
  }
95229
- async function mergeFeature(args) {
95230
- const current = readWorkflowState(args.projectDir);
95231
- if (!current) {
95232
- throw new ScmMergeError(
95233
- "No SCM workflow state. wait-ci first.",
95234
- "no-state-file"
95235
- );
95236
- }
95237
- if (current.state !== "ci-green") {
95238
- throw new ScmMergeError(
95239
- `merge refuses state "${current.state}". Allowed predecessor: ci-green.`,
95240
- "bad-precondition"
95241
- );
95238
+ async function publishBranch(args) {
95239
+ const remote = args.remote ?? "origin";
95240
+ const branch = await currentBranchName(args.cwd);
95241
+ if (!branch) throw new Error("No current branch");
95242
+ await exec2(`git push -u ${remote} ${shq(branch)}`, {
95243
+ cwd: args.cwd
95244
+ });
95245
+ }
95246
+ async function pushCurrentBranchForPr(args) {
95247
+ const remote = args.remote ?? "origin";
95248
+ const branch = await currentBranchName(args.cwd);
95249
+ if (!branch) throw new Error("No current branch");
95250
+ const upstreamSet = await hasUpstream({ cwd: args.cwd });
95251
+ if (!upstreamSet) {
95252
+ await exec2(`git push -u ${remote} ${shq(branch)}`, {
95253
+ cwd: args.cwd
95254
+ });
95255
+ } else {
95256
+ await exec2("git push", { cwd: args.cwd });
95242
95257
  }
95243
- if (!current.pr_url) {
95244
- throw new ScmMergeError(
95245
- "ci-green row is missing pr_url; cannot resolve the PR to merge.",
95246
- "no-pr-url"
95258
+ }
95259
+ async function fetch3(args) {
95260
+ const parts = ["git fetch"];
95261
+ if (args.prune) parts.push("--prune");
95262
+ if (args.all) parts.push("--all");
95263
+ await exec2(parts.join(" "), { cwd: args.cwd });
95264
+ }
95265
+ async function pullFrom(args) {
95266
+ await exec2(`git pull ${shq(args.remote)} ${shq(args.branch)}`, {
95267
+ cwd: args.cwd
95268
+ });
95269
+ }
95270
+ async function pushTo(args) {
95271
+ await exec2(`git push ${shq(args.remote)} ${shq(args.branch)}`, {
95272
+ cwd: args.cwd
95273
+ });
95274
+ }
95275
+ async function sync(args) {
95276
+ await exec2("git pull", { cwd: args.cwd });
95277
+ await exec2("git push", { cwd: args.cwd });
95278
+ }
95279
+
95280
+ // scripts/lakebase/workflow-drift.ts
95281
+ init_cjs_shims();
95282
+ var fs30 = __toESM(require("fs"), 1);
95283
+ var path28 = __toESM(require("path"), 1);
95284
+ function findKitTemplatesDir(start) {
95285
+ let dir = start;
95286
+ for (let i2 = 0; i2 < 6; i2++) {
95287
+ const candidate = path28.join(
95288
+ dir,
95289
+ "templates",
95290
+ "project",
95291
+ "common",
95292
+ ".github",
95293
+ "workflows"
95247
95294
  );
95295
+ if (fs30.existsSync(candidate)) return candidate;
95296
+ const parent = path28.dirname(dir);
95297
+ if (parent === dir) break;
95298
+ dir = parent;
95248
95299
  }
95249
- if (!current.branch || !current.parent_branch) {
95250
- throw new ScmMergeError(
95251
- "ci-green row missing branch / parent_branch; refusing to merge.",
95252
- "bad-precondition"
95253
- );
95300
+ throw new Error(
95301
+ `Could not locate templates/project/common/.github/workflows/ relative to ${start}. Pass explicit kitDir.`
95302
+ );
95303
+ }
95304
+ function unifiedDiff(name, projectContent, templateContent) {
95305
+ if (projectContent === templateContent) return "";
95306
+ const a = projectContent.split("\n");
95307
+ const b = templateContent.split("\n");
95308
+ const out = [`--- project/${name}`, `+++ template/${name}`];
95309
+ const max = Math.max(a.length, b.length);
95310
+ for (let i2 = 0; i2 < max; i2++) {
95311
+ const av = a[i2];
95312
+ const bv = b[i2];
95313
+ if (av === bv) continue;
95314
+ if (av !== void 0) out.push(`-${i2 + 1}: ${av}`);
95315
+ if (bv !== void 0) out.push(`+${i2 + 1}: ${bv}`);
95254
95316
  }
95255
- const ownerRepo = await getOwnerRepo(args.projectDir);
95256
- if (!ownerRepo) {
95257
- throw new ScmMergeError(
95258
- "No GitHub remote found at origin.",
95259
- "no-github-remote"
95260
- );
95317
+ return out.join("\n");
95318
+ }
95319
+ function detectWorkflowDrift(args) {
95320
+ const projectWorkflowsDir = path28.join(
95321
+ args.projectDir,
95322
+ ".github",
95323
+ "workflows"
95324
+ );
95325
+ const here = path28.dirname(new URL(importMetaUrl).pathname);
95326
+ const kitWorkflowsDir = args.kitDir ? path28.join(
95327
+ args.kitDir,
95328
+ "templates",
95329
+ "project",
95330
+ "common",
95331
+ ".github",
95332
+ "workflows"
95333
+ ) : findKitTemplatesDir(here);
95334
+ const templateFiles = fs30.existsSync(kitWorkflowsDir) ? fs30.readdirSync(kitWorkflowsDir).filter((f3) => f3.endsWith(".yml")) : [];
95335
+ const projectFiles = fs30.existsSync(projectWorkflowsDir) ? fs30.readdirSync(projectWorkflowsDir).filter((f3) => f3.endsWith(".yml")) : [];
95336
+ const version = readKitVersion(kitWorkflowsDir);
95337
+ const seen = /* @__PURE__ */ new Set();
95338
+ const files = [];
95339
+ for (const name of templateFiles) {
95340
+ seen.add(name);
95341
+ const projectPath2 = path28.join(projectWorkflowsDir, name);
95342
+ const templatePath = path28.join(kitWorkflowsDir, name);
95343
+ if (!fs30.existsSync(projectPath2)) {
95344
+ files.push({ name, status: "missing" });
95345
+ continue;
95346
+ }
95347
+ const projectContent = fs30.readFileSync(projectPath2, "utf8");
95348
+ const templateContent = applyPlaceholders(fs30.readFileSync(templatePath, "utf8"), version);
95349
+ if (projectContent === templateContent) {
95350
+ files.push({ name, status: "unchanged" });
95351
+ } else {
95352
+ files.push({
95353
+ name,
95354
+ status: "drifted",
95355
+ diff: unifiedDiff(name, projectContent, templateContent)
95356
+ });
95357
+ }
95261
95358
  }
95262
- const pullNumber = extractPullNumber(current.pr_url);
95263
- if (!pullNumber) {
95264
- throw new ScmMergeError(
95265
- `Could not extract PR number from URL: ${current.pr_url}`,
95266
- "bad-pr-url"
95267
- );
95359
+ for (const name of projectFiles) {
95360
+ if (seen.has(name)) continue;
95361
+ files.push({ name, status: "extra" });
95268
95362
  }
95269
- const instance = args.instance ?? current.project_id;
95270
- const gitBase = await resolveGitBase(current.parent_branch, args.projectDir);
95271
- const wantMigrate = args.waitMigrate !== false;
95272
- let authVerified = false;
95273
- if (wantMigrate && args.verifyMigrateAuth) {
95274
- const probe = await args.verifyMigrateAuth();
95275
- if (!probe.ok) {
95276
- throw new ScmMergeError(
95277
- `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.`,
95278
- "migrate-auth"
95279
- );
95280
- }
95281
- authVerified = true;
95363
+ const order = {
95364
+ drifted: 0,
95365
+ missing: 1,
95366
+ extra: 2,
95367
+ unchanged: 3
95368
+ };
95369
+ files.sort((a, b) => order[a.status] - order[b.status] || a.name.localeCompare(b.name));
95370
+ const hasDrift = files.some((f3) => f3.status === "drifted" || f3.status === "missing");
95371
+ return {
95372
+ overall: hasDrift ? "drift" : "ok",
95373
+ files
95374
+ };
95375
+ }
95376
+ function readKitVersion(kitWorkflowsDir) {
95377
+ let dir = kitWorkflowsDir;
95378
+ for (let i2 = 0; i2 < 5; i2++) {
95379
+ dir = path28.dirname(dir);
95282
95380
  }
95283
- let paired;
95284
95381
  try {
95285
- paired = await mergePairedPullRequest({
95286
- ownerRepo,
95287
- pullNumber,
95288
- lakebaseInstance: instance,
95289
- method: args.method ?? "squash"
95290
- });
95291
- } catch (err) {
95292
- throw new ScmMergeError(
95293
- `mergePairedPullRequest failed: ${err instanceof Error ? err.message : String(err)}`,
95294
- "merge-failed"
95295
- );
95382
+ const raw = fs30.readFileSync(path28.join(dir, "package.json"), "utf-8");
95383
+ const pkg = JSON.parse(raw);
95384
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
95385
+ } catch {
95386
+ return "unknown";
95296
95387
  }
95297
- const warnings = [...paired.warnings];
95298
- let localBranchDeleted = false;
95299
- let headAfter = current.branch;
95300
- if (!args.skipLocalCleanup) {
95301
- const switchTo = args.switchTo ?? gitBase;
95302
- const head = await getCurrentBranch({ cwd: args.projectDir });
95303
- if (head === current.branch) {
95304
- try {
95305
- await exec2(`git checkout ${shellEscape2(switchTo)}`, {
95306
- cwd: args.projectDir,
95307
- timeout: 1e4
95308
- });
95309
- headAfter = switchTo;
95310
- try {
95311
- await exec2(`git fetch origin ${shellEscape2(switchTo)}`, {
95312
- cwd: args.projectDir,
95313
- timeout: 3e4
95314
- });
95315
- await exec2(`git merge --ff-only ${shellEscape2(`origin/${switchTo}`)}`, {
95316
- cwd: args.projectDir,
95317
- timeout: 1e4
95318
- });
95319
- } catch (err) {
95320
- warnings.push(
95321
- `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\`.`
95322
- );
95323
- }
95324
- } catch (err) {
95325
- warnings.push(
95326
- `git checkout ${switchTo} failed: ${err instanceof Error ? err.message : String(err)}. Local branch was NOT deleted.`
95327
- );
95328
- }
95329
- } else {
95330
- headAfter = head || current.branch;
95331
- const reconcileWarning = await reconcileTierToOrigin({ cwd: args.projectDir, tier: switchTo });
95332
- if (reconcileWarning) warnings.push(reconcileWarning);
95333
- }
95334
- if (headAfter !== current.branch) {
95335
- try {
95336
- await exec2(
95337
- `git branch -D ${shellEscape2(current.branch)}`,
95338
- { cwd: args.projectDir, timeout: 1e4 }
95339
- );
95340
- localBranchDeleted = true;
95341
- } catch (err) {
95342
- warnings.push(
95343
- `git branch -D ${current.branch} failed: ${err instanceof Error ? err.message : String(err)}.`
95344
- );
95345
- }
95346
- }
95347
- }
95348
- const nowFn = args.now ?? (() => /* @__PURE__ */ new Date());
95349
- const mergedAt = nowFn();
95350
- let next = {
95351
- ...current,
95352
- state: "merged",
95353
- merged_at: mergedAt.toISOString()
95354
- };
95355
- writeWorkflowState(args.projectDir, next);
95356
- let migrate;
95357
- const waitMigrate = args.waitMigrate !== false;
95358
- if (waitMigrate) {
95359
- const timeoutMs = args.migrateTimeoutMs ?? DEFAULT_MIGRATE_TIMEOUT_MS;
95360
- const pollMs = args.migratePollMs ?? DEFAULT_MIGRATE_POLL_MS;
95361
- const fetchRuns = args.fetchRuns ?? listWorkflowRuns;
95362
- const predicate = args.migrateRunPredicate ?? (paired.mergeCommitSha ? shaMigratePredicate(paired.mergeCommitSha) : defaultMigratePredicate);
95363
- const elapsedSinceMerge = nowFn().getTime() - mergedAt.getTime();
95364
- const remainingTimeoutMs = Math.max(0, timeoutMs - elapsedSinceMerge);
95365
- let polls = 0;
95366
- let matched;
95367
- let lastSeen;
95368
- try {
95369
- const result = await pollUntil({
95370
- timeoutMs: remainingTimeoutMs,
95371
- intervalMs: pollMs,
95372
- now: nowFn,
95373
- sleep: args.sleep,
95374
- probe: async () => {
95375
- const runs = await fetchRuns(ownerRepo, 20);
95376
- const candidates = runs.filter((r2) => r2.branch === gitBase).filter((r2) => predicate(r2, mergedAt));
95377
- if (candidates.length === 0) {
95378
- return { done: false };
95379
- }
95380
- candidates.sort(
95381
- (a, b) => Date.parse(b.createdAt ?? "0") - Date.parse(a.createdAt ?? "0")
95382
- );
95383
- lastSeen = candidates[0];
95384
- const status = (lastSeen.status ?? "").toLowerCase();
95385
- return status === "completed" ? { done: true, value: lastSeen } : { done: false };
95386
- }
95387
- });
95388
- polls = result.polls;
95389
- if (result.outcome === "done") {
95390
- matched = result.value;
95391
- }
95392
- } catch (err) {
95393
- warnings.push(
95394
- `Downstream migrate poll errored: ${err instanceof Error ? err.message : String(err)}. Treating as advisory.`
95395
- );
95396
- }
95397
- const applyLocalFallback = async (reason) => {
95398
- if (!args.localMigrateFallback) return false;
95399
- const r2 = await args.localMigrateFallback();
95400
- if (!r2.ok) {
95401
- warnings.push(
95402
- `Local-migrate fallback FAILED after ${reason}: ${r2.detail ?? "unknown error"}. git promoted but the parent schema is NOT applied (partial promotion); apply it manually.`
95403
- );
95404
- return false;
95405
- }
95406
- next = { ...next, migrate_completed_at: nowFn().toISOString() };
95407
- writeWorkflowState(args.projectDir, next);
95408
- migrate = { ...migrate ?? { waited: true, polls }, appliedLocally: true, authVerified };
95409
- warnings.push(
95410
- `Downstream migrate did not confirm (${reason}); applied the parent migrations LOCALLY instead. git and Lakebase schema are in sync${r2.detail ? ` (${r2.detail})` : ""}.`
95411
- );
95412
- return true;
95413
- };
95414
- if (matched) {
95415
- const runUrl = workflowRunUrl(ownerRepo, matched);
95416
- const conclusion = (matched.conclusion ?? "").toLowerCase();
95417
- migrate = {
95418
- waited: true,
95419
- runUrl,
95420
- conclusion,
95421
- polls,
95422
- authVerified
95423
- };
95424
- if (conclusion === "success") {
95425
- next = {
95426
- ...next,
95427
- migrate_run_url: runUrl,
95428
- migrate_completed_at: nowFn().toISOString()
95429
- };
95430
- writeWorkflowState(args.projectDir, next);
95431
- } else {
95432
- const applied = await applyLocalFallback(`the downstream migrate finished conclusion=${conclusion} (${runUrl})`);
95433
- if (!applied) {
95434
- throw new ScmMergeError(
95435
- `Downstream migrate workflow finished with conclusion=${conclusion}. Run ${runUrl} for details.`,
95436
- "migrate-failed"
95437
- );
95438
- }
95439
- }
95440
- } else {
95441
- const budgetSec = Math.round(
95442
- (args.migrateTimeoutMs ?? DEFAULT_MIGRATE_TIMEOUT_MS) / 1e3
95443
- );
95444
- const lastStatus = lastSeen?.status ?? "(no matching run)";
95445
- const timeoutFatal = args.migrateTimeoutFatal !== false;
95446
- if (timeoutFatal) {
95447
- migrate = { waited: true, polls, authVerified };
95448
- const applied = await applyLocalFallback(
95449
- `the downstream migrate did not complete within ${budgetSec}s (last seen: ${lastStatus})`
95450
- );
95451
- if (!applied) {
95452
- throw new ScmMergeError(
95453
- `Timed out after ${budgetSec}s waiting for the downstream migrate workflow on "${gitBase}". Last seen status: ${lastStatus}.`,
95454
- "migrate-timeout"
95455
- );
95456
- }
95457
- } else {
95458
- migrate = { waited: true, polls, timedOut: true, authVerified };
95459
- warnings.push(
95460
- `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.`
95461
- );
95462
- }
95463
- }
95464
- } else {
95465
- migrate = { waited: false, polls: 0 };
95466
- }
95467
- return {
95468
- state: next,
95469
- paired,
95470
- localBranchDeleted,
95471
- headAfter,
95472
- migrate,
95473
- warnings
95474
- };
95475
- }
95476
- function workflowRunUrl(ownerRepo, run) {
95477
- return `https://github.com/${ownerRepo}/actions/runs/${run.id}`;
95478
- }
95479
- function extractPullNumber(prUrl) {
95480
- const m2 = prUrl.match(/\/pull\/(\d+)(?:[\/?#].*)?$/);
95481
- if (!m2) return void 0;
95482
- const n = Number.parseInt(m2[1], 10);
95483
- return Number.isFinite(n) ? n : void 0;
95484
95388
  }
95485
- function shellEscape2(s2) {
95486
- return `'${s2.replace(/'/g, "'\\''")}'`;
95389
+ function applyPlaceholders(content, version) {
95390
+ return content.replace(/\{\{LAKEBASE_SCM_UTILS_VERSION\}\}/g, version);
95487
95391
  }
95488
-
95489
- // scripts/lakebase/scm-recover-orphans.ts
95490
- init_cjs_shims();
95491
-
95492
- // scripts/git/branches.ts
95493
- init_cjs_shims();
95494
- async function currentBranchName(cwd) {
95495
- try {
95496
- return await exec2("git rev-parse --abbrev-ref HEAD", { cwd });
95497
- } catch {
95498
- return "";
95499
- }
95392
+ function applyCommandPlaceholders(content, version) {
95393
+ return content.replace(/\$\{KIT_VERSION_AT_SCAFFOLD\}/g, version);
95500
95394
  }
95501
- async function listLocalBranches(args) {
95502
- const { cwd } = args;
95503
- let raw;
95504
- try {
95505
- raw = await exec2(
95506
- 'git branch --format="%(refname:short)|%(upstream:short)|%(upstream:track)"',
95507
- { cwd }
95395
+ var COMMAND_HOOK_FILE_PATTERN = /\.(pre|post)-hook\.md$/;
95396
+ function findKitCommandsDir(start) {
95397
+ let dir = start;
95398
+ for (let i2 = 0; i2 < 6; i2++) {
95399
+ const candidate = path28.join(
95400
+ dir,
95401
+ "templates",
95402
+ "project",
95403
+ "common",
95404
+ ".claude",
95405
+ "commands"
95508
95406
  );
95509
- } catch {
95510
- return [];
95511
- }
95512
- if (!raw) return [];
95513
- const current = await currentBranchName(cwd);
95514
- return raw.split("\n").filter(Boolean).map((line) => {
95515
- const [name, tracking, trackInfo] = line.split("|");
95516
- let ahead = 0;
95517
- let behind = 0;
95518
- if (trackInfo) {
95519
- const aheadMatch = trackInfo.match(/ahead (\d+)/);
95520
- const behindMatch = trackInfo.match(/behind (\d+)/);
95521
- if (aheadMatch) ahead = parseInt(aheadMatch[1], 10);
95522
- if (behindMatch) behind = parseInt(behindMatch[1], 10);
95523
- }
95524
- return {
95525
- name,
95526
- isCurrent: name === current,
95527
- isRemote: false,
95528
- tracking: tracking || void 0,
95529
- ahead,
95530
- behind
95531
- };
95532
- });
95533
- }
95534
- async function listRemoteBranches(args) {
95535
- const { cwd, remote = "origin" } = args;
95536
- try {
95537
- const localBranches = await listLocalBranches({ cwd });
95538
- const localNames = new Set(localBranches.map((b) => b.name));
95539
- const raw = await exec2(`git branch -r --format="%(refname:short)"`, {
95540
- cwd
95541
- });
95542
- if (!raw) return [];
95543
- const remotePrefix = `${remote}/`;
95544
- return raw.split("\n").filter(Boolean).filter((name) => !name.includes("HEAD")).map((name) => {
95545
- const shortName = name.startsWith(remotePrefix) ? name.slice(remotePrefix.length) : name;
95546
- return { fullName: name, shortName };
95547
- }).filter(({ shortName }) => !localNames.has(shortName)).map(({ fullName, shortName }) => ({
95548
- name: shortName,
95549
- isCurrent: false,
95550
- isRemote: true,
95551
- tracking: fullName
95552
- }));
95553
- } catch {
95554
- return [];
95407
+ if (fs30.existsSync(candidate)) return candidate;
95408
+ const parent = path28.dirname(dir);
95409
+ if (parent === dir) break;
95410
+ dir = parent;
95555
95411
  }
95412
+ throw new Error(
95413
+ `Could not locate templates/project/common/.claude/commands/ relative to ${start}. Pass explicit kitDir.`
95414
+ );
95556
95415
  }
95557
- async function hasRemoteBranch(args) {
95558
- const { cwd, branch, remote = "origin" } = args;
95559
- try {
95560
- const out = await exec2(
95561
- `git ls-remote --heads "${remote}" "${branch}"`,
95562
- { cwd }
95563
- );
95564
- return out.trim().length > 0;
95565
- } catch {
95566
- return false;
95567
- }
95416
+ function parsePinnedVersion(content) {
95417
+ const m2 = content.match(/^\s*[*_>`\s]*pinned\s+to\s*:\s*[`*_]*([^\s`*_]+)[`*_]*\s*$/im);
95418
+ return m2 ? m2[1] : void 0;
95568
95419
  }
95569
-
95570
- // scripts/lakebase/scm-recover-orphans.ts
95571
- var ScmRecoverError = class extends Error {
95572
- constructor(message, code) {
95573
- super(message);
95574
- this.code = code;
95575
- this.name = "ScmRecoverError";
95576
- }
95577
- code;
95578
- };
95579
- var TIER_LEAFS = /* @__PURE__ */ new Set(["staging", "dev", "main", "master"]);
95580
- async function recoverOrphans(args) {
95581
- if (!args.instance) {
95582
- throw new ScmRecoverError(
95583
- "Lakebase project id required (--instance / LAKEBASE_PROJECT_ID).",
95584
- "missing-instance"
95585
- );
95586
- }
95587
- const lakebaseBranches = await listBranches({ instance: args.instance });
95588
- const tierTopology = inferTierTopology(lakebaseBranches);
95589
- const lakebaseLeafs = new Set(
95590
- lakebaseBranches.map((b) => leafName2(b))
95591
- );
95592
- const defaultLeaf = leafName2(
95593
- lakebaseBranches.find((b) => b.isDefault === true)
95594
- );
95595
- const gitBranches = await listLocalBranches({ cwd: args.projectDir });
95596
- const orphans = [];
95597
- const skipped = [];
95598
- for (const gb of gitBranches) {
95599
- if (gb.isRemote) continue;
95600
- const name = gb.name;
95601
- if (TIER_LEAFS.has(name)) {
95602
- skipped.push({ gitBranch: name, reason: "tier branch" });
95603
- continue;
95604
- }
95605
- if (defaultLeaf && name === defaultLeaf) {
95606
- skipped.push({ gitBranch: name, reason: "default branch" });
95607
- continue;
95608
- }
95609
- const sanitized = sanitizeBranchName(name);
95610
- if (lakebaseLeafs.has(sanitized)) {
95611
- skipped.push({
95612
- gitBranch: name,
95613
- reason: `paired Lakebase branch "${sanitized}" exists`
95614
- });
95420
+ function detectCommandDrift(args) {
95421
+ const projectCommandsDir = path28.join(args.projectDir, ".claude", "commands");
95422
+ const here = path28.dirname(new URL(importMetaUrl).pathname);
95423
+ const kitCommandsDir = args.kitDir ? path28.join(args.kitDir, "templates", "project", "common", ".claude", "commands") : findKitCommandsDir(here);
95424
+ const kitVersion = readKitVersionFromCommandsDir(kitCommandsDir);
95425
+ const templateFiles = fs30.existsSync(kitCommandsDir) ? fs30.readdirSync(kitCommandsDir).filter((f3) => f3.endsWith(".md") && !COMMAND_HOOK_FILE_PATTERN.test(f3)) : [];
95426
+ const projectFiles = fs30.existsSync(projectCommandsDir) ? fs30.readdirSync(projectCommandsDir).filter((f3) => f3.endsWith(".md") && !COMMAND_HOOK_FILE_PATTERN.test(f3)) : [];
95427
+ const seen = /* @__PURE__ */ new Set();
95428
+ const files = [];
95429
+ for (const name of templateFiles) {
95430
+ seen.add(name);
95431
+ const projectPath2 = path28.join(projectCommandsDir, name);
95432
+ const templatePath = path28.join(kitCommandsDir, name);
95433
+ const templateRaw = fs30.readFileSync(templatePath, "utf8");
95434
+ if (!fs30.existsSync(projectPath2)) {
95435
+ files.push({ name, status: "missing", kit_version: kitVersion });
95615
95436
  continue;
95616
95437
  }
95617
- orphans.push({
95618
- gitBranch: name,
95619
- sanitized,
95620
- isCurrent: gb.isCurrent === true,
95621
- reason: name.startsWith("feature/") ? "feature/<slug> branch with no Lakebase pair" : `non-tier git branch "${name}" with no Lakebase pair`
95622
- });
95623
- }
95624
- const result = {
95625
- tierTopology,
95626
- orphans,
95627
- skipped,
95628
- claimed: []
95629
- };
95630
- if (!args.claim || orphans.length === 0) {
95631
- return result;
95632
- }
95633
- const parentBranch = parentForTopology(tierTopology, defaultLeaf);
95634
- const currentState = readWorkflowState(args.projectDir);
95635
- const candidates = args.onlyBranch ? orphans.filter((o) => o.gitBranch === args.onlyBranch) : orphans;
95636
- if (args.onlyBranch && candidates.length === 0) {
95637
- throw new ScmRecoverError(
95638
- `No orphan found for --only-branch ${args.onlyBranch}.`,
95639
- "claim-conflict"
95640
- );
95641
- }
95642
- const headOrphan = candidates.find((o) => o.isCurrent);
95643
- const stateTargetOrphan = headOrphan ?? candidates[0];
95644
- for (const orphan of candidates) {
95645
- try {
95646
- const paired = await createFeaturePairedBranch({
95647
- instance: args.instance,
95648
- branch: orphan.gitBranch,
95649
- parentBranch,
95650
- cwd: args.projectDir
95651
- // The git branch already exists on disk; the substrate primitive
95652
- // is idempotent on the git side (it'll checkout the existing
95653
- // branch rather than fail) but if the project is not on this
95654
- // branch, we want a no-op git side. Leaving the default true is
95655
- // OK: if the git branch already matches, the checkout is a
95656
- // no-op; if the branch isn't HEAD, the substrate switches to it
95657
- // which is what the user implicitly asked for by including the
95658
- // branch.
95659
- });
95660
- let stateUpdated = false;
95661
- if (orphan === stateTargetOrphan) {
95662
- const next = {
95663
- ...currentState ?? {
95664
- $schema: "./scm-workflow-state.schema.json",
95665
- version: 1,
95666
- state: "scaffold-complete",
95667
- tier_topology: tierTopology,
95668
- project_id: args.instance
95669
- },
95670
- state: "feature-claimed",
95671
- feature_id: orphan.gitBranch.replace(/^feature\//, ""),
95672
- branch: paired.gitBranch,
95673
- parent_branch: parentBranch,
95674
- lakebase_branch_uid: paired.branch.uid,
95675
- claimed_at: (args.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
95676
- pr_url: void 0,
95677
- pushed_at: void 0,
95678
- ci_run_url: void 0,
95679
- ci_green_at: void 0,
95680
- merged_at: void 0
95681
- };
95682
- writeWorkflowState(args.projectDir, next);
95683
- stateUpdated = true;
95684
- result.stateUpdatedFor = orphan.gitBranch;
95685
- }
95686
- result.claimed.push({
95687
- candidate: orphan,
95688
- lakebaseBranchUid: paired.branch.uid,
95689
- stateUpdated,
95690
- warnings: paired.warnings
95438
+ const projectContent = fs30.readFileSync(projectPath2, "utf8");
95439
+ const pinned = parsePinnedVersion(projectContent);
95440
+ const versionForCompare = pinned ?? kitVersion;
95441
+ const templateContent = applyCommandPlaceholders(templateRaw, versionForCompare);
95442
+ if (projectContent === templateContent) {
95443
+ files.push({
95444
+ name,
95445
+ status: "unchanged",
95446
+ pinned_version: pinned,
95447
+ kit_version: kitVersion
95448
+ });
95449
+ } else {
95450
+ files.push({
95451
+ name,
95452
+ status: "drifted",
95453
+ pinned_version: pinned,
95454
+ kit_version: kitVersion,
95455
+ diff: unifiedDiff(name, projectContent, templateContent)
95691
95456
  });
95692
- } catch (err) {
95693
- throw new ScmRecoverError(
95694
- `Substrate claim failed for ${orphan.gitBranch}: ${err instanceof Error ? err.message : String(err)}`,
95695
- "substrate-failure"
95696
- );
95697
95457
  }
95698
95458
  }
95699
- return result;
95700
- }
95701
- function leafName2(b) {
95702
- if (!b) return "";
95703
- return b.name.split("/").pop() ?? b.name;
95704
- }
95705
- function parentForTopology(t2, defaultLeaf) {
95706
- if (t2 === 3) return "dev";
95707
- if (t2 === 2) return "staging";
95708
- return defaultLeaf || "main";
95709
- }
95710
-
95711
- // scripts/lakebase/scm-doctor.ts
95712
- init_cjs_shims();
95713
- var fs30 = __toESM(require("fs"), 1);
95714
- var path28 = __toESM(require("path"), 1);
95715
- var FEATURE_PREFIX = "feature/";
95716
- var TIER_LEAFS2 = DEFAULT_PROTECTED_TIER_NAMES;
95717
- function readEnv(projectDir) {
95718
- const envPath = path28.join(projectDir, ".env");
95719
- const out = /* @__PURE__ */ new Map();
95720
- if (!fs30.existsSync(envPath)) return out;
95721
- const lines = fs30.readFileSync(envPath, "utf8").split("\n");
95722
- for (const line of lines) {
95723
- const m2 = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
95724
- if (m2) out.set(m2[1], m2[2].replace(/^["']|["']$/g, ""));
95459
+ for (const name of projectFiles) {
95460
+ if (seen.has(name)) continue;
95461
+ files.push({ name, status: "extra", kit_version: kitVersion });
95725
95462
  }
95726
- return out;
95463
+ const order = {
95464
+ drifted: 0,
95465
+ missing: 1,
95466
+ extra: 2,
95467
+ unchanged: 3
95468
+ };
95469
+ files.sort((a, b) => order[a.status] - order[b.status] || a.name.localeCompare(b.name));
95470
+ const hasDrift = files.some((f3) => f3.status === "drifted" || f3.status === "missing");
95471
+ return { overall: hasDrift ? "drift" : "ok", files };
95727
95472
  }
95728
- function leafOf2(b) {
95729
- return b.name.split("/").pop() ?? b.name;
95473
+ function readKitVersionFromCommandsDir(kitCommandsDir) {
95474
+ let dir = kitCommandsDir;
95475
+ for (let i2 = 0; i2 < 5; i2++) {
95476
+ dir = path28.dirname(dir);
95477
+ }
95478
+ try {
95479
+ const raw = fs30.readFileSync(path28.join(dir, "package.json"), "utf-8");
95480
+ const pkg = JSON.parse(raw);
95481
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
95482
+ } catch {
95483
+ return "unknown";
95484
+ }
95730
95485
  }
95731
- function worstOf(a, b) {
95732
- const order = ["ok", "warn", "fail"];
95733
- return order[Math.max(order.indexOf(a), order.indexOf(b))];
95486
+ function detectScaffoldedDrift(args) {
95487
+ const workflows = detectWorkflowDrift(args);
95488
+ const commands = detectCommandDrift(args);
95489
+ return {
95490
+ overall: workflows.overall === "drift" || commands.overall === "drift" ? "drift" : "ok",
95491
+ workflows,
95492
+ commands
95493
+ };
95734
95494
  }
95735
- async function runDoctor(args, deps = {}) {
95736
- const projectDir = args.projectDir;
95737
- const findings = [];
95738
- const env = readEnv(projectDir);
95739
- const instance = args.instance ?? env.get("LAKEBASE_PROJECT_ID");
95740
- const state = readWorkflowState(projectDir);
95741
- const workflowStatePresent = state !== null;
95742
- for (const stale of deps.findStaleBranches?.() ?? []) {
95743
- const where = stale.feature_id ? ` ${stale.feature_id}/${stale.story_id}` : "";
95744
- findings.push({
95745
- id: `stale-${stale.kind}`,
95746
- severity: "warn",
95747
- message: `Stale ${stale.kind}${where} "${stale.slug}"${stale.branch ? ` (branch ${stale.branch})` : ""}: ${stale.reason}.`,
95748
- 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"
95749
- });
95750
- }
95751
- if (!workflowStatePresent) {
95752
- findings.push({
95753
- id: "no-state-file",
95754
- severity: "fail",
95755
- message: "No .lakebase/workflow-state.json. Either the project pre-dates the SCM workflow or scaffold did not seed it.",
95756
- suggestion: "lakebase-scm-adopt-state"
95757
- });
95495
+ function updateWorkflows(args) {
95496
+ const projectWorkflowsDir = path28.join(
95497
+ args.projectDir,
95498
+ ".github",
95499
+ "workflows"
95500
+ );
95501
+ const here = path28.dirname(new URL(importMetaUrl).pathname);
95502
+ const kitWorkflowsDir = args.kitDir ? path28.join(
95503
+ args.kitDir,
95504
+ "templates",
95505
+ "project",
95506
+ "common",
95507
+ ".github",
95508
+ "workflows"
95509
+ ) : findKitTemplatesDir(here);
95510
+ const substitute = args.substitute !== false;
95511
+ const dryRun = args.dryRun === true;
95512
+ const pruneExtras = args.pruneExtras === true;
95513
+ const templateFiles = fs30.existsSync(kitWorkflowsDir) ? fs30.readdirSync(kitWorkflowsDir).filter((f3) => f3.endsWith(".yml")) : [];
95514
+ const projectFiles = fs30.existsSync(projectWorkflowsDir) ? fs30.readdirSync(projectWorkflowsDir).filter((f3) => f3.endsWith(".yml")) : [];
95515
+ if (!dryRun && templateFiles.length > 0 && !fs30.existsSync(projectWorkflowsDir)) {
95516
+ fs30.mkdirSync(projectWorkflowsDir, { recursive: true });
95758
95517
  }
95759
- try {
95760
- const ownerRepo = await getOwnerRepo(projectDir);
95761
- if (ownerRepo) {
95762
- const enabled = await getActionsEnabled(ownerRepo);
95763
- if (enabled === false) {
95764
- findings.push({
95765
- id: "github-actions-disabled",
95766
- severity: "warn",
95767
- 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.`,
95768
- 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."
95769
- });
95770
- }
95518
+ const version = substitute ? readKitVersion(kitWorkflowsDir) : "";
95519
+ const seen = /* @__PURE__ */ new Set();
95520
+ const files = [];
95521
+ for (const name of templateFiles) {
95522
+ seen.add(name);
95523
+ const projectPath2 = path28.join(projectWorkflowsDir, name);
95524
+ const templatePath = path28.join(kitWorkflowsDir, name);
95525
+ const templateRaw = fs30.readFileSync(templatePath, "utf-8");
95526
+ const desired = substitute ? applyPlaceholders(templateRaw, version) : templateRaw;
95527
+ const existed = fs30.existsSync(projectPath2);
95528
+ const current = existed ? fs30.readFileSync(projectPath2, "utf-8") : "";
95529
+ let outcome;
95530
+ if (!existed) {
95531
+ outcome = "added";
95532
+ } else if (current === desired) {
95533
+ outcome = "unchanged";
95534
+ } else {
95535
+ outcome = "updated";
95771
95536
  }
95772
- } catch {
95537
+ if (!dryRun && outcome !== "unchanged") {
95538
+ fs30.writeFileSync(projectPath2, desired);
95539
+ }
95540
+ files.push({ name, outcome });
95773
95541
  }
95774
- for (const wf of ["pr.yml", "merge.yml"]) {
95775
- try {
95776
- const p = path28.join(projectDir, ".github", "workflows", wf);
95777
- if (!fs30.existsSync(p)) continue;
95778
- const body = fs30.readFileSync(p, "utf8");
95779
- if (/lakebase-scm-utils#v\d/.test(body)) {
95780
- findings.push({
95781
- id: "ci-workflow-substrate-pin",
95782
- severity: "warn",
95783
- 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.`,
95784
- 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."
95785
- });
95542
+ if (pruneExtras) {
95543
+ for (const name of projectFiles) {
95544
+ if (seen.has(name)) continue;
95545
+ const projectPath2 = path28.join(projectWorkflowsDir, name);
95546
+ if (!dryRun) {
95547
+ fs30.unlinkSync(projectPath2);
95786
95548
  }
95787
- } catch {
95549
+ files.push({ name, outcome: "removed" });
95788
95550
  }
95789
95551
  }
95552
+ const order = {
95553
+ added: 0,
95554
+ updated: 1,
95555
+ removed: 2,
95556
+ unchanged: 3
95557
+ };
95558
+ files.sort((a, b) => order[a.outcome] - order[b.outcome] || a.name.localeCompare(b.name));
95559
+ const changed = files.some((f3) => f3.outcome !== "unchanged");
95560
+ return { files, changed };
95561
+ }
95562
+
95563
+ // scripts/lakebase/scm-merge.ts
95564
+ async function reconcileTierToOrigin(args) {
95565
+ const { cwd, tier } = args;
95790
95566
  try {
95791
- if (isGitTracked(projectDir, ".lakebase/workflow-state.json")) {
95792
- findings.push({
95793
- id: "scm-state-git-tracked",
95794
- severity: "warn",
95795
- 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).",
95796
- 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."
95797
- });
95567
+ const head = await getCurrentBranch({ cwd });
95568
+ if (head === tier) {
95569
+ await exec2(`git pull --ff-only`, { cwd, timeout: 3e4 });
95570
+ } else {
95571
+ await exec2(`git fetch origin ${shellEscape2(`${tier}:${tier}`)}`, { cwd, timeout: 3e4 });
95798
95572
  }
95799
- } catch {
95573
+ return null;
95574
+ } catch (err) {
95575
+ 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).`;
95800
95576
  }
95801
- if (!env.has("LAKEBASE_PROJECT_ID")) {
95802
- findings.push({
95803
- id: "env-missing-project-id",
95804
- severity: "fail",
95805
- message: ".env does not contain LAKEBASE_PROJECT_ID. The post-checkout hook will exit early; workflow CLIs will need an explicit --instance.",
95806
- suggestion: "Set LAKEBASE_PROJECT_ID=<your project id> in .env"
95807
- });
95577
+ }
95578
+ var ScmMergeError = class extends Error {
95579
+ constructor(message, code) {
95580
+ super(message);
95581
+ this.code = code;
95582
+ this.name = "ScmMergeError";
95583
+ }
95584
+ code;
95585
+ };
95586
+ var DEFAULT_MIGRATE_TIMEOUT_MS = 30 * 60 * 1e3;
95587
+ var DEFAULT_MIGRATE_POLL_MS = 30 * 1e3;
95588
+ function defaultMigratePredicate(run, mergedAt) {
95589
+ if (!run.createdAt) return false;
95590
+ const created = Date.parse(run.createdAt);
95591
+ if (!Number.isFinite(created)) return false;
95592
+ if (run.event && run.event !== "push") return false;
95593
+ return created >= mergedAt.getTime() - 5e3;
95594
+ }
95595
+ function shaMigratePredicate(mergeCommitSha) {
95596
+ return (run) => {
95597
+ if (run.event && run.event !== "push") return false;
95598
+ return !!run.headSha && run.headSha === mergeCommitSha;
95599
+ };
95600
+ }
95601
+ async function defaultRefreshPromoteWorkflows(projectDir) {
95602
+ const drift = detectWorkflowDrift({ projectDir });
95603
+ const touched = drift.files.filter((f3) => f3.status === "drifted").map((f3) => f3.name);
95604
+ if (touched.length === 0) return { refreshed: false };
95605
+ const result = updateWorkflows({ projectDir });
95606
+ if (!result.changed) return { refreshed: false };
95607
+ await exec2(`git add ${shellEscape2(".github/workflows")}`, { cwd: projectDir, timeout: 1e4 });
95608
+ const staged = await exec2("git diff --cached --name-only -- .github/workflows", {
95609
+ cwd: projectDir,
95610
+ timeout: 1e4
95611
+ });
95612
+ if (!staged.trim()) return { refreshed: false };
95613
+ await exec2(
95614
+ `git commit -m ${shellEscape2(
95615
+ "chore(ci): refresh scaffolded workflows to pinned substrate (promote uses apply-tier)"
95616
+ )} -- .github/workflows`,
95617
+ { cwd: projectDir, timeout: 15e3 }
95618
+ );
95619
+ await pushCurrentBranchForPr({ cwd: projectDir });
95620
+ return { refreshed: true, detail: `refreshed + pushed ${touched.join(", ") || "workflow(s)"} onto the PR head` };
95621
+ }
95622
+ async function mergeFeature(args) {
95623
+ const current = readWorkflowState(args.projectDir);
95624
+ if (!current) {
95625
+ throw new ScmMergeError(
95626
+ "No SCM workflow state. wait-ci first.",
95627
+ "no-state-file"
95628
+ );
95629
+ }
95630
+ if (current.state !== "ci-green") {
95631
+ throw new ScmMergeError(
95632
+ `merge refuses state "${current.state}". Allowed predecessor: ci-green.`,
95633
+ "bad-precondition"
95634
+ );
95635
+ }
95636
+ if (!current.pr_url) {
95637
+ throw new ScmMergeError(
95638
+ "ci-green row is missing pr_url; cannot resolve the PR to merge.",
95639
+ "no-pr-url"
95640
+ );
95641
+ }
95642
+ if (!current.branch || !current.parent_branch) {
95643
+ throw new ScmMergeError(
95644
+ "ci-green row missing branch / parent_branch; refusing to merge.",
95645
+ "bad-precondition"
95646
+ );
95647
+ }
95648
+ const ownerRepo = await getOwnerRepo(args.projectDir);
95649
+ if (!ownerRepo) {
95650
+ throw new ScmMergeError(
95651
+ "No GitHub remote found at origin.",
95652
+ "no-github-remote"
95653
+ );
95654
+ }
95655
+ const pullNumber = extractPullNumber(current.pr_url);
95656
+ if (!pullNumber) {
95657
+ throw new ScmMergeError(
95658
+ `Could not extract PR number from URL: ${current.pr_url}`,
95659
+ "bad-pr-url"
95660
+ );
95661
+ }
95662
+ const instance = args.instance ?? current.project_id;
95663
+ const gitBase = await resolveGitBase(current.parent_branch, args.projectDir);
95664
+ const wantMigrate = args.waitMigrate !== false;
95665
+ let authVerified = false;
95666
+ if (wantMigrate && args.verifyMigrateAuth) {
95667
+ const probe = await args.verifyMigrateAuth();
95668
+ if (!probe.ok) {
95669
+ throw new ScmMergeError(
95670
+ `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.`,
95671
+ "migrate-auth"
95672
+ );
95673
+ }
95674
+ authVerified = true;
95808
95675
  }
95809
- if (!instance) {
95810
- return finalize({
95811
- projectDir,
95812
- workflowStatePresent,
95813
- state: state ?? void 0,
95814
- findings
95815
- });
95676
+ const preflightNotes = [];
95677
+ {
95678
+ const refresh = args.refreshWorkflows ?? (() => defaultRefreshPromoteWorkflows(args.projectDir));
95679
+ try {
95680
+ const r2 = await refresh();
95681
+ if (r2.refreshed) {
95682
+ preflightNotes.push(`Refreshed stale CI workflows before merge${r2.detail ? ` (${r2.detail})` : ""}.`);
95683
+ }
95684
+ } catch (err) {
95685
+ preflightNotes.push(
95686
+ `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').`
95687
+ );
95688
+ }
95816
95689
  }
95817
- let lakebaseBranches = [];
95690
+ let paired;
95818
95691
  try {
95819
- lakebaseBranches = await listBranches({ instance });
95820
- } catch (err) {
95821
- findings.push({
95822
- id: "lakebase-unreachable",
95823
- severity: "fail",
95824
- message: `Could not list Lakebase branches for instance ${instance}: ${err instanceof Error ? err.message : String(err)}`,
95825
- suggestion: "databricks auth login (or check DATABRICKS_CONFIG_PROFILE)."
95826
- });
95827
- return finalize({
95828
- projectDir,
95829
- workflowStatePresent,
95830
- state: state ?? void 0,
95831
- findings
95832
- });
95833
- }
95834
- const inferredTopology = inferTierTopology(lakebaseBranches);
95835
- if (state && state.tier_topology !== inferredTopology) {
95836
- findings.push({
95837
- id: "tier-topology-mismatch",
95838
- severity: "warn",
95839
- message: `workflow-state records tier_topology=${state.tier_topology}, but the Lakebase tier inventory suggests ${inferredTopology}.`,
95840
- suggestion: "lakebase-scm-adopt-state --force"
95692
+ paired = await mergePairedPullRequest({
95693
+ ownerRepo,
95694
+ pullNumber,
95695
+ lakebaseInstance: instance,
95696
+ method: args.method ?? "squash"
95841
95697
  });
95698
+ } catch (err) {
95699
+ throw new ScmMergeError(
95700
+ `mergePairedPullRequest failed: ${err instanceof Error ? err.message : String(err)}`,
95701
+ "merge-failed"
95702
+ );
95842
95703
  }
95843
- const headBranch = await getCurrentBranch({ cwd: projectDir });
95844
- if (state && state.state === "feature-claimed") {
95845
- if (state.branch && headBranch && headBranch !== state.branch) {
95846
- findings.push({
95847
- id: "head-branch-drift",
95848
- severity: "warn",
95849
- message: `workflow says feature-claimed for "${state.branch}", but HEAD is on "${headBranch}".`,
95850
- suggestion: `git checkout '${state.branch}'`
95851
- });
95852
- }
95853
- if (state.branch) {
95854
- const sanitized = sanitizeBranchName(state.branch);
95855
- let pair;
95704
+ const warnings = [...preflightNotes, ...paired.warnings];
95705
+ let localBranchDeleted = false;
95706
+ let headAfter = current.branch;
95707
+ if (!args.skipLocalCleanup) {
95708
+ const switchTo = args.switchTo ?? gitBase;
95709
+ const head = await getCurrentBranch({ cwd: args.projectDir });
95710
+ if (head === current.branch) {
95856
95711
  try {
95857
- pair = await getBranchByName(sanitized, { instance });
95858
- } catch {
95859
- pair = void 0;
95860
- }
95861
- if (!pair) {
95862
- findings.push({
95863
- id: "lakebase-pair-missing",
95864
- severity: "fail",
95865
- message: `workflow says feature-claimed for "${state.branch}", but no Lakebase branch "${sanitized}" exists.`,
95866
- suggestion: `lakebase-scm-abandon-feature # reset state; re-claim if needed`
95867
- });
95868
- } else if (state.lakebase_branch_uid && pair.uid !== state.lakebase_branch_uid) {
95869
- findings.push({
95870
- id: "lakebase-uid-drift",
95871
- severity: "warn",
95872
- message: `workflow records lakebase_branch_uid=${state.lakebase_branch_uid}, but the live branch reports ${pair.uid}.`,
95873
- suggestion: "lakebase-scm-adopt-state --force"
95712
+ await exec2(`git checkout ${shellEscape2(switchTo)}`, {
95713
+ cwd: args.projectDir,
95714
+ timeout: 1e4
95874
95715
  });
95716
+ headAfter = switchTo;
95717
+ try {
95718
+ await exec2(`git fetch origin ${shellEscape2(switchTo)}`, {
95719
+ cwd: args.projectDir,
95720
+ timeout: 3e4
95721
+ });
95722
+ await exec2(`git merge --ff-only ${shellEscape2(`origin/${switchTo}`)}`, {
95723
+ cwd: args.projectDir,
95724
+ timeout: 1e4
95725
+ });
95726
+ } catch (err) {
95727
+ warnings.push(
95728
+ `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\`.`
95729
+ );
95730
+ }
95731
+ } catch (err) {
95732
+ warnings.push(
95733
+ `git checkout ${switchTo} failed: ${err instanceof Error ? err.message : String(err)}. Local branch was NOT deleted.`
95734
+ );
95875
95735
  }
95736
+ } else {
95737
+ headAfter = head || current.branch;
95738
+ const reconcileWarning = await reconcileTierToOrigin({ cwd: args.projectDir, tier: switchTo });
95739
+ if (reconcileWarning) warnings.push(reconcileWarning);
95876
95740
  }
95877
- }
95878
- if (state && state.state === "feature-claimed" && state.branch) {
95879
- const envBranchId = env.get("LAKEBASE_BRANCH_ID");
95880
- const sanitized = sanitizeBranchName(state.branch);
95881
- if (envBranchId && envBranchId !== sanitized) {
95882
- findings.push({
95883
- id: "env-branch-drift",
95884
- severity: "warn",
95885
- message: `.env LAKEBASE_BRANCH_ID=${envBranchId} but workflow says ${sanitized}. The post-checkout hook may not have run since the last branch switch.`,
95886
- suggestion: `git checkout '${state.branch}' # re-fires post-checkout`
95887
- });
95888
- }
95889
- }
95890
- if (headBranch && !TIER_LEAFS2.has(headBranch) && headBranch.startsWith(FEATURE_PREFIX)) {
95891
- const sanitized = sanitizeBranchName(headBranch);
95892
- const paired = lakebaseBranches.some((b) => leafOf2(b) === sanitized);
95893
- if (!paired) {
95894
- findings.push({
95895
- id: "orphan-current-branch",
95896
- severity: "fail",
95897
- message: `Current git branch "${headBranch}" has no Lakebase pair (post-checkout fallback retired in phase C).`,
95898
- suggestion: `lakebase-scm-recover-orphans --claim --only-branch '${headBranch}'`
95899
- });
95741
+ if (headAfter !== current.branch) {
95742
+ try {
95743
+ await exec2(
95744
+ `git branch -D ${shellEscape2(current.branch)}`,
95745
+ { cwd: args.projectDir, timeout: 1e4 }
95746
+ );
95747
+ localBranchDeleted = true;
95748
+ } catch (err) {
95749
+ warnings.push(
95750
+ `git branch -D ${current.branch} failed: ${err instanceof Error ? err.message : String(err)}.`
95751
+ );
95752
+ }
95900
95753
  }
95901
95754
  }
95902
- try {
95903
- const heads = await collapseMigrationHeads({ projectDir, dryRun: true });
95904
- if (heads.headsBefore.length > 1) {
95905
- findings.push({
95906
- id: "multiple-migration-heads",
95907
- severity: "fail",
95908
- 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.`,
95909
- suggestion: "lakebase-collapse-heads"
95755
+ const nowFn = args.now ?? (() => /* @__PURE__ */ new Date());
95756
+ const mergedAt = nowFn();
95757
+ let next = {
95758
+ ...current,
95759
+ state: "merged",
95760
+ merged_at: mergedAt.toISOString()
95761
+ };
95762
+ writeWorkflowState(args.projectDir, next);
95763
+ let migrate;
95764
+ const waitMigrate = args.waitMigrate !== false;
95765
+ if (waitMigrate) {
95766
+ const timeoutMs = args.migrateTimeoutMs ?? DEFAULT_MIGRATE_TIMEOUT_MS;
95767
+ const pollMs = args.migratePollMs ?? DEFAULT_MIGRATE_POLL_MS;
95768
+ const fetchRuns = args.fetchRuns ?? listWorkflowRuns;
95769
+ const predicate = args.migrateRunPredicate ?? (paired.mergeCommitSha ? shaMigratePredicate(paired.mergeCommitSha) : defaultMigratePredicate);
95770
+ const elapsedSinceMerge = nowFn().getTime() - mergedAt.getTime();
95771
+ const remainingTimeoutMs = Math.max(0, timeoutMs - elapsedSinceMerge);
95772
+ let polls = 0;
95773
+ let matched;
95774
+ let lastSeen;
95775
+ try {
95776
+ const result = await pollUntil({
95777
+ timeoutMs: remainingTimeoutMs,
95778
+ intervalMs: pollMs,
95779
+ now: nowFn,
95780
+ sleep: args.sleep,
95781
+ probe: async () => {
95782
+ const runs = await fetchRuns(ownerRepo, 20);
95783
+ const candidates = runs.filter((r2) => r2.branch === gitBase).filter((r2) => predicate(r2, mergedAt));
95784
+ if (candidates.length === 0) {
95785
+ return { done: false };
95786
+ }
95787
+ candidates.sort(
95788
+ (a, b) => Date.parse(b.createdAt ?? "0") - Date.parse(a.createdAt ?? "0")
95789
+ );
95790
+ lastSeen = candidates[0];
95791
+ const status = (lastSeen.status ?? "").toLowerCase();
95792
+ return status === "completed" ? { done: true, value: lastSeen } : { done: false };
95793
+ }
95910
95794
  });
95795
+ polls = result.polls;
95796
+ if (result.outcome === "done") {
95797
+ matched = result.value;
95798
+ }
95799
+ } catch (err) {
95800
+ warnings.push(
95801
+ `Downstream migrate poll errored: ${err instanceof Error ? err.message : String(err)}. Treating as advisory.`
95802
+ );
95911
95803
  }
95912
- } catch {
95913
- }
95914
- if (instance && state?.branch) {
95915
- try {
95916
- const localIds = listSchemaMigrations({ projectDir }).map((m2) => m2.version);
95917
- let orphanRev = null;
95918
- try {
95919
- const status = await schemaMigrationStatus({ instance, branch: state.branch, projectDir });
95920
- if (dbRevisionOrphaned(status.current, localIds)) orphanRev = status.current ?? null;
95921
- } catch (e2) {
95922
- orphanRev = parseAlembicMissingRevision(e2 instanceof Error ? e2.message : String(e2));
95804
+ const applyLocalFallback = async (reason) => {
95805
+ if (!args.localMigrateFallback) return false;
95806
+ const r2 = await args.localMigrateFallback();
95807
+ if (!r2.ok) {
95808
+ warnings.push(
95809
+ `Local-migrate fallback FAILED after ${reason}: ${r2.detail ?? "unknown error"}. git promoted but the parent schema is NOT applied (partial promotion); apply it manually.`
95810
+ );
95811
+ return false;
95812
+ }
95813
+ next = { ...next, migrate_completed_at: nowFn().toISOString() };
95814
+ writeWorkflowState(args.projectDir, next);
95815
+ migrate = { ...migrate ?? { waited: true, polls }, appliedLocally: true, authVerified };
95816
+ warnings.push(
95817
+ `Downstream migrate did not confirm (${reason}); applied the parent migrations LOCALLY instead. git and Lakebase schema are in sync${r2.detail ? ` (${r2.detail})` : ""}.`
95818
+ );
95819
+ return true;
95820
+ };
95821
+ if (matched) {
95822
+ const runUrl = workflowRunUrl(ownerRepo, matched);
95823
+ const conclusion = (matched.conclusion ?? "").toLowerCase();
95824
+ migrate = {
95825
+ waited: true,
95826
+ runUrl,
95827
+ conclusion,
95828
+ polls,
95829
+ authVerified
95830
+ };
95831
+ if (conclusion === "success") {
95832
+ next = {
95833
+ ...next,
95834
+ migrate_run_url: runUrl,
95835
+ migrate_completed_at: nowFn().toISOString()
95836
+ };
95837
+ writeWorkflowState(args.projectDir, next);
95838
+ } else {
95839
+ const applied = await applyLocalFallback(`the downstream migrate finished conclusion=${conclusion} (${runUrl})`);
95840
+ if (!applied) {
95841
+ throw new ScmMergeError(
95842
+ `Downstream migrate workflow finished with conclusion=${conclusion}. Run ${runUrl} for details.`,
95843
+ "migrate-failed"
95844
+ );
95845
+ }
95923
95846
  }
95924
- if (orphanRev) {
95925
- findings.push({
95926
- id: "db-ahead-of-code",
95927
- severity: "fail",
95928
- 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".`,
95929
- 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"
95930
- });
95847
+ } else {
95848
+ const budgetSec = Math.round(
95849
+ (args.migrateTimeoutMs ?? DEFAULT_MIGRATE_TIMEOUT_MS) / 1e3
95850
+ );
95851
+ const lastStatus = lastSeen?.status ?? "(no matching run)";
95852
+ const timeoutFatal = args.migrateTimeoutFatal !== false;
95853
+ if (timeoutFatal) {
95854
+ migrate = { waited: true, polls, authVerified };
95855
+ const applied = await applyLocalFallback(
95856
+ `the downstream migrate did not complete within ${budgetSec}s (last seen: ${lastStatus})`
95857
+ );
95858
+ if (!applied) {
95859
+ throw new ScmMergeError(
95860
+ `Timed out after ${budgetSec}s waiting for the downstream migrate workflow on "${gitBase}". Last seen status: ${lastStatus}.`,
95861
+ "migrate-timeout"
95862
+ );
95863
+ }
95864
+ } else {
95865
+ migrate = { waited: true, polls, timedOut: true, authVerified };
95866
+ warnings.push(
95867
+ `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.`
95868
+ );
95931
95869
  }
95932
- } catch {
95933
95870
  }
95871
+ } else {
95872
+ migrate = { waited: false, polls: 0 };
95934
95873
  }
95935
- return finalize({
95936
- projectDir,
95937
- workflowStatePresent,
95938
- state: state ?? void 0,
95939
- inferredTierTopology: inferredTopology,
95940
- findings
95941
- });
95874
+ return {
95875
+ state: next,
95876
+ paired,
95877
+ localBranchDeleted,
95878
+ headAfter,
95879
+ migrate,
95880
+ warnings
95881
+ };
95942
95882
  }
95943
- function finalize(report) {
95944
- let worst = "ok";
95945
- for (const f3 of report.findings) {
95946
- worst = worstOf(worst, f3.severity);
95947
- }
95948
- return { ...report, worstSeverity: worst };
95883
+ function workflowRunUrl(ownerRepo, run) {
95884
+ return `https://github.com/${ownerRepo}/actions/runs/${run.id}`;
95949
95885
  }
95950
- var ScmDoctorFixError = class extends Error {
95951
- constructor(message, code) {
95952
- super(message);
95953
- this.code = code;
95954
- this.name = "ScmDoctorFixError";
95886
+ function extractPullNumber(prUrl) {
95887
+ const m2 = prUrl.match(/\/pull\/(\d+)(?:[\/?#].*)?$/);
95888
+ if (!m2) return void 0;
95889
+ const n = Number.parseInt(m2[1], 10);
95890
+ return Number.isFinite(n) ? n : void 0;
95891
+ }
95892
+ function shellEscape2(s2) {
95893
+ return `'${s2.replace(/'/g, "'\\''")}'`;
95894
+ }
95895
+
95896
+ // scripts/lakebase/scm-recover-orphans.ts
95897
+ init_cjs_shims();
95898
+
95899
+ // scripts/git/branches.ts
95900
+ init_cjs_shims();
95901
+ async function currentBranchName2(cwd) {
95902
+ try {
95903
+ return await exec2("git rev-parse --abbrev-ref HEAD", { cwd });
95904
+ } catch {
95905
+ return "";
95955
95906
  }
95956
- code;
95957
- };
95958
- var FIXABLE_FINDING_IDS = [
95959
- "env-branch-drift",
95960
- "head-branch-drift",
95961
- "tier-topology-mismatch",
95962
- "orphan-current-branch",
95963
- "multiple-migration-heads",
95964
- "db-ahead-of-code"
95965
- ];
95966
- async function fixFinding(args) {
95967
- if (!FIXABLE_FINDING_IDS.includes(args.findingId)) {
95968
- throw new ScmDoctorFixError(
95969
- `Finding "${args.findingId}" is not supported by --fix. Supported: ${FIXABLE_FINDING_IDS.join(", ")}.`,
95970
- "unsupported-finding"
95907
+ }
95908
+ async function listLocalBranches(args) {
95909
+ const { cwd } = args;
95910
+ let raw;
95911
+ try {
95912
+ raw = await exec2(
95913
+ 'git branch --format="%(refname:short)|%(upstream:short)|%(upstream:track)"',
95914
+ { cwd }
95971
95915
  );
95916
+ } catch {
95917
+ return [];
95972
95918
  }
95973
- const report = args.report ?? await runDoctor({ projectDir: args.projectDir, instance: args.instance });
95974
- const present = report.findings.find((f3) => f3.id === args.findingId);
95975
- if (!present) {
95976
- throw new ScmDoctorFixError(
95977
- `Finding "${args.findingId}" is not present in the current report. Re-run lakebase-scm-doctor to see what needs fixing.`,
95978
- "finding-not-present"
95979
- );
95919
+ if (!raw) return [];
95920
+ const current = await currentBranchName2(cwd);
95921
+ return raw.split("\n").filter(Boolean).map((line) => {
95922
+ const [name, tracking, trackInfo] = line.split("|");
95923
+ let ahead = 0;
95924
+ let behind = 0;
95925
+ if (trackInfo) {
95926
+ const aheadMatch = trackInfo.match(/ahead (\d+)/);
95927
+ const behindMatch = trackInfo.match(/behind (\d+)/);
95928
+ if (aheadMatch) ahead = parseInt(aheadMatch[1], 10);
95929
+ if (behindMatch) behind = parseInt(behindMatch[1], 10);
95930
+ }
95931
+ return {
95932
+ name,
95933
+ isCurrent: name === current,
95934
+ isRemote: false,
95935
+ tracking: tracking || void 0,
95936
+ ahead,
95937
+ behind
95938
+ };
95939
+ });
95940
+ }
95941
+ async function listRemoteBranches(args) {
95942
+ const { cwd, remote = "origin" } = args;
95943
+ try {
95944
+ const localBranches = await listLocalBranches({ cwd });
95945
+ const localNames = new Set(localBranches.map((b) => b.name));
95946
+ const raw = await exec2(`git branch -r --format="%(refname:short)"`, {
95947
+ cwd
95948
+ });
95949
+ if (!raw) return [];
95950
+ const remotePrefix = `${remote}/`;
95951
+ return raw.split("\n").filter(Boolean).filter((name) => !name.includes("HEAD")).map((name) => {
95952
+ const shortName = name.startsWith(remotePrefix) ? name.slice(remotePrefix.length) : name;
95953
+ return { fullName: name, shortName };
95954
+ }).filter(({ shortName }) => !localNames.has(shortName)).map(({ fullName, shortName }) => ({
95955
+ name: shortName,
95956
+ isCurrent: false,
95957
+ isRemote: true,
95958
+ tracking: fullName
95959
+ }));
95960
+ } catch {
95961
+ return [];
95980
95962
  }
95981
- let action = "";
95963
+ }
95964
+ async function hasRemoteBranch(args) {
95965
+ const { cwd, branch, remote = "origin" } = args;
95982
95966
  try {
95983
- switch (args.findingId) {
95984
- case "env-branch-drift": {
95985
- const branch = report.state?.branch;
95986
- if (!branch) {
95987
- throw new ScmDoctorFixError(
95988
- "Cannot fix: workflow state has no branch field.",
95989
- "fix-failed"
95990
- );
95991
- }
95992
- const sanitized = sanitizeBranchName(branch);
95993
- const envFile = path28.join(args.projectDir, ".env");
95994
- updateEnvConnection({
95995
- envPath: envFile,
95996
- projectId: readEnvVar(envFile, "LAKEBASE_PROJECT_ID") ?? "",
95997
- branchId: sanitized,
95998
- username: readEnvVar(envFile, "DB_USERNAME") ?? "",
95999
- endpointHost: readEnvVar(envFile, "LAKEBASE_HOST")
96000
- });
96001
- action = `rewrote .env LAKEBASE_BRANCH_ID=${sanitized} (metadata only; the app mints its token at runtime)`;
96002
- break;
96003
- }
96004
- case "head-branch-drift": {
96005
- const branch = report.state?.branch;
96006
- if (!branch) {
96007
- throw new ScmDoctorFixError(
96008
- "Cannot fix: workflow state has no branch field.",
96009
- "fix-failed"
96010
- );
96011
- }
96012
- await exec2(`git checkout ${shellEscape3(branch)}`, {
96013
- cwd: args.projectDir,
96014
- timeout: 15e3
96015
- });
96016
- action = `git checkout ${branch} (re-fires post-checkout to resync HEAD)`;
96017
- break;
96018
- }
96019
- case "tier-topology-mismatch": {
96020
- const instance = args.instance ?? report.state?.project_id;
96021
- if (!instance) {
96022
- throw new ScmDoctorFixError(
96023
- "Cannot fix: missing Lakebase project id.",
96024
- "fix-failed"
96025
- );
96026
- }
96027
- await adoptScmState({
96028
- projectDir: args.projectDir,
96029
- instance,
96030
- force: true
96031
- });
96032
- action = `adopted state with --force to re-infer tier_topology`;
96033
- break;
96034
- }
96035
- case "orphan-current-branch": {
96036
- const instance = args.instance ?? report.state?.project_id;
96037
- if (!instance) {
96038
- throw new ScmDoctorFixError(
96039
- "Cannot fix: missing Lakebase project id.",
96040
- "fix-failed"
96041
- );
96042
- }
96043
- const headBranch = await getCurrentBranch({ cwd: args.projectDir });
96044
- if (!headBranch) {
96045
- throw new ScmDoctorFixError(
96046
- "Cannot fix: detached HEAD or no current branch.",
96047
- "fix-failed"
96048
- );
96049
- }
96050
- await recoverOrphans({
96051
- projectDir: args.projectDir,
96052
- instance,
96053
- claim: true,
96054
- onlyBranch: headBranch
96055
- });
96056
- action = `recovered orphan ${headBranch} via createFeaturePairedBranch`;
96057
- break;
96058
- }
96059
- case "multiple-migration-heads": {
96060
- const r2 = await collapseMigrationHeads({ projectDir: args.projectDir });
96061
- if (r2.status !== "ok" || !r2.mergeRevision) {
96062
- throw new ScmDoctorFixError(
96063
- `Expected to create a merge revision but got status="${r2.status}".`,
96064
- "fix-failed"
96065
- );
96066
- }
96067
- action = `collapsed ${r2.headsBefore.length} heads into merge revision ${r2.mergeRevision} (commit it)`;
96068
- break;
96069
- }
96070
- case "db-ahead-of-code": {
96071
- const r2 = await abandonFeatureBranch({ projectDir: args.projectDir, instance: args.instance, force: true });
96072
- action = `abandoned the feature (deleted the polluted paired branch${r2.lakebaseDeleted ? "" : " , Lakebase delete reported not-deleted"}) and reset state to '${r2.state.state}'; re-claim to re-fork a clean branch from the tier`;
96073
- break;
96074
- }
95967
+ const out = await exec2(
95968
+ `git ls-remote --heads "${remote}" "${branch}"`,
95969
+ { cwd }
95970
+ );
95971
+ return out.trim().length > 0;
95972
+ } catch {
95973
+ return false;
95974
+ }
95975
+ }
95976
+
95977
+ // scripts/lakebase/scm-recover-orphans.ts
95978
+ var ScmRecoverError = class extends Error {
95979
+ constructor(message, code) {
95980
+ super(message);
95981
+ this.code = code;
95982
+ this.name = "ScmRecoverError";
95983
+ }
95984
+ code;
95985
+ };
95986
+ var TIER_LEAFS = /* @__PURE__ */ new Set(["staging", "dev", "main", "master"]);
95987
+ async function recoverOrphans(args) {
95988
+ if (!args.instance) {
95989
+ throw new ScmRecoverError(
95990
+ "Lakebase project id required (--instance / LAKEBASE_PROJECT_ID).",
95991
+ "missing-instance"
95992
+ );
95993
+ }
95994
+ const lakebaseBranches = await listBranches({ instance: args.instance });
95995
+ const tierTopology = inferTierTopology(lakebaseBranches);
95996
+ const lakebaseLeafs = new Set(
95997
+ lakebaseBranches.map((b) => leafName2(b))
95998
+ );
95999
+ const defaultLeaf = leafName2(
96000
+ lakebaseBranches.find((b) => b.isDefault === true)
96001
+ );
96002
+ const gitBranches = await listLocalBranches({ cwd: args.projectDir });
96003
+ const orphans = [];
96004
+ const skipped = [];
96005
+ for (const gb of gitBranches) {
96006
+ if (gb.isRemote) continue;
96007
+ const name = gb.name;
96008
+ if (TIER_LEAFS.has(name)) {
96009
+ skipped.push({ gitBranch: name, reason: "tier branch" });
96010
+ continue;
96075
96011
  }
96076
- } catch (err) {
96077
- if (err instanceof ScmDoctorFixError) throw err;
96078
- throw new ScmDoctorFixError(
96079
- `Remediation failed: ${err instanceof Error ? err.message : String(err)}`,
96080
- "fix-failed"
96012
+ if (defaultLeaf && name === defaultLeaf) {
96013
+ skipped.push({ gitBranch: name, reason: "default branch" });
96014
+ continue;
96015
+ }
96016
+ const sanitized = sanitizeBranchName(name);
96017
+ if (lakebaseLeafs.has(sanitized)) {
96018
+ skipped.push({
96019
+ gitBranch: name,
96020
+ reason: `paired Lakebase branch "${sanitized}" exists`
96021
+ });
96022
+ continue;
96023
+ }
96024
+ orphans.push({
96025
+ gitBranch: name,
96026
+ sanitized,
96027
+ isCurrent: gb.isCurrent === true,
96028
+ reason: name.startsWith("feature/") ? "feature/<slug> branch with no Lakebase pair" : `non-tier git branch "${name}" with no Lakebase pair`
96029
+ });
96030
+ }
96031
+ const result = {
96032
+ tierTopology,
96033
+ orphans,
96034
+ skipped,
96035
+ claimed: []
96036
+ };
96037
+ if (!args.claim || orphans.length === 0) {
96038
+ return result;
96039
+ }
96040
+ const parentBranch = parentForTopology(tierTopology, defaultLeaf);
96041
+ const currentState = readWorkflowState(args.projectDir);
96042
+ const candidates = args.onlyBranch ? orphans.filter((o) => o.gitBranch === args.onlyBranch) : orphans;
96043
+ if (args.onlyBranch && candidates.length === 0) {
96044
+ throw new ScmRecoverError(
96045
+ `No orphan found for --only-branch ${args.onlyBranch}.`,
96046
+ "claim-conflict"
96081
96047
  );
96082
96048
  }
96083
- const postReport = await runDoctor({
96084
- projectDir: args.projectDir,
96085
- instance: args.instance
96086
- });
96087
- return { findingId: args.findingId, action, postReport };
96049
+ const headOrphan = candidates.find((o) => o.isCurrent);
96050
+ const stateTargetOrphan = headOrphan ?? candidates[0];
96051
+ for (const orphan of candidates) {
96052
+ try {
96053
+ const paired = await createFeaturePairedBranch({
96054
+ instance: args.instance,
96055
+ branch: orphan.gitBranch,
96056
+ parentBranch,
96057
+ cwd: args.projectDir
96058
+ // The git branch already exists on disk; the substrate primitive
96059
+ // is idempotent on the git side (it'll checkout the existing
96060
+ // branch rather than fail) but if the project is not on this
96061
+ // branch, we want a no-op git side. Leaving the default true is
96062
+ // OK: if the git branch already matches, the checkout is a
96063
+ // no-op; if the branch isn't HEAD, the substrate switches to it
96064
+ // which is what the user implicitly asked for by including the
96065
+ // branch.
96066
+ });
96067
+ let stateUpdated = false;
96068
+ if (orphan === stateTargetOrphan) {
96069
+ const next = {
96070
+ ...currentState ?? {
96071
+ $schema: "./scm-workflow-state.schema.json",
96072
+ version: 1,
96073
+ state: "scaffold-complete",
96074
+ tier_topology: tierTopology,
96075
+ project_id: args.instance
96076
+ },
96077
+ state: "feature-claimed",
96078
+ feature_id: orphan.gitBranch.replace(/^feature\//, ""),
96079
+ branch: paired.gitBranch,
96080
+ parent_branch: parentBranch,
96081
+ lakebase_branch_uid: paired.branch.uid,
96082
+ claimed_at: (args.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
96083
+ pr_url: void 0,
96084
+ pushed_at: void 0,
96085
+ ci_run_url: void 0,
96086
+ ci_green_at: void 0,
96087
+ merged_at: void 0
96088
+ };
96089
+ writeWorkflowState(args.projectDir, next);
96090
+ stateUpdated = true;
96091
+ result.stateUpdatedFor = orphan.gitBranch;
96092
+ }
96093
+ result.claimed.push({
96094
+ candidate: orphan,
96095
+ lakebaseBranchUid: paired.branch.uid,
96096
+ stateUpdated,
96097
+ warnings: paired.warnings
96098
+ });
96099
+ } catch (err) {
96100
+ throw new ScmRecoverError(
96101
+ `Substrate claim failed for ${orphan.gitBranch}: ${err instanceof Error ? err.message : String(err)}`,
96102
+ "substrate-failure"
96103
+ );
96104
+ }
96105
+ }
96106
+ return result;
96088
96107
  }
96089
- function shellEscape3(s2) {
96090
- return `'${s2.replace(/'/g, "'\\''")}'`;
96108
+ function leafName2(b) {
96109
+ if (!b) return "";
96110
+ return b.name.split("/").pop() ?? b.name;
96111
+ }
96112
+ function parentForTopology(t2, defaultLeaf) {
96113
+ if (t2 === 3) return "dev";
96114
+ if (t2 === 2) return "staging";
96115
+ return defaultLeaf || "main";
96091
96116
  }
96092
96117
 
96093
- // scripts/lakebase/doctor.ts
96094
- init_cjs_shims();
96095
- var fs32 = __toESM(require("fs"), 1);
96096
- var path30 = __toESM(require("path"), 1);
96097
- var cp6 = __toESM(require("child_process"), 1);
96098
-
96099
- // scripts/lakebase/workflow-drift.ts
96118
+ // scripts/lakebase/scm-doctor.ts
96100
96119
  init_cjs_shims();
96101
96120
  var fs31 = __toESM(require("fs"), 1);
96102
96121
  var path29 = __toESM(require("path"), 1);
96103
- function findKitTemplatesDir(start) {
96104
- let dir = start;
96105
- for (let i2 = 0; i2 < 6; i2++) {
96106
- const candidate = path29.join(
96107
- dir,
96108
- "templates",
96109
- "project",
96110
- "common",
96111
- ".github",
96112
- "workflows"
96113
- );
96114
- if (fs31.existsSync(candidate)) return candidate;
96115
- const parent = path29.dirname(dir);
96116
- if (parent === dir) break;
96117
- dir = parent;
96122
+ var FEATURE_PREFIX = "feature/";
96123
+ var TIER_LEAFS2 = DEFAULT_PROTECTED_TIER_NAMES;
96124
+ function readEnv(projectDir) {
96125
+ const envPath = path29.join(projectDir, ".env");
96126
+ const out = /* @__PURE__ */ new Map();
96127
+ if (!fs31.existsSync(envPath)) return out;
96128
+ const lines = fs31.readFileSync(envPath, "utf8").split("\n");
96129
+ for (const line of lines) {
96130
+ const m2 = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
96131
+ if (m2) out.set(m2[1], m2[2].replace(/^["']|["']$/g, ""));
96118
96132
  }
96119
- throw new Error(
96120
- `Could not locate templates/project/common/.github/workflows/ relative to ${start}. Pass explicit kitDir.`
96121
- );
96133
+ return out;
96122
96134
  }
96123
- function unifiedDiff(name, projectContent, templateContent) {
96124
- if (projectContent === templateContent) return "";
96125
- const a = projectContent.split("\n");
96126
- const b = templateContent.split("\n");
96127
- const out = [`--- project/${name}`, `+++ template/${name}`];
96128
- const max = Math.max(a.length, b.length);
96129
- for (let i2 = 0; i2 < max; i2++) {
96130
- const av = a[i2];
96131
- const bv = b[i2];
96132
- if (av === bv) continue;
96133
- if (av !== void 0) out.push(`-${i2 + 1}: ${av}`);
96134
- if (bv !== void 0) out.push(`+${i2 + 1}: ${bv}`);
96135
- }
96136
- return out.join("\n");
96135
+ function leafOf2(b) {
96136
+ return b.name.split("/").pop() ?? b.name;
96137
96137
  }
96138
- function detectWorkflowDrift(args) {
96139
- const projectWorkflowsDir = path29.join(
96140
- args.projectDir,
96141
- ".github",
96142
- "workflows"
96143
- );
96144
- const here = path29.dirname(new URL(importMetaUrl).pathname);
96145
- const kitWorkflowsDir = args.kitDir ? path29.join(
96146
- args.kitDir,
96147
- "templates",
96148
- "project",
96149
- "common",
96150
- ".github",
96151
- "workflows"
96152
- ) : findKitTemplatesDir(here);
96153
- const templateFiles = fs31.existsSync(kitWorkflowsDir) ? fs31.readdirSync(kitWorkflowsDir).filter((f3) => f3.endsWith(".yml")) : [];
96154
- const projectFiles = fs31.existsSync(projectWorkflowsDir) ? fs31.readdirSync(projectWorkflowsDir).filter((f3) => f3.endsWith(".yml")) : [];
96155
- const version = readKitVersion(kitWorkflowsDir);
96156
- const seen = /* @__PURE__ */ new Set();
96157
- const files = [];
96158
- for (const name of templateFiles) {
96159
- seen.add(name);
96160
- const projectPath2 = path29.join(projectWorkflowsDir, name);
96161
- const templatePath = path29.join(kitWorkflowsDir, name);
96162
- if (!fs31.existsSync(projectPath2)) {
96163
- files.push({ name, status: "missing" });
96164
- continue;
96138
+ function worstOf(a, b) {
96139
+ const order = ["ok", "warn", "fail"];
96140
+ return order[Math.max(order.indexOf(a), order.indexOf(b))];
96141
+ }
96142
+ async function runDoctor(args, deps = {}) {
96143
+ const projectDir = args.projectDir;
96144
+ const findings = [];
96145
+ const env = readEnv(projectDir);
96146
+ const instance = args.instance ?? env.get("LAKEBASE_PROJECT_ID");
96147
+ const state = readWorkflowState(projectDir);
96148
+ const workflowStatePresent = state !== null;
96149
+ for (const stale of deps.findStaleBranches?.() ?? []) {
96150
+ const where = stale.feature_id ? ` ${stale.feature_id}/${stale.story_id}` : "";
96151
+ findings.push({
96152
+ id: `stale-${stale.kind}`,
96153
+ severity: "warn",
96154
+ message: `Stale ${stale.kind}${where} "${stale.slug}"${stale.branch ? ` (branch ${stale.branch})` : ""}: ${stale.reason}.`,
96155
+ 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"
96156
+ });
96157
+ }
96158
+ if (!workflowStatePresent) {
96159
+ findings.push({
96160
+ id: "no-state-file",
96161
+ severity: "fail",
96162
+ message: "No .lakebase/workflow-state.json. Either the project pre-dates the SCM workflow or scaffold did not seed it.",
96163
+ suggestion: "lakebase-scm-adopt-state"
96164
+ });
96165
+ }
96166
+ try {
96167
+ const ownerRepo = await getOwnerRepo(projectDir);
96168
+ if (ownerRepo) {
96169
+ const enabled = await getActionsEnabled(ownerRepo);
96170
+ if (enabled === false) {
96171
+ findings.push({
96172
+ id: "github-actions-disabled",
96173
+ severity: "warn",
96174
+ 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.`,
96175
+ 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."
96176
+ });
96177
+ }
96165
96178
  }
96166
- const projectContent = fs31.readFileSync(projectPath2, "utf8");
96167
- const templateContent = applyPlaceholders(fs31.readFileSync(templatePath, "utf8"), version);
96168
- if (projectContent === templateContent) {
96169
- files.push({ name, status: "unchanged" });
96170
- } else {
96171
- files.push({
96172
- name,
96173
- status: "drifted",
96174
- diff: unifiedDiff(name, projectContent, templateContent)
96179
+ } catch {
96180
+ }
96181
+ for (const wf of ["pr.yml", "merge.yml"]) {
96182
+ try {
96183
+ const p = path29.join(projectDir, ".github", "workflows", wf);
96184
+ if (!fs31.existsSync(p)) continue;
96185
+ const body = fs31.readFileSync(p, "utf8");
96186
+ if (/lakebase-scm-utils#v\d/.test(body)) {
96187
+ findings.push({
96188
+ id: "ci-workflow-substrate-pin",
96189
+ severity: "warn",
96190
+ 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.`,
96191
+ 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."
96192
+ });
96193
+ }
96194
+ } catch {
96195
+ }
96196
+ }
96197
+ try {
96198
+ if (isGitTracked(projectDir, ".lakebase/workflow-state.json")) {
96199
+ findings.push({
96200
+ id: "scm-state-git-tracked",
96201
+ severity: "warn",
96202
+ 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).",
96203
+ 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."
96175
96204
  });
96176
96205
  }
96206
+ } catch {
96177
96207
  }
96178
- for (const name of projectFiles) {
96179
- if (seen.has(name)) continue;
96180
- files.push({ name, status: "extra" });
96208
+ if (!env.has("LAKEBASE_PROJECT_ID")) {
96209
+ findings.push({
96210
+ id: "env-missing-project-id",
96211
+ severity: "fail",
96212
+ message: ".env does not contain LAKEBASE_PROJECT_ID. The post-checkout hook will exit early; workflow CLIs will need an explicit --instance.",
96213
+ suggestion: "Set LAKEBASE_PROJECT_ID=<your project id> in .env"
96214
+ });
96181
96215
  }
96182
- const order = {
96183
- drifted: 0,
96184
- missing: 1,
96185
- extra: 2,
96186
- unchanged: 3
96187
- };
96188
- files.sort((a, b) => order[a.status] - order[b.status] || a.name.localeCompare(b.name));
96189
- const hasDrift = files.some((f3) => f3.status === "drifted" || f3.status === "missing");
96190
- return {
96191
- overall: hasDrift ? "drift" : "ok",
96192
- files
96193
- };
96194
- }
96195
- function readKitVersion(kitWorkflowsDir) {
96196
- let dir = kitWorkflowsDir;
96197
- for (let i2 = 0; i2 < 5; i2++) {
96198
- dir = path29.dirname(dir);
96216
+ if (!instance) {
96217
+ return finalize({
96218
+ projectDir,
96219
+ workflowStatePresent,
96220
+ state: state ?? void 0,
96221
+ findings
96222
+ });
96199
96223
  }
96224
+ let lakebaseBranches = [];
96200
96225
  try {
96201
- const raw = fs31.readFileSync(path29.join(dir, "package.json"), "utf-8");
96202
- const pkg = JSON.parse(raw);
96203
- return typeof pkg.version === "string" ? pkg.version : "unknown";
96204
- } catch {
96205
- return "unknown";
96226
+ lakebaseBranches = await listBranches({ instance });
96227
+ } catch (err) {
96228
+ findings.push({
96229
+ id: "lakebase-unreachable",
96230
+ severity: "fail",
96231
+ message: `Could not list Lakebase branches for instance ${instance}: ${err instanceof Error ? err.message : String(err)}`,
96232
+ suggestion: "databricks auth login (or check DATABRICKS_CONFIG_PROFILE)."
96233
+ });
96234
+ return finalize({
96235
+ projectDir,
96236
+ workflowStatePresent,
96237
+ state: state ?? void 0,
96238
+ findings
96239
+ });
96206
96240
  }
96207
- }
96208
- function applyPlaceholders(content, version) {
96209
- return content.replace(/\{\{LAKEBASE_SCM_UTILS_VERSION\}\}/g, version);
96210
- }
96211
- function applyCommandPlaceholders(content, version) {
96212
- return content.replace(/\$\{KIT_VERSION_AT_SCAFFOLD\}/g, version);
96213
- }
96214
- var COMMAND_HOOK_FILE_PATTERN = /\.(pre|post)-hook\.md$/;
96215
- function findKitCommandsDir(start) {
96216
- let dir = start;
96217
- for (let i2 = 0; i2 < 6; i2++) {
96218
- const candidate = path29.join(
96219
- dir,
96220
- "templates",
96221
- "project",
96222
- "common",
96223
- ".claude",
96224
- "commands"
96225
- );
96226
- if (fs31.existsSync(candidate)) return candidate;
96227
- const parent = path29.dirname(dir);
96228
- if (parent === dir) break;
96229
- dir = parent;
96241
+ const inferredTopology = inferTierTopology(lakebaseBranches);
96242
+ if (state && state.tier_topology !== inferredTopology) {
96243
+ findings.push({
96244
+ id: "tier-topology-mismatch",
96245
+ severity: "warn",
96246
+ message: `workflow-state records tier_topology=${state.tier_topology}, but the Lakebase tier inventory suggests ${inferredTopology}.`,
96247
+ suggestion: "lakebase-scm-adopt-state --force"
96248
+ });
96230
96249
  }
96231
- throw new Error(
96232
- `Could not locate templates/project/common/.claude/commands/ relative to ${start}. Pass explicit kitDir.`
96233
- );
96234
- }
96235
- function parsePinnedVersion(content) {
96236
- const m2 = content.match(/^\s*[*_>`\s]*pinned\s+to\s*:\s*[`*_]*([^\s`*_]+)[`*_]*\s*$/im);
96237
- return m2 ? m2[1] : void 0;
96238
- }
96239
- function detectCommandDrift(args) {
96240
- const projectCommandsDir = path29.join(args.projectDir, ".claude", "commands");
96241
- const here = path29.dirname(new URL(importMetaUrl).pathname);
96242
- const kitCommandsDir = args.kitDir ? path29.join(args.kitDir, "templates", "project", "common", ".claude", "commands") : findKitCommandsDir(here);
96243
- const kitVersion = readKitVersionFromCommandsDir(kitCommandsDir);
96244
- const templateFiles = fs31.existsSync(kitCommandsDir) ? fs31.readdirSync(kitCommandsDir).filter((f3) => f3.endsWith(".md") && !COMMAND_HOOK_FILE_PATTERN.test(f3)) : [];
96245
- const projectFiles = fs31.existsSync(projectCommandsDir) ? fs31.readdirSync(projectCommandsDir).filter((f3) => f3.endsWith(".md") && !COMMAND_HOOK_FILE_PATTERN.test(f3)) : [];
96246
- const seen = /* @__PURE__ */ new Set();
96247
- const files = [];
96248
- for (const name of templateFiles) {
96249
- seen.add(name);
96250
- const projectPath2 = path29.join(projectCommandsDir, name);
96251
- const templatePath = path29.join(kitCommandsDir, name);
96252
- const templateRaw = fs31.readFileSync(templatePath, "utf8");
96253
- if (!fs31.existsSync(projectPath2)) {
96254
- files.push({ name, status: "missing", kit_version: kitVersion });
96255
- continue;
96256
- }
96257
- const projectContent = fs31.readFileSync(projectPath2, "utf8");
96258
- const pinned = parsePinnedVersion(projectContent);
96259
- const versionForCompare = pinned ?? kitVersion;
96260
- const templateContent = applyCommandPlaceholders(templateRaw, versionForCompare);
96261
- if (projectContent === templateContent) {
96262
- files.push({
96263
- name,
96264
- status: "unchanged",
96265
- pinned_version: pinned,
96266
- kit_version: kitVersion
96267
- });
96268
- } else {
96269
- files.push({
96270
- name,
96271
- status: "drifted",
96272
- pinned_version: pinned,
96273
- kit_version: kitVersion,
96274
- diff: unifiedDiff(name, projectContent, templateContent)
96250
+ const headBranch = await getCurrentBranch({ cwd: projectDir });
96251
+ if (state && state.state === "feature-claimed") {
96252
+ if (state.branch && headBranch && headBranch !== state.branch) {
96253
+ findings.push({
96254
+ id: "head-branch-drift",
96255
+ severity: "warn",
96256
+ message: `workflow says feature-claimed for "${state.branch}", but HEAD is on "${headBranch}".`,
96257
+ suggestion: `git checkout '${state.branch}'`
96275
96258
  });
96276
96259
  }
96260
+ if (state.branch) {
96261
+ const sanitized = sanitizeBranchName(state.branch);
96262
+ let pair;
96263
+ try {
96264
+ pair = await getBranchByName(sanitized, { instance });
96265
+ } catch {
96266
+ pair = void 0;
96267
+ }
96268
+ if (!pair) {
96269
+ findings.push({
96270
+ id: "lakebase-pair-missing",
96271
+ severity: "fail",
96272
+ message: `workflow says feature-claimed for "${state.branch}", but no Lakebase branch "${sanitized}" exists.`,
96273
+ suggestion: `lakebase-scm-abandon-feature # reset state; re-claim if needed`
96274
+ });
96275
+ } else if (state.lakebase_branch_uid && pair.uid !== state.lakebase_branch_uid) {
96276
+ findings.push({
96277
+ id: "lakebase-uid-drift",
96278
+ severity: "warn",
96279
+ message: `workflow records lakebase_branch_uid=${state.lakebase_branch_uid}, but the live branch reports ${pair.uid}.`,
96280
+ suggestion: "lakebase-scm-adopt-state --force"
96281
+ });
96282
+ }
96283
+ }
96277
96284
  }
96278
- for (const name of projectFiles) {
96279
- if (seen.has(name)) continue;
96280
- files.push({ name, status: "extra", kit_version: kitVersion });
96285
+ if (state && state.state === "feature-claimed" && state.branch) {
96286
+ const envBranchId = env.get("LAKEBASE_BRANCH_ID");
96287
+ const sanitized = sanitizeBranchName(state.branch);
96288
+ if (envBranchId && envBranchId !== sanitized) {
96289
+ findings.push({
96290
+ id: "env-branch-drift",
96291
+ severity: "warn",
96292
+ message: `.env LAKEBASE_BRANCH_ID=${envBranchId} but workflow says ${sanitized}. The post-checkout hook may not have run since the last branch switch.`,
96293
+ suggestion: `git checkout '${state.branch}' # re-fires post-checkout`
96294
+ });
96295
+ }
96281
96296
  }
96282
- const order = {
96283
- drifted: 0,
96284
- missing: 1,
96285
- extra: 2,
96286
- unchanged: 3
96287
- };
96288
- files.sort((a, b) => order[a.status] - order[b.status] || a.name.localeCompare(b.name));
96289
- const hasDrift = files.some((f3) => f3.status === "drifted" || f3.status === "missing");
96290
- return { overall: hasDrift ? "drift" : "ok", files };
96291
- }
96292
- function readKitVersionFromCommandsDir(kitCommandsDir) {
96293
- let dir = kitCommandsDir;
96294
- for (let i2 = 0; i2 < 5; i2++) {
96295
- dir = path29.dirname(dir);
96297
+ if (headBranch && !TIER_LEAFS2.has(headBranch) && headBranch.startsWith(FEATURE_PREFIX)) {
96298
+ const sanitized = sanitizeBranchName(headBranch);
96299
+ const paired = lakebaseBranches.some((b) => leafOf2(b) === sanitized);
96300
+ if (!paired) {
96301
+ findings.push({
96302
+ id: "orphan-current-branch",
96303
+ severity: "fail",
96304
+ message: `Current git branch "${headBranch}" has no Lakebase pair (post-checkout fallback retired in phase C).`,
96305
+ suggestion: `lakebase-scm-recover-orphans --claim --only-branch '${headBranch}'`
96306
+ });
96307
+ }
96296
96308
  }
96297
96309
  try {
96298
- const raw = fs31.readFileSync(path29.join(dir, "package.json"), "utf-8");
96299
- const pkg = JSON.parse(raw);
96300
- return typeof pkg.version === "string" ? pkg.version : "unknown";
96310
+ const heads = await collapseMigrationHeads({ projectDir, dryRun: true });
96311
+ if (heads.headsBefore.length > 1) {
96312
+ findings.push({
96313
+ id: "multiple-migration-heads",
96314
+ severity: "fail",
96315
+ 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.`,
96316
+ suggestion: "lakebase-collapse-heads"
96317
+ });
96318
+ }
96301
96319
  } catch {
96302
- return "unknown";
96303
96320
  }
96321
+ if (instance && state?.branch) {
96322
+ try {
96323
+ const localIds = listSchemaMigrations({ projectDir }).map((m2) => m2.version);
96324
+ let orphanRev = null;
96325
+ try {
96326
+ const status = await schemaMigrationStatus({ instance, branch: state.branch, projectDir });
96327
+ if (dbRevisionOrphaned(status.current, localIds)) orphanRev = status.current ?? null;
96328
+ } catch (e2) {
96329
+ orphanRev = parseAlembicMissingRevision(e2 instanceof Error ? e2.message : String(e2));
96330
+ }
96331
+ if (orphanRev) {
96332
+ findings.push({
96333
+ id: "db-ahead-of-code",
96334
+ severity: "fail",
96335
+ 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".`,
96336
+ 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"
96337
+ });
96338
+ }
96339
+ } catch {
96340
+ }
96341
+ }
96342
+ return finalize({
96343
+ projectDir,
96344
+ workflowStatePresent,
96345
+ state: state ?? void 0,
96346
+ inferredTierTopology: inferredTopology,
96347
+ findings
96348
+ });
96304
96349
  }
96305
- function detectScaffoldedDrift(args) {
96306
- const workflows = detectWorkflowDrift(args);
96307
- const commands = detectCommandDrift(args);
96308
- return {
96309
- overall: workflows.overall === "drift" || commands.overall === "drift" ? "drift" : "ok",
96310
- workflows,
96311
- commands
96312
- };
96350
+ function finalize(report) {
96351
+ let worst = "ok";
96352
+ for (const f3 of report.findings) {
96353
+ worst = worstOf(worst, f3.severity);
96354
+ }
96355
+ return { ...report, worstSeverity: worst };
96313
96356
  }
96314
- function updateWorkflows(args) {
96315
- const projectWorkflowsDir = path29.join(
96316
- args.projectDir,
96317
- ".github",
96318
- "workflows"
96319
- );
96320
- const here = path29.dirname(new URL(importMetaUrl).pathname);
96321
- const kitWorkflowsDir = args.kitDir ? path29.join(
96322
- args.kitDir,
96323
- "templates",
96324
- "project",
96325
- "common",
96326
- ".github",
96327
- "workflows"
96328
- ) : findKitTemplatesDir(here);
96329
- const substitute = args.substitute !== false;
96330
- const dryRun = args.dryRun === true;
96331
- const pruneExtras = args.pruneExtras === true;
96332
- const templateFiles = fs31.existsSync(kitWorkflowsDir) ? fs31.readdirSync(kitWorkflowsDir).filter((f3) => f3.endsWith(".yml")) : [];
96333
- const projectFiles = fs31.existsSync(projectWorkflowsDir) ? fs31.readdirSync(projectWorkflowsDir).filter((f3) => f3.endsWith(".yml")) : [];
96334
- if (!dryRun && templateFiles.length > 0 && !fs31.existsSync(projectWorkflowsDir)) {
96335
- fs31.mkdirSync(projectWorkflowsDir, { recursive: true });
96357
+ var ScmDoctorFixError = class extends Error {
96358
+ constructor(message, code) {
96359
+ super(message);
96360
+ this.code = code;
96361
+ this.name = "ScmDoctorFixError";
96336
96362
  }
96337
- const version = substitute ? readKitVersion(kitWorkflowsDir) : "";
96338
- const seen = /* @__PURE__ */ new Set();
96339
- const files = [];
96340
- for (const name of templateFiles) {
96341
- seen.add(name);
96342
- const projectPath2 = path29.join(projectWorkflowsDir, name);
96343
- const templatePath = path29.join(kitWorkflowsDir, name);
96344
- const templateRaw = fs31.readFileSync(templatePath, "utf-8");
96345
- const desired = substitute ? applyPlaceholders(templateRaw, version) : templateRaw;
96346
- const existed = fs31.existsSync(projectPath2);
96347
- const current = existed ? fs31.readFileSync(projectPath2, "utf-8") : "";
96348
- let outcome;
96349
- if (!existed) {
96350
- outcome = "added";
96351
- } else if (current === desired) {
96352
- outcome = "unchanged";
96353
- } else {
96354
- outcome = "updated";
96355
- }
96356
- if (!dryRun && outcome !== "unchanged") {
96357
- fs31.writeFileSync(projectPath2, desired);
96358
- }
96359
- files.push({ name, outcome });
96363
+ code;
96364
+ };
96365
+ var FIXABLE_FINDING_IDS = [
96366
+ "env-branch-drift",
96367
+ "head-branch-drift",
96368
+ "tier-topology-mismatch",
96369
+ "orphan-current-branch",
96370
+ "multiple-migration-heads",
96371
+ "db-ahead-of-code"
96372
+ ];
96373
+ async function fixFinding(args) {
96374
+ if (!FIXABLE_FINDING_IDS.includes(args.findingId)) {
96375
+ throw new ScmDoctorFixError(
96376
+ `Finding "${args.findingId}" is not supported by --fix. Supported: ${FIXABLE_FINDING_IDS.join(", ")}.`,
96377
+ "unsupported-finding"
96378
+ );
96360
96379
  }
96361
- if (pruneExtras) {
96362
- for (const name of projectFiles) {
96363
- if (seen.has(name)) continue;
96364
- const projectPath2 = path29.join(projectWorkflowsDir, name);
96365
- if (!dryRun) {
96366
- fs31.unlinkSync(projectPath2);
96380
+ const report = args.report ?? await runDoctor({ projectDir: args.projectDir, instance: args.instance });
96381
+ const present = report.findings.find((f3) => f3.id === args.findingId);
96382
+ if (!present) {
96383
+ throw new ScmDoctorFixError(
96384
+ `Finding "${args.findingId}" is not present in the current report. Re-run lakebase-scm-doctor to see what needs fixing.`,
96385
+ "finding-not-present"
96386
+ );
96387
+ }
96388
+ let action = "";
96389
+ try {
96390
+ switch (args.findingId) {
96391
+ case "env-branch-drift": {
96392
+ const branch = report.state?.branch;
96393
+ if (!branch) {
96394
+ throw new ScmDoctorFixError(
96395
+ "Cannot fix: workflow state has no branch field.",
96396
+ "fix-failed"
96397
+ );
96398
+ }
96399
+ const sanitized = sanitizeBranchName(branch);
96400
+ const envFile = path29.join(args.projectDir, ".env");
96401
+ updateEnvConnection({
96402
+ envPath: envFile,
96403
+ projectId: readEnvVar(envFile, "LAKEBASE_PROJECT_ID") ?? "",
96404
+ branchId: sanitized,
96405
+ username: readEnvVar(envFile, "DB_USERNAME") ?? "",
96406
+ endpointHost: readEnvVar(envFile, "LAKEBASE_HOST")
96407
+ });
96408
+ action = `rewrote .env LAKEBASE_BRANCH_ID=${sanitized} (metadata only; the app mints its token at runtime)`;
96409
+ break;
96410
+ }
96411
+ case "head-branch-drift": {
96412
+ const branch = report.state?.branch;
96413
+ if (!branch) {
96414
+ throw new ScmDoctorFixError(
96415
+ "Cannot fix: workflow state has no branch field.",
96416
+ "fix-failed"
96417
+ );
96418
+ }
96419
+ await exec2(`git checkout ${shellEscape3(branch)}`, {
96420
+ cwd: args.projectDir,
96421
+ timeout: 15e3
96422
+ });
96423
+ action = `git checkout ${branch} (re-fires post-checkout to resync HEAD)`;
96424
+ break;
96425
+ }
96426
+ case "tier-topology-mismatch": {
96427
+ const instance = args.instance ?? report.state?.project_id;
96428
+ if (!instance) {
96429
+ throw new ScmDoctorFixError(
96430
+ "Cannot fix: missing Lakebase project id.",
96431
+ "fix-failed"
96432
+ );
96433
+ }
96434
+ await adoptScmState({
96435
+ projectDir: args.projectDir,
96436
+ instance,
96437
+ force: true
96438
+ });
96439
+ action = `adopted state with --force to re-infer tier_topology`;
96440
+ break;
96441
+ }
96442
+ case "orphan-current-branch": {
96443
+ const instance = args.instance ?? report.state?.project_id;
96444
+ if (!instance) {
96445
+ throw new ScmDoctorFixError(
96446
+ "Cannot fix: missing Lakebase project id.",
96447
+ "fix-failed"
96448
+ );
96449
+ }
96450
+ const headBranch = await getCurrentBranch({ cwd: args.projectDir });
96451
+ if (!headBranch) {
96452
+ throw new ScmDoctorFixError(
96453
+ "Cannot fix: detached HEAD or no current branch.",
96454
+ "fix-failed"
96455
+ );
96456
+ }
96457
+ await recoverOrphans({
96458
+ projectDir: args.projectDir,
96459
+ instance,
96460
+ claim: true,
96461
+ onlyBranch: headBranch
96462
+ });
96463
+ action = `recovered orphan ${headBranch} via createFeaturePairedBranch`;
96464
+ break;
96465
+ }
96466
+ case "multiple-migration-heads": {
96467
+ const r2 = await collapseMigrationHeads({ projectDir: args.projectDir });
96468
+ if (r2.status !== "ok" || !r2.mergeRevision) {
96469
+ throw new ScmDoctorFixError(
96470
+ `Expected to create a merge revision but got status="${r2.status}".`,
96471
+ "fix-failed"
96472
+ );
96473
+ }
96474
+ action = `collapsed ${r2.headsBefore.length} heads into merge revision ${r2.mergeRevision} (commit it)`;
96475
+ break;
96476
+ }
96477
+ case "db-ahead-of-code": {
96478
+ const r2 = await abandonFeatureBranch({ projectDir: args.projectDir, instance: args.instance, force: true });
96479
+ action = `abandoned the feature (deleted the polluted paired branch${r2.lakebaseDeleted ? "" : " , Lakebase delete reported not-deleted"}) and reset state to '${r2.state.state}'; re-claim to re-fork a clean branch from the tier`;
96480
+ break;
96367
96481
  }
96368
- files.push({ name, outcome: "removed" });
96369
96482
  }
96483
+ } catch (err) {
96484
+ if (err instanceof ScmDoctorFixError) throw err;
96485
+ throw new ScmDoctorFixError(
96486
+ `Remediation failed: ${err instanceof Error ? err.message : String(err)}`,
96487
+ "fix-failed"
96488
+ );
96370
96489
  }
96371
- const order = {
96372
- added: 0,
96373
- updated: 1,
96374
- removed: 2,
96375
- unchanged: 3
96376
- };
96377
- files.sort((a, b) => order[a.outcome] - order[b.outcome] || a.name.localeCompare(b.name));
96378
- const changed = files.some((f3) => f3.outcome !== "unchanged");
96379
- return { files, changed };
96490
+ const postReport = await runDoctor({
96491
+ projectDir: args.projectDir,
96492
+ instance: args.instance
96493
+ });
96494
+ return { findingId: args.findingId, action, postReport };
96495
+ }
96496
+ function shellEscape3(s2) {
96497
+ return `'${s2.replace(/'/g, "'\\''")}'`;
96380
96498
  }
96381
96499
 
96382
96500
  // scripts/lakebase/doctor.ts
96501
+ init_cjs_shims();
96502
+ var fs32 = __toESM(require("fs"), 1);
96503
+ var path30 = __toESM(require("path"), 1);
96504
+ var cp6 = __toESM(require("child_process"), 1);
96383
96505
  function readEnvFile(projectDir) {
96384
96506
  const envPath = path30.join(projectDir, ".env");
96385
96507
  if (!fs32.existsSync(envPath)) return {};
@@ -96812,7 +96934,7 @@ function checkWorkflowDrift(projectDir) {
96812
96934
  status: "warn",
96813
96935
  message: `Scaffolded workflows drift from kit: ${drifted} drifted, ${missing} missing`,
96814
96936
  detail: { files: report.files.map((f3) => ({ name: f3.name, status: f3.status })) },
96815
- hint: "Inspect via the lakebase_workflow_drift MCP tool (or detectWorkflowDrift import). Refresh manually until updateWorkflows lands."
96937
+ 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."
96816
96938
  };
96817
96939
  } catch (err) {
96818
96940
  return {
@@ -96923,9 +97045,10 @@ async function ensureLakebaseSecretAuth(args) {
96923
97045
  if (typeof pat !== "string" || !pat) {
96924
97046
  throw new Error("databricks tokens create returned no token_value");
96925
97047
  }
96926
- await runDatabricks(["secrets", "put-secret", scopeName, keyName, "--string-value", pat], {
97048
+ await runDatabricks(["secrets", "put-secret", "--json", "@/dev/stdin"], {
96927
97049
  profile,
96928
- timeout: timeoutMs
97050
+ timeout: timeoutMs,
97051
+ input: JSON.stringify({ scope: scopeName, key: keyName, string_value: pat })
96929
97052
  });
96930
97053
  let aclGranted = false;
96931
97054
  if (servicePrincipalClientId) {
@@ -97287,63 +97410,6 @@ async function discardAllChanges(args) {
97287
97410
  await exec2("git clean -fd", { cwd: args.cwd });
97288
97411
  }
97289
97412
 
97290
- // scripts/git/sync.ts
97291
- init_cjs_shims();
97292
- async function currentBranchName2(cwd) {
97293
- try {
97294
- return await exec2("git rev-parse --abbrev-ref HEAD", { cwd });
97295
- } catch {
97296
- return "";
97297
- }
97298
- }
97299
- async function push(args) {
97300
- await exec2("git push", { cwd: args.cwd });
97301
- }
97302
- async function pull(args) {
97303
- await exec2("git pull", { cwd: args.cwd });
97304
- }
97305
- async function publishBranch(args) {
97306
- const remote = args.remote ?? "origin";
97307
- const branch = await currentBranchName2(args.cwd);
97308
- if (!branch) throw new Error("No current branch");
97309
- await exec2(`git push -u ${remote} ${shq(branch)}`, {
97310
- cwd: args.cwd
97311
- });
97312
- }
97313
- async function pushCurrentBranchForPr(args) {
97314
- const remote = args.remote ?? "origin";
97315
- const branch = await currentBranchName2(args.cwd);
97316
- if (!branch) throw new Error("No current branch");
97317
- const upstreamSet = await hasUpstream({ cwd: args.cwd });
97318
- if (!upstreamSet) {
97319
- await exec2(`git push -u ${remote} ${shq(branch)}`, {
97320
- cwd: args.cwd
97321
- });
97322
- } else {
97323
- await exec2("git push", { cwd: args.cwd });
97324
- }
97325
- }
97326
- async function fetch3(args) {
97327
- const parts = ["git fetch"];
97328
- if (args.prune) parts.push("--prune");
97329
- if (args.all) parts.push("--all");
97330
- await exec2(parts.join(" "), { cwd: args.cwd });
97331
- }
97332
- async function pullFrom(args) {
97333
- await exec2(`git pull ${shq(args.remote)} ${shq(args.branch)}`, {
97334
- cwd: args.cwd
97335
- });
97336
- }
97337
- async function pushTo(args) {
97338
- await exec2(`git push ${shq(args.remote)} ${shq(args.branch)}`, {
97339
- cwd: args.cwd
97340
- });
97341
- }
97342
- async function sync(args) {
97343
- await exec2("git pull", { cwd: args.cwd });
97344
- await exec2("git push", { cwd: args.cwd });
97345
- }
97346
-
97347
97413
  // scripts/git/branch-tag.ts
97348
97414
  init_cjs_shims();
97349
97415
  var PROTECTED_BRANCHES = /* @__PURE__ */ new Set(["production", "main", "master"]);
@@ -97749,6 +97815,7 @@ function withProxyEnv(base = {}) {
97749
97815
  createWorktree,
97750
97816
  cutBackup,
97751
97817
  databricksAuthPrereqMessage,
97818
+ defaultRefreshPromoteWorkflows,
97752
97819
  delay,
97753
97820
  deleteAppEndpoint,
97754
97821
  deleteBranch,