@databricks-solutions/lakebase-scm-utils 0.2.47 → 0.2.48

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 (39) hide show
  1. package/dist/scripts/index.cjs +55 -2
  2. package/dist/scripts/index.d.cts +1 -1
  3. package/dist/scripts/index.d.ts +1 -1
  4. package/dist/scripts/index.js +54 -2
  5. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2 -2
  6. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +2 -2
  7. package/dist/scripts/lakebase/collapse-heads.cli.cjs +2 -2
  8. package/dist/scripts/lakebase/collapse-heads.cli.js +2 -2
  9. package/dist/scripts/lakebase/detect-language.cli.cjs +2 -2
  10. package/dist/scripts/lakebase/detect-language.cli.js +2 -2
  11. package/dist/scripts/lakebase/doctor.cli.cjs +2 -2
  12. package/dist/scripts/lakebase/doctor.cli.js +2 -2
  13. package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
  14. package/dist/scripts/lakebase/get-connection.cli.js +2 -2
  15. package/dist/scripts/lakebase/index.cjs +55 -2
  16. package/dist/scripts/lakebase/index.d.cts +29 -11
  17. package/dist/scripts/lakebase/index.d.ts +29 -11
  18. package/dist/scripts/lakebase/index.js +54 -2
  19. package/dist/scripts/lakebase/infra-runner.cli.cjs +2 -2
  20. package/dist/scripts/lakebase/infra-runner.cli.js +2 -2
  21. package/dist/scripts/lakebase/new-migration.cli.cjs +2 -2
  22. package/dist/scripts/lakebase/new-migration.cli.js +2 -2
  23. package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
  24. package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
  25. package/dist/scripts/lakebase/schema-migrate.cli.cjs +2 -2
  26. package/dist/scripts/lakebase/schema-migrate.cli.js +2 -2
  27. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2 -2
  28. package/dist/scripts/lakebase/scm-claim-feature.cli.js +2 -2
  29. package/dist/scripts/lakebase/scm-doctor.cli.cjs +2 -2
  30. package/dist/scripts/lakebase/scm-doctor.cli.js +2 -2
  31. package/dist/scripts/lakebase/scm-merge.cli.cjs +131 -2
  32. package/dist/scripts/lakebase/scm-merge.cli.js +131 -2
  33. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2 -2
  34. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +2 -2
  35. package/dist/scripts/util/index.cjs +2 -2
  36. package/dist/scripts/util/index.js +2 -2
  37. package/package.json +1 -1
  38. package/scripts/lakebase/scm-merge.ts +91 -0
  39. package/templates/project/common/.gitignore.base +4 -6
@@ -8609,6 +8609,84 @@ function wrap(err, context) {
8609
8609
  }
8610
8610
  throw new GitHubPullRequestError(context);
8611
8611
  }
8612
+ async function getPullRequest(ownerRepo, headBranch) {
8613
+ try {
8614
+ const { owner, repo } = parseOwnerRepo(ownerRepo);
8615
+ const ok = await octokit();
8616
+ const { data: pulls } = await ok.rest.pulls.list({
8617
+ owner,
8618
+ repo,
8619
+ state: "open",
8620
+ head: `${owner}:${headBranch}`,
8621
+ per_page: 1
8622
+ });
8623
+ if (pulls.length === 0) return void 0;
8624
+ const { data: pr } = await ok.rest.pulls.get({
8625
+ owner,
8626
+ repo,
8627
+ pull_number: pulls[0].number
8628
+ });
8629
+ if (pr.state !== "open") return void 0;
8630
+ let checks = [];
8631
+ let ciStatus = "pending";
8632
+ const headSha = pr.head?.sha;
8633
+ if (headSha) {
8634
+ try {
8635
+ const { data: checksData } = await ok.rest.checks.listForRef({
8636
+ owner,
8637
+ repo,
8638
+ ref: headSha
8639
+ });
8640
+ const runs = checksData.check_runs || [];
8641
+ checks = runs.map((c) => ({
8642
+ name: c.name || "unknown",
8643
+ status: (c.status || "").toUpperCase(),
8644
+ conclusion: (c.conclusion || "").toUpperCase(),
8645
+ detailsUrl: c.details_url || void 0
8646
+ }));
8647
+ ciStatus = parseCiStatus(runs);
8648
+ } catch {
8649
+ ciStatus = "pending";
8650
+ }
8651
+ }
8652
+ return {
8653
+ number: pr.number,
8654
+ title: pr.title,
8655
+ url: pr.html_url || "",
8656
+ state: (pr.state || "open").toUpperCase(),
8657
+ isDraft: pr.draft || false,
8658
+ ciStatus,
8659
+ checks,
8660
+ mergeable: pr.mergeable,
8661
+ mergeableState: pr.mergeable_state,
8662
+ headBranch: pr.head?.ref || headBranch,
8663
+ baseBranch: pr.base?.ref || "",
8664
+ body: pr.body || void 0,
8665
+ additions: pr.additions,
8666
+ deletions: pr.deletions,
8667
+ changedFiles: pr.changed_files
8668
+ };
8669
+ } catch {
8670
+ return void 0;
8671
+ }
8672
+ }
8673
+ function parseCiStatus(rawChecks) {
8674
+ if (rawChecks.length === 0) return "pending";
8675
+ const latestByName = /* @__PURE__ */ new Map();
8676
+ for (const c of rawChecks) {
8677
+ latestByName.set(c.name || "unknown", c);
8678
+ }
8679
+ const states = Array.from(latestByName.values()).map(
8680
+ (c) => (c.conclusion || c.status || "").toUpperCase()
8681
+ );
8682
+ if (states.some((s) => s === "FAILURE" || s === "ERROR" || s === "ACTION_REQUIRED")) {
8683
+ return "failure";
8684
+ }
8685
+ if (states.every((s) => s === "SUCCESS" || s === "NEUTRAL" || s === "SKIPPED")) {
8686
+ return "success";
8687
+ }
8688
+ return "pending";
8689
+ }
8612
8690
  async function mergePullRequest(args) {
8613
8691
  const method = args.method ?? "merge";
8614
8692
  const deleteRemoteBranch = args.deleteRemoteBranch !== false;
@@ -9303,6 +9381,48 @@ function shaMigratePredicate(mergeCommitSha) {
9303
9381
  return !!run.headSha && run.headSha === mergeCommitSha;
9304
9382
  };
9305
9383
  }
9384
+ var PROMOTE_RUNSTATE_CONFLICT = /(^|\/)\.consort\/(features\/[^/]+\/)?pipeline\.json$/;
9385
+ async function reconcilePromoteRunState(projectDir, baseBranch) {
9386
+ const o = { cwd: projectDir, timeout: 3e4 };
9387
+ const dirty = (await exec2("git status --porcelain", o).catch(() => "")).split("\n").map((l) => l.slice(3).trim()).filter(Boolean);
9388
+ const nonRunStateDirty = dirty.filter((p) => !p.startsWith(".consort/"));
9389
+ if (nonRunStateDirty.length > 0) {
9390
+ throw new ScmMergeError(
9391
+ `working tree has uncommitted non-run-state changes (${nonRunStateDirty.slice(0, 5).join(", ")}${nonRunStateDirty.length > 5 ? ", \u2026" : ""}); commit or stash before promoting.`,
9392
+ "promote-conflict"
9393
+ );
9394
+ }
9395
+ if (dirty.length > 0) {
9396
+ await exec2(`git add ${shellEscape(".consort")}`, o);
9397
+ await exec2(`git commit -m ${shellEscape("chore(run): checkpoint run state before promotion")}`, o);
9398
+ }
9399
+ await exec2(`git fetch origin ${shellEscape(baseBranch)}`, o).catch(() => {
9400
+ });
9401
+ let conflicted = false;
9402
+ try {
9403
+ await exec2(`git merge --no-ff -m ${shellEscape(`merge ${baseBranch} for promotion`)} ${shellEscape(`origin/${baseBranch}`)}`, o);
9404
+ } catch {
9405
+ conflicted = true;
9406
+ }
9407
+ if (!conflicted) return { reconciled: false };
9408
+ const u = (await exec2("git diff --name-only --diff-filter=U", o).catch(() => "")).split("\n").map((s) => s.trim()).filter(Boolean);
9409
+ const other = u.filter((p) => !PROMOTE_RUNSTATE_CONFLICT.test(p));
9410
+ if (u.length === 0 || other.length > 0) {
9411
+ await exec2("git merge --abort", o).catch(() => {
9412
+ });
9413
+ throw new ScmMergeError(
9414
+ other.length > 0 ? `promotion conflicts on non-ledger file(s): ${other.join(", ")}. Resolve the real divergence manually, then re-run the promote.` : `promotion merge failed with no resolvable conflicts; resolve manually and re-run.`,
9415
+ "promote-conflict"
9416
+ );
9417
+ }
9418
+ for (const p of u) {
9419
+ await exec2(`git checkout --ours -- ${shellEscape(p)}`, o);
9420
+ await exec2(`git add -- ${shellEscape(p)}`, o);
9421
+ }
9422
+ await exec2(`git commit -m ${shellEscape(`merge ${baseBranch} for promotion (pipeline.json ledger kept at run state)`)}`, o);
9423
+ await pushCurrentBranchForPr({ cwd: projectDir });
9424
+ return { reconciled: true, detail: `reconciled ${u.length} ledger path(s) to the run side + pushed` };
9425
+ }
9306
9426
  async function defaultRefreshPromoteWorkflows(projectDir) {
9307
9427
  const drift = detectWorkflowDrift({ projectDir });
9308
9428
  const touched = drift.files.filter((f) => f.status === "drifted").map((f) => f.name);
@@ -9392,6 +9512,15 @@ async function mergeFeature(args) {
9392
9512
  );
9393
9513
  }
9394
9514
  }
9515
+ {
9516
+ const fetchState = args.fetchPrState ?? ((o, h) => getPullRequest(o, h));
9517
+ const prState = await fetchState(ownerRepo, current.branch).catch(() => void 0);
9518
+ if (prState?.mergeableState === "dirty") {
9519
+ const reconcile = args.reconcileRunState ?? ((base) => reconcilePromoteRunState(args.projectDir, base));
9520
+ const r = await reconcile(gitBase);
9521
+ if (r.reconciled) preflightNotes.push(`Reconciled a pipeline.json-ledger promotion conflict to the run side${r.detail ? ` (${r.detail})` : ""}.`);
9522
+ }
9523
+ }
9395
9524
  let paired;
9396
9525
  try {
9397
9526
  paired = await mergePairedPullRequest({
@@ -9627,8 +9756,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
9627
9756
  var cached;
9628
9757
  function substrateSelfVersion() {
9629
9758
  if (cached !== void 0) return cached;
9630
- if ("0.2.47".length > 0) {
9631
- cached = "0.2.47";
9759
+ if ("0.2.48".length > 0) {
9760
+ cached = "0.2.48";
9632
9761
  return cached;
9633
9762
  }
9634
9763
  cached = "unknown";
@@ -79288,8 +79288,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
79288
79288
  var cached;
79289
79289
  function substrateSelfVersion() {
79290
79290
  if (cached !== void 0) return cached;
79291
- if ("0.2.47".length > 0) {
79292
- cached = "0.2.47";
79291
+ if ("0.2.48".length > 0) {
79292
+ cached = "0.2.48";
79293
79293
  return cached;
79294
79294
  }
79295
79295
  cached = "unknown";
@@ -389,8 +389,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
389
389
  var cached;
390
390
  function substrateSelfVersion() {
391
391
  if (cached !== void 0) return cached;
392
- if ("0.2.47".length > 0) {
393
- cached = "0.2.47";
392
+ if ("0.2.48".length > 0) {
393
+ cached = "0.2.48";
394
394
  return cached;
395
395
  }
396
396
  cached = "unknown";
@@ -1661,8 +1661,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
1661
1661
  var cached;
1662
1662
  function substrateSelfVersion() {
1663
1663
  if (cached !== void 0) return cached;
1664
- if ("0.2.47".length > 0) {
1665
- cached = "0.2.47";
1664
+ if ("0.2.48".length > 0) {
1665
+ cached = "0.2.48";
1666
1666
  return cached;
1667
1667
  }
1668
1668
  cached = "unknown";
@@ -1630,8 +1630,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
1630
1630
  var cached;
1631
1631
  function substrateSelfVersion() {
1632
1632
  if (cached !== void 0) return cached;
1633
- if ("0.2.47".length > 0) {
1634
- cached = "0.2.47";
1633
+ if ("0.2.48".length > 0) {
1634
+ cached = "0.2.48";
1635
1635
  return cached;
1636
1636
  }
1637
1637
  cached = "unknown";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@databricks-solutions/lakebase-scm-utils",
3
- "version": "0.2.47",
3
+ "version": "0.2.48",
4
4
  "description": "Lakebase SCM + substrate engine: database branching, paired-branch SCM workflows, connection/credential minting, schema migration, project scaffold + deploy primitives, and the git/github utility layer. Extracted from consort for portable, standalone consumption by lakebase-scm-extension and the SFTDD orchestration.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -12,6 +12,7 @@
12
12
  // stable surface for that signal.
13
13
 
14
14
  import {
15
+ getPullRequest,
15
16
  listWorkflowRuns,
16
17
  mergePairedPullRequest,
17
18
  type WorkflowRunSummary,
@@ -68,6 +69,7 @@ export class ScmMergeError extends Error {
68
69
  | "no-pr-url"
69
70
  | "bad-pr-url"
70
71
  | "merge-failed"
72
+ | "promote-conflict"
71
73
  | "migrate-failed"
72
74
  | "migrate-timeout"
73
75
  | "migrate-auth",
@@ -166,6 +168,13 @@ export interface MergeArgs {
166
168
  * implementation ({@link defaultRefreshPromoteWorkflows}); injected in tests.
167
169
  */
168
170
  refreshWorkflows?: () => Promise<{ refreshed: boolean; detail?: string }>;
171
+ /** Pre-merge reconcile of a pipeline.json-ledger promotion conflict to the run side (keeps it
172
+ * tracked). Called ONLY when the PR is CONFLICTING. Defaults to {@link reconcilePromoteRunState};
173
+ * injected in tests. */
174
+ reconcileRunState?: (baseBranch: string) => Promise<{ reconciled: boolean; detail?: string }>;
175
+ /** Fetch the PR's mergeability (to gate the reconcile). Defaults to the real getPullRequest;
176
+ * injected in tests so the gate is exercised without the network. */
177
+ fetchPrState?: (ownerRepo: string, headBranch: string) => Promise<{ mergeableState?: string } | undefined>;
169
178
  }
170
179
 
171
180
  export interface MergeResult {
@@ -248,6 +257,71 @@ function shaMigratePredicate(
248
257
  * working-tree changes. Callers invoke it BEST-EFFORT — a failure here must never
249
258
  * block the merge (the local migrate fallback still keeps git + schema in sync).
250
259
  */
260
+ /** The ONLY paths a promotion merge may auto-resolve to the RUN (feature/head) side: the per-story
261
+ * pipeline.json ledger (per-feature, and the legacy top-level pointer). It is the feature branch's
262
+ * own durable bookkeeping — the tier has no authoritative copy — so on a promotion conflict the
263
+ * head side always wins. Everything else is real divergence and must surface, never auto-resolve. */
264
+ const PROMOTE_RUNSTATE_CONFLICT = /(^|\/)\.consort\/(features\/[^/]+\/)?pipeline\.json$/;
265
+
266
+ /**
267
+ * Clear a promotion-merge conflict that is ONLY the pipeline.json ledger, so GitHub can dispatch CI
268
+ * and merge — WITHOUT untracking the ledger (it's durable run-state that must persist). Merges the
269
+ * base into the PR head and resolves the ledger path(s) to the head (run) side, then pushes. This is
270
+ * the merge-layer fix for the "pipeline.json conflict blocks promotion" defect, replacing the wrong
271
+ * earlier approach of gitignoring/untracking it. Behavior:
272
+ * - a dirty tree under `.consort/` only is checkpointed first (the run's own bookkeeping);
273
+ * - a dirty tree OUTSIDE `.consort/` aborts (never auto-commit app code);
274
+ * - a conflict on any NON-ledger path aborts the merge + surfaces (real divergence isn't resolved);
275
+ * - a clean merge (no conflict) is a no-op (reconciled:false).
276
+ * Only call this when the PR is actually CONFLICTING — a clean promote needs no merge commit.
277
+ */
278
+ export async function reconcilePromoteRunState(
279
+ projectDir: string,
280
+ baseBranch: string,
281
+ ): Promise<{ reconciled: boolean; detail?: string }> {
282
+ const o = { cwd: projectDir, timeout: 30_000 };
283
+ // Working tree must be clean to merge. Checkpoint run-state (.consort/) if that's all that's dirty;
284
+ // refuse on anything else so we never auto-commit app code during a promotion.
285
+ const dirty = (await exec("git status --porcelain", o).catch(() => "")).split("\n").map((l) => l.slice(3).trim()).filter(Boolean);
286
+ const nonRunStateDirty = dirty.filter((p) => !p.startsWith(".consort/"));
287
+ if (nonRunStateDirty.length > 0) {
288
+ throw new ScmMergeError(
289
+ `working tree has uncommitted non-run-state changes (${nonRunStateDirty.slice(0, 5).join(", ")}${nonRunStateDirty.length > 5 ? ", …" : ""}); commit or stash before promoting.`,
290
+ "promote-conflict",
291
+ );
292
+ }
293
+ if (dirty.length > 0) {
294
+ await exec(`git add ${shellEscape(".consort")}`, o);
295
+ await exec(`git commit -m ${shellEscape("chore(run): checkpoint run state before promotion")}`, o);
296
+ }
297
+ await exec(`git fetch origin ${shellEscape(baseBranch)}`, o).catch(() => {});
298
+ let conflicted = false;
299
+ try {
300
+ await exec(`git merge --no-ff -m ${shellEscape(`merge ${baseBranch} for promotion`)} ${shellEscape(`origin/${baseBranch}`)}`, o);
301
+ } catch {
302
+ conflicted = true;
303
+ }
304
+ if (!conflicted) return { reconciled: false }; // clean merge (or already up to date) — nothing to resolve
305
+ const u = (await exec("git diff --name-only --diff-filter=U", o).catch(() => "")).split("\n").map((s) => s.trim()).filter(Boolean);
306
+ const other = u.filter((p) => !PROMOTE_RUNSTATE_CONFLICT.test(p));
307
+ if (u.length === 0 || other.length > 0) {
308
+ await exec("git merge --abort", o).catch(() => {});
309
+ throw new ScmMergeError(
310
+ other.length > 0
311
+ ? `promotion conflicts on non-ledger file(s): ${other.join(", ")}. Resolve the real divergence manually, then re-run the promote.`
312
+ : `promotion merge failed with no resolvable conflicts; resolve manually and re-run.`,
313
+ "promote-conflict",
314
+ );
315
+ }
316
+ for (const p of u) {
317
+ await exec(`git checkout --ours -- ${shellEscape(p)}`, o); // keep the run (feature) ledger
318
+ await exec(`git add -- ${shellEscape(p)}`, o);
319
+ }
320
+ await exec(`git commit -m ${shellEscape(`merge ${baseBranch} for promotion (pipeline.json ledger kept at run state)`)}`, o);
321
+ await pushCurrentBranchForPr({ cwd: projectDir });
322
+ return { reconciled: true, detail: `reconciled ${u.length} ledger path(s) to the run side + pushed` };
323
+ }
324
+
251
325
  export async function defaultRefreshPromoteWorkflows(
252
326
  projectDir: string,
253
327
  ): Promise<{ refreshed: boolean; detail?: string }> {
@@ -376,6 +450,23 @@ export async function mergeFeature(args: MergeArgs): Promise<MergeResult> {
376
450
  }
377
451
  }
378
452
 
453
+ // ─── Pre-merge pipeline.json-ledger reconcile ───
454
+ // The per-feature pipeline.json ledger is TRACKED (durable run-state) and can diverge between the
455
+ // feature branch and its tier, so the promotion PR lands CONFLICTING — GitHub won't dispatch CI or
456
+ // merge. When that happens, reconcile the ledger to the run (head) side at the MERGE layer and push,
457
+ // so the PR goes mergeable WITHOUT untracking the ledger. Only runs when the PR is actually dirty;
458
+ // a non-ledger conflict throws (real divergence surfaces). This is the correct replacement for the
459
+ // withdrawn gitignore/untrack approach.
460
+ {
461
+ const fetchState = args.fetchPrState ?? ((o, h) => getPullRequest(o, h));
462
+ const prState = await fetchState(ownerRepo, current.branch).catch(() => undefined);
463
+ if (prState?.mergeableState === "dirty") {
464
+ const reconcile = args.reconcileRunState ?? ((base: string) => reconcilePromoteRunState(args.projectDir, base));
465
+ const r = await reconcile(gitBase); // throws ScmMergeError on a non-ledger conflict (surfaces)
466
+ if (r.reconciled) preflightNotes.push(`Reconciled a pipeline.json-ledger promotion conflict to the run side${r.detail ? ` (${r.detail})` : ""}.`);
467
+ }
468
+ }
469
+
379
470
  let paired: Awaited<ReturnType<typeof mergePairedPullRequest>>;
380
471
  try {
381
472
  paired = await mergePairedPullRequest({
@@ -29,13 +29,11 @@ application-local.properties
29
29
  # it on the feature branch at each cut so the spec history survives for later stories).
30
30
  .consort/cycles/
31
31
  .consort/experiments/
32
- # The live build-pipeline pointer. It lives PER-FEATURE (.consort/features/<id>/pipeline.json);
33
- # the bare `.consort/pipeline.json` is kept for older layouts. Matching only the top-level path
34
- # left the per-feature file TRACKED — so it travelled onto staging/main and collided at the
35
- # promotion merge (two branches editing one run's gate-bookkeeping), the exact "pipeline.json
36
- # conflict blocks promotion" defect. Both forms are run-state, never the design corpus.
37
32
  .consort/pipeline.json
38
- .consort/features/*/pipeline.json
33
+ # NOTE: the PER-FEATURE ledger .consort/features/<id>/pipeline.json is deliberately NOT ignored —
34
+ # it is the durable per-story pipeline state (persisted on the feature branch so it survives across
35
+ # sessions). Ignoring it lost the ledger. A promotion-merge conflict on it is resolved at the MERGE
36
+ # layer (scm-merge reconciles it to the run side), never by untracking it.
39
37
  # The turn recorder's per-run observability: its internal file-hash map
40
38
  # (.recorder-state.json, used for delta computation), the routing-decision
41
39
  # diagnostic stream (routing-decisions.jsonl), and the per-turn record dirs