@databricks-solutions/lakebase-scm-utils 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/scripts/index.cjs +55 -25
- package/dist/scripts/index.d.cts +1 -1
- package/dist/scripts/index.d.ts +1 -1
- package/dist/scripts/index.js +54 -25
- package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2 -2
- package/dist/scripts/lakebase/ci-resolve-branch.cli.js +2 -2
- package/dist/scripts/lakebase/collapse-heads.cli.cjs +2 -2
- package/dist/scripts/lakebase/collapse-heads.cli.js +2 -2
- package/dist/scripts/lakebase/detect-language.cli.cjs +2 -2
- package/dist/scripts/lakebase/detect-language.cli.js +2 -2
- package/dist/scripts/lakebase/doctor.cli.cjs +2 -2
- package/dist/scripts/lakebase/doctor.cli.js +2 -2
- package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
- package/dist/scripts/lakebase/get-connection.cli.js +2 -2
- package/dist/scripts/lakebase/index.cjs +55 -25
- package/dist/scripts/lakebase/index.d.cts +13 -4
- package/dist/scripts/lakebase/index.d.ts +13 -4
- package/dist/scripts/lakebase/index.js +54 -25
- package/dist/scripts/lakebase/infra-runner.cli.cjs +2 -2
- package/dist/scripts/lakebase/infra-runner.cli.js +2 -2
- package/dist/scripts/lakebase/new-migration.cli.cjs +2 -2
- package/dist/scripts/lakebase/new-migration.cli.js +2 -2
- package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
- package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
- package/dist/scripts/lakebase/schema-migrate.cli.cjs +2 -2
- package/dist/scripts/lakebase/schema-migrate.cli.js +2 -2
- package/dist/scripts/lakebase/scm-abandon-feature.cli.cjs +37 -22
- package/dist/scripts/lakebase/scm-abandon-feature.cli.js +34 -22
- package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2 -2
- package/dist/scripts/lakebase/scm-claim-feature.cli.js +2 -2
- package/dist/scripts/lakebase/scm-doctor.cli.cjs +36 -21
- package/dist/scripts/lakebase/scm-doctor.cli.js +36 -21
- package/dist/scripts/lakebase/scm-merge.cli.cjs +2 -2
- package/dist/scripts/lakebase/scm-merge.cli.js +2 -2
- package/dist/scripts/lakebase/scm-park-feature.cli.cjs +586 -0
- package/dist/scripts/lakebase/scm-park-feature.cli.d.cts +4 -0
- package/dist/scripts/lakebase/scm-park-feature.cli.d.ts +4 -0
- package/dist/scripts/lakebase/scm-park-feature.cli.js +546 -0
- package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2 -2
- package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +2 -2
- package/dist/scripts/util/index.cjs +2 -2
- package/dist/scripts/util/index.js +2 -2
- package/package.json +2 -1
- package/scripts/lakebase/enable-e2e.ts +34 -4
- package/scripts/lakebase/scm-abandon-feature.cli.ts +3 -3
- package/scripts/lakebase/scm-abandon-feature.ts +19 -83
- package/scripts/lakebase/scm-park-feature.cli.ts +150 -0
- package/scripts/lakebase/scm-park-feature.ts +71 -0
- package/scripts/lakebase/scm-release-claim.ts +146 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// scripts/lakebase/scm-park-feature.cli.ts
|
|
4
|
+
import * as path2 from "path";
|
|
5
|
+
|
|
6
|
+
// scripts/util/cli-entry.ts
|
|
7
|
+
import { realpathSync } from "fs";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
function isCliEntry(importMetaUrl) {
|
|
10
|
+
const invokedRaw = process.argv[1];
|
|
11
|
+
if (!invokedRaw) return false;
|
|
12
|
+
let invokedResolved;
|
|
13
|
+
let moduleResolved;
|
|
14
|
+
try {
|
|
15
|
+
invokedResolved = realpathSync(invokedRaw);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
moduleResolved = realpathSync(fileURLToPath(importMetaUrl));
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return invokedResolved === moduleResolved;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// scripts/util/exec.ts
|
|
28
|
+
import * as cp from "child_process";
|
|
29
|
+
function shq(s) {
|
|
30
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
31
|
+
}
|
|
32
|
+
function exec2(command, opts = {}) {
|
|
33
|
+
return new Promise((resolve2, reject) => {
|
|
34
|
+
const options = {
|
|
35
|
+
cwd: opts.cwd,
|
|
36
|
+
timeout: opts.timeout ?? 6e4
|
|
37
|
+
};
|
|
38
|
+
if (opts.env) {
|
|
39
|
+
options.env = { ...process.env, ...opts.env };
|
|
40
|
+
}
|
|
41
|
+
cp.exec(command, options, (err, stdout, stderr) => {
|
|
42
|
+
if (err) {
|
|
43
|
+
const msg = String(stderr || err.message);
|
|
44
|
+
reject(new Error(`${command}: ${msg}`));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
resolve2(String(stdout).trim());
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// scripts/git/inspect.ts
|
|
53
|
+
async function getCurrentBranch(args) {
|
|
54
|
+
try {
|
|
55
|
+
const name = await exec2("git rev-parse --abbrev-ref HEAD", {
|
|
56
|
+
cwd: args.cwd
|
|
57
|
+
});
|
|
58
|
+
return name === "HEAD" ? "" : name;
|
|
59
|
+
} catch {
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// scripts/git/status.ts
|
|
65
|
+
async function isDirty(args) {
|
|
66
|
+
try {
|
|
67
|
+
const ignore = args.ignore ?? [];
|
|
68
|
+
const untrackedFlag = args.untracked === false ? " --untracked-files=no" : "";
|
|
69
|
+
let command = `git status --porcelain${untrackedFlag}`;
|
|
70
|
+
if (ignore.length > 0) {
|
|
71
|
+
const excludes = ignore.map((p) => shq(`:(exclude)${p.replace(/\/+$/, "")}`)).join(" ");
|
|
72
|
+
command = `git status --porcelain${untrackedFlag} -- . ${excludes}`;
|
|
73
|
+
}
|
|
74
|
+
const out = await exec2(command, { cwd: args.cwd });
|
|
75
|
+
return out.trim().length > 0;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// scripts/lakebase/scm-workflow-state.ts
|
|
82
|
+
import * as fs from "fs";
|
|
83
|
+
import * as path from "path";
|
|
84
|
+
import { execFileSync } from "child_process";
|
|
85
|
+
function isGitTracked(projectDir, rel) {
|
|
86
|
+
try {
|
|
87
|
+
execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function ensureWorkflowStateUntracked(projectDir) {
|
|
94
|
+
const rel = ".lakebase/workflow-state.json";
|
|
95
|
+
try {
|
|
96
|
+
if (isGitTracked(projectDir, rel)) {
|
|
97
|
+
execFileSync("git", ["rm", "--cached", "--quiet", "--ignore-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const gitignore = path.join(projectDir, ".gitignore");
|
|
103
|
+
const existing = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, "utf8") : "";
|
|
104
|
+
if (!existing.split("\n").some((l) => l.trim() === rel)) {
|
|
105
|
+
const sep = existing === "" || existing.endsWith("\n") ? "" : "\n";
|
|
106
|
+
fs.appendFileSync(
|
|
107
|
+
gitignore,
|
|
108
|
+
`${sep}# Runtime SCM claim state (per working tree): a branch checkout must never restore
|
|
109
|
+
# a stale committed claim over the live one (issue #203 / Finding 28).
|
|
110
|
+
${rel}
|
|
111
|
+
`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
var SCM_STATES = [
|
|
118
|
+
"scaffold-complete",
|
|
119
|
+
"feature-claimed",
|
|
120
|
+
"pr-ready",
|
|
121
|
+
"ci-green",
|
|
122
|
+
"merged"
|
|
123
|
+
];
|
|
124
|
+
var STATE_INDEX = SCM_STATES.reduce(
|
|
125
|
+
(acc, s, i) => ({ ...acc, [s]: i }),
|
|
126
|
+
{}
|
|
127
|
+
);
|
|
128
|
+
var STATE_FILE_REL = ".lakebase/workflow-state.json";
|
|
129
|
+
function stateFilePath(projectDir) {
|
|
130
|
+
return path.join(projectDir, STATE_FILE_REL);
|
|
131
|
+
}
|
|
132
|
+
function readWorkflowState(projectDir) {
|
|
133
|
+
const p = stateFilePath(projectDir);
|
|
134
|
+
if (!fs.existsSync(p)) return null;
|
|
135
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
136
|
+
let parsed;
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(raw);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Failed to parse ${STATE_FILE_REL}: ${e.message}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const result = validateWorkflowState(parsed);
|
|
145
|
+
if (!result.ok) {
|
|
146
|
+
const summary = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n");
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Invalid ${STATE_FILE_REL}:
|
|
149
|
+
${summary}
|
|
150
|
+
|
|
151
|
+
Fix the file or delete it to re-init.`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return result.value;
|
|
155
|
+
}
|
|
156
|
+
function writeWorkflowState(projectDir, state) {
|
|
157
|
+
const result = validateWorkflowState(state);
|
|
158
|
+
if (!result.ok) {
|
|
159
|
+
const summary = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n");
|
|
160
|
+
throw new Error(`Refusing to write invalid SCM state:
|
|
161
|
+
${summary}`);
|
|
162
|
+
}
|
|
163
|
+
ensureWorkflowStateUntracked(projectDir);
|
|
164
|
+
const dir = path.join(projectDir, ".lakebase");
|
|
165
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
166
|
+
const target = stateFilePath(projectDir);
|
|
167
|
+
const tmp = `${target}.tmp`;
|
|
168
|
+
const ordered = orderForOutput(result.value);
|
|
169
|
+
fs.writeFileSync(tmp, `${JSON.stringify(ordered, null, 2)}
|
|
170
|
+
`, "utf8");
|
|
171
|
+
fs.renameSync(tmp, target);
|
|
172
|
+
}
|
|
173
|
+
function validateWorkflowState(value) {
|
|
174
|
+
const errors = [];
|
|
175
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
errors: [{ path: "$", message: "must be an object" }]
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const v = value;
|
|
182
|
+
if (v.version !== 1) {
|
|
183
|
+
errors.push({ path: "version", message: `must be 1, got ${String(v.version)}` });
|
|
184
|
+
}
|
|
185
|
+
if (typeof v.state !== "string" || !SCM_STATES.includes(v.state)) {
|
|
186
|
+
errors.push({
|
|
187
|
+
path: "state",
|
|
188
|
+
message: `must be one of ${SCM_STATES.join(" | ")}`
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (v.tier_topology !== 1 && v.tier_topology !== 2 && v.tier_topology !== 3) {
|
|
192
|
+
errors.push({
|
|
193
|
+
path: "tier_topology",
|
|
194
|
+
message: "must be 1, 2, or 3"
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (typeof v.project_id !== "string" || v.project_id.length === 0) {
|
|
198
|
+
errors.push({
|
|
199
|
+
path: "project_id",
|
|
200
|
+
message: "must be a non-empty string"
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const stringFields = [
|
|
204
|
+
"feature_id",
|
|
205
|
+
"branch",
|
|
206
|
+
"parent_branch",
|
|
207
|
+
"lakebase_branch_uid",
|
|
208
|
+
"claimed_at",
|
|
209
|
+
"pr_url",
|
|
210
|
+
"pushed_at",
|
|
211
|
+
"ci_run_url",
|
|
212
|
+
"ci_green_at",
|
|
213
|
+
"merged_at",
|
|
214
|
+
"migrate_run_url",
|
|
215
|
+
"migrate_completed_at",
|
|
216
|
+
"$schema"
|
|
217
|
+
];
|
|
218
|
+
for (const key of stringFields) {
|
|
219
|
+
if (v[key] === void 0) continue;
|
|
220
|
+
if (typeof v[key] !== "string" || v[key].length === 0) {
|
|
221
|
+
errors.push({
|
|
222
|
+
path: key,
|
|
223
|
+
message: "must be a non-empty string when present"
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const requiredForState = {
|
|
228
|
+
"scaffold-complete": [],
|
|
229
|
+
"feature-claimed": [
|
|
230
|
+
"feature_id",
|
|
231
|
+
"branch",
|
|
232
|
+
"parent_branch",
|
|
233
|
+
"lakebase_branch_uid",
|
|
234
|
+
"claimed_at"
|
|
235
|
+
],
|
|
236
|
+
"pr-ready": [
|
|
237
|
+
"feature_id",
|
|
238
|
+
"branch",
|
|
239
|
+
"parent_branch",
|
|
240
|
+
"lakebase_branch_uid",
|
|
241
|
+
"claimed_at",
|
|
242
|
+
"pr_url",
|
|
243
|
+
"pushed_at"
|
|
244
|
+
],
|
|
245
|
+
"ci-green": [
|
|
246
|
+
"feature_id",
|
|
247
|
+
"branch",
|
|
248
|
+
"parent_branch",
|
|
249
|
+
"lakebase_branch_uid",
|
|
250
|
+
"claimed_at",
|
|
251
|
+
"pr_url",
|
|
252
|
+
"pushed_at",
|
|
253
|
+
"ci_run_url",
|
|
254
|
+
"ci_green_at"
|
|
255
|
+
],
|
|
256
|
+
merged: [
|
|
257
|
+
"feature_id",
|
|
258
|
+
"branch",
|
|
259
|
+
"parent_branch",
|
|
260
|
+
"lakebase_branch_uid",
|
|
261
|
+
"claimed_at",
|
|
262
|
+
"pr_url",
|
|
263
|
+
"pushed_at",
|
|
264
|
+
"ci_run_url",
|
|
265
|
+
"ci_green_at",
|
|
266
|
+
"merged_at"
|
|
267
|
+
]
|
|
268
|
+
};
|
|
269
|
+
if (typeof v.state === "string" && SCM_STATES.includes(v.state)) {
|
|
270
|
+
for (const key of requiredForState[v.state]) {
|
|
271
|
+
if (v[key] === void 0) {
|
|
272
|
+
errors.push({
|
|
273
|
+
path: key,
|
|
274
|
+
message: `required when state is "${v.state}"`
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const allowedKeys = /* @__PURE__ */ new Set([
|
|
280
|
+
"$schema",
|
|
281
|
+
"version",
|
|
282
|
+
"state",
|
|
283
|
+
"tier_topology",
|
|
284
|
+
"project_id",
|
|
285
|
+
"feature_id",
|
|
286
|
+
"branch",
|
|
287
|
+
"parent_branch",
|
|
288
|
+
"lakebase_branch_uid",
|
|
289
|
+
"claimed_at",
|
|
290
|
+
"pr_url",
|
|
291
|
+
"pushed_at",
|
|
292
|
+
"ci_run_url",
|
|
293
|
+
"ci_green_at",
|
|
294
|
+
"merged_at",
|
|
295
|
+
"migrate_run_url",
|
|
296
|
+
"migrate_completed_at"
|
|
297
|
+
]);
|
|
298
|
+
for (const key of Object.keys(v)) {
|
|
299
|
+
if (!allowedKeys.has(key)) {
|
|
300
|
+
errors.push({ path: key, message: "unknown property" });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
304
|
+
return { ok: true, value: v };
|
|
305
|
+
}
|
|
306
|
+
function orderForOutput(state) {
|
|
307
|
+
const keyOrder = [
|
|
308
|
+
"$schema",
|
|
309
|
+
"version",
|
|
310
|
+
"state",
|
|
311
|
+
"tier_topology",
|
|
312
|
+
"project_id",
|
|
313
|
+
"feature_id",
|
|
314
|
+
"branch",
|
|
315
|
+
"parent_branch",
|
|
316
|
+
"lakebase_branch_uid",
|
|
317
|
+
"claimed_at",
|
|
318
|
+
"pr_url",
|
|
319
|
+
"pushed_at",
|
|
320
|
+
"ci_run_url",
|
|
321
|
+
"ci_green_at",
|
|
322
|
+
"merged_at",
|
|
323
|
+
"migrate_run_url",
|
|
324
|
+
"migrate_completed_at"
|
|
325
|
+
];
|
|
326
|
+
const out = {};
|
|
327
|
+
for (const k of keyOrder) {
|
|
328
|
+
if (state[k] !== void 0) {
|
|
329
|
+
out[k] = state[k];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// scripts/lakebase/scm-release-claim.ts
|
|
336
|
+
var ScmReleaseError = class extends Error {
|
|
337
|
+
constructor(message, code) {
|
|
338
|
+
super(message);
|
|
339
|
+
this.code = code;
|
|
340
|
+
this.name = "ScmReleaseError";
|
|
341
|
+
}
|
|
342
|
+
code;
|
|
343
|
+
};
|
|
344
|
+
async function prepareClaimRelease(args, verb) {
|
|
345
|
+
const current = readWorkflowState(args.projectDir);
|
|
346
|
+
if (!current) {
|
|
347
|
+
throw new ScmReleaseError(
|
|
348
|
+
`No SCM workflow state found at ${args.projectDir}/.lakebase/workflow-state.json.`,
|
|
349
|
+
"no-state-file"
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
if (current.state !== "feature-claimed") {
|
|
353
|
+
throw new ScmReleaseError(
|
|
354
|
+
`${verb} refuses state "${current.state}". Only feature-claimed is ${verb}able; later states must complete or be reverted via gh.`,
|
|
355
|
+
"bad-precondition"
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (!current.feature_id || !current.branch || !current.parent_branch || !current.lakebase_branch_uid) {
|
|
359
|
+
throw new ScmReleaseError(
|
|
360
|
+
`feature-claimed row is missing required invariants. Cannot ${verb} safely; consider re-adopting state first.`,
|
|
361
|
+
"missing-claim-fields"
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
if (!args.force) {
|
|
365
|
+
const dirty = await isDirty({ cwd: args.projectDir });
|
|
366
|
+
if (dirty) {
|
|
367
|
+
throw new ScmReleaseError(
|
|
368
|
+
`Working tree has uncommitted changes; refusing to ${verb} (switching HEAD off the feature branch would strand them). Commit / stash / discard first, or pass --force.`,
|
|
369
|
+
"dirty-working-tree"
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const switchTo = args.switchTo ?? current.parent_branch;
|
|
374
|
+
const warnings = [];
|
|
375
|
+
const headBranch = await getCurrentBranch({ cwd: args.projectDir });
|
|
376
|
+
if (headBranch === current.branch) {
|
|
377
|
+
try {
|
|
378
|
+
await exec2(`git checkout ${JSON.stringify(switchTo)}`, {
|
|
379
|
+
cwd: args.projectDir,
|
|
380
|
+
timeout: 1e4
|
|
381
|
+
});
|
|
382
|
+
} catch (err) {
|
|
383
|
+
warnings.push(
|
|
384
|
+
`git checkout ${switchTo} failed: ${err instanceof Error ? err.message : String(err)}. Local branch state may be left on the feature branch.`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return { current, switchTo, warnings };
|
|
389
|
+
}
|
|
390
|
+
function resetToScaffoldComplete(projectDir, current) {
|
|
391
|
+
const reset = {
|
|
392
|
+
$schema: current.$schema,
|
|
393
|
+
version: 1,
|
|
394
|
+
state: "scaffold-complete",
|
|
395
|
+
tier_topology: current.tier_topology,
|
|
396
|
+
project_id: current.project_id
|
|
397
|
+
};
|
|
398
|
+
writeWorkflowState(projectDir, reset);
|
|
399
|
+
return reset;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// scripts/lakebase/scm-park-feature.ts
|
|
403
|
+
async function parkFeatureBranch(args) {
|
|
404
|
+
const { current, switchTo, warnings } = await prepareClaimRelease(
|
|
405
|
+
{ projectDir: args.projectDir, force: args.force, switchTo: args.switchTo },
|
|
406
|
+
"park"
|
|
407
|
+
);
|
|
408
|
+
const reset = resetToScaffoldComplete(args.projectDir, current);
|
|
409
|
+
return {
|
|
410
|
+
state: reset,
|
|
411
|
+
keptBranch: current.branch,
|
|
412
|
+
parkedFeatureId: current.feature_id,
|
|
413
|
+
switchedTo: switchTo,
|
|
414
|
+
warnings
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// scripts/lakebase/scm-park-feature.cli.ts
|
|
419
|
+
function parseArgs(argv) {
|
|
420
|
+
const out = {};
|
|
421
|
+
for (let i = 0; i < argv.length; i++) {
|
|
422
|
+
const a = argv[i];
|
|
423
|
+
switch (a) {
|
|
424
|
+
case "--project-dir":
|
|
425
|
+
case "--cwd":
|
|
426
|
+
out.projectDir = argv[++i];
|
|
427
|
+
break;
|
|
428
|
+
case "--switch-to":
|
|
429
|
+
out.switchTo = argv[++i];
|
|
430
|
+
break;
|
|
431
|
+
case "--force":
|
|
432
|
+
out.force = true;
|
|
433
|
+
break;
|
|
434
|
+
case "--json":
|
|
435
|
+
out.json = true;
|
|
436
|
+
break;
|
|
437
|
+
case "--pretty":
|
|
438
|
+
out.pretty = true;
|
|
439
|
+
break;
|
|
440
|
+
case "--help":
|
|
441
|
+
case "-h":
|
|
442
|
+
out.help = true;
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
var HELP = `lakebase-scm-park-feature (phase B+)
|
|
449
|
+
|
|
450
|
+
Release a feature claim but KEEP its paired branch: switch HEAD to the parent
|
|
451
|
+
tier, reset workflow-state to scaffold-complete, leave the Lakebase + git
|
|
452
|
+
branch intact. Frees the single SCM claim so another feature can be claimed
|
|
453
|
+
and driven; resume the parked feature later with
|
|
454
|
+
'lakebase-scm-claim-feature-branch <feature>' (it reuses the kept branch).
|
|
455
|
+
|
|
456
|
+
Usage:
|
|
457
|
+
lakebase-scm-park-feature [flags]
|
|
458
|
+
|
|
459
|
+
Flags:
|
|
460
|
+
--project-dir <dir> Project root (default: cwd)
|
|
461
|
+
--switch-to <branch> Branch to checkout after releasing (default: parent_branch)
|
|
462
|
+
--force Allow park even with a dirty working tree
|
|
463
|
+
(edits are kept but stranded on the feature branch)
|
|
464
|
+
--json Machine-readable JSON output
|
|
465
|
+
--pretty Pretty-print JSON (only with --json)
|
|
466
|
+
-h, --help Show this help
|
|
467
|
+
|
|
468
|
+
Exit codes:
|
|
469
|
+
0 = parked (claim freed, branch kept, state reset to scaffold-complete)
|
|
470
|
+
1 = no state file
|
|
471
|
+
2 = precondition refused (wrong state, dirty tree without --force, missing fields)
|
|
472
|
+
3 = substrate failure
|
|
473
|
+
`;
|
|
474
|
+
function renderHuman(report) {
|
|
475
|
+
if (!report.ok) {
|
|
476
|
+
return `lakebase-scm-park-feature: ${report.error?.code}
|
|
477
|
+
|
|
478
|
+
${report.error?.message}`;
|
|
479
|
+
}
|
|
480
|
+
const r = report.result;
|
|
481
|
+
const lines = ["Feature parked (branch kept):"];
|
|
482
|
+
lines.push(` state : ${r.state.state}`);
|
|
483
|
+
lines.push(` parked_feature : ${r.parkedFeatureId}`);
|
|
484
|
+
lines.push(` kept_branch : ${r.keptBranch}`);
|
|
485
|
+
lines.push(` switched_to : ${r.switchedTo}`);
|
|
486
|
+
lines.push("");
|
|
487
|
+
lines.push(` resume with : lakebase-scm-claim-feature-branch ${r.parkedFeatureId}`);
|
|
488
|
+
if (r.warnings.length > 0) {
|
|
489
|
+
lines.push("");
|
|
490
|
+
lines.push("warnings:");
|
|
491
|
+
for (const w of r.warnings) lines.push(` - ${w}`);
|
|
492
|
+
}
|
|
493
|
+
return lines.join("\n");
|
|
494
|
+
}
|
|
495
|
+
function exitCodeForError(err) {
|
|
496
|
+
if (err instanceof ScmReleaseError) {
|
|
497
|
+
if (err.code === "no-state-file") return 1;
|
|
498
|
+
return 2;
|
|
499
|
+
}
|
|
500
|
+
return 3;
|
|
501
|
+
}
|
|
502
|
+
async function runScmParkFeatureCli(argv) {
|
|
503
|
+
const args = parseArgs(argv);
|
|
504
|
+
if (args.help) {
|
|
505
|
+
process.stdout.write(`${HELP}
|
|
506
|
+
`);
|
|
507
|
+
return 0;
|
|
508
|
+
}
|
|
509
|
+
const projectDir = path2.resolve(args.projectDir ?? process.cwd());
|
|
510
|
+
try {
|
|
511
|
+
const result = await parkFeatureBranch({
|
|
512
|
+
projectDir,
|
|
513
|
+
switchTo: args.switchTo,
|
|
514
|
+
force: args.force
|
|
515
|
+
});
|
|
516
|
+
const report = { ok: true, result };
|
|
517
|
+
if (args.json) {
|
|
518
|
+
const indent = args.pretty ? 2 : 0;
|
|
519
|
+
process.stdout.write(`${JSON.stringify(report, null, indent)}
|
|
520
|
+
`);
|
|
521
|
+
} else {
|
|
522
|
+
process.stdout.write(`${renderHuman(report)}
|
|
523
|
+
`);
|
|
524
|
+
}
|
|
525
|
+
return 0;
|
|
526
|
+
} catch (e) {
|
|
527
|
+
const err = e;
|
|
528
|
+
const code = err instanceof ScmReleaseError ? err.code : "substrate-failure";
|
|
529
|
+
const report = { ok: false, error: { code, message: err.message } };
|
|
530
|
+
if (args.json) {
|
|
531
|
+
const indent = args.pretty ? 2 : 0;
|
|
532
|
+
process.stdout.write(`${JSON.stringify(report, null, indent)}
|
|
533
|
+
`);
|
|
534
|
+
} else {
|
|
535
|
+
process.stderr.write(`${renderHuman(report)}
|
|
536
|
+
`);
|
|
537
|
+
}
|
|
538
|
+
return exitCodeForError(err);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (isCliEntry(import.meta.url)) {
|
|
542
|
+
void runScmParkFeatureCli(process.argv.slice(2)).then((c) => process.exit(c));
|
|
543
|
+
}
|
|
544
|
+
export {
|
|
545
|
+
runScmParkFeatureCli
|
|
546
|
+
};
|
|
@@ -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.3.
|
|
79292
|
-
cached = "0.3.
|
|
79291
|
+
if ("0.3.7".length > 0) {
|
|
79292
|
+
cached = "0.3.7";
|
|
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.3.
|
|
393
|
-
cached = "0.3.
|
|
392
|
+
if ("0.3.7".length > 0) {
|
|
393
|
+
cached = "0.3.7";
|
|
394
394
|
return cached;
|
|
395
395
|
}
|
|
396
396
|
cached = "unknown";
|
|
@@ -1667,8 +1667,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
|
|
|
1667
1667
|
var cached;
|
|
1668
1668
|
function substrateSelfVersion() {
|
|
1669
1669
|
if (cached !== void 0) return cached;
|
|
1670
|
-
if ("0.3.
|
|
1671
|
-
cached = "0.3.
|
|
1670
|
+
if ("0.3.7".length > 0) {
|
|
1671
|
+
cached = "0.3.7";
|
|
1672
1672
|
return cached;
|
|
1673
1673
|
}
|
|
1674
1674
|
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.3.
|
|
1634
|
-
cached = "0.3.
|
|
1633
|
+
if ("0.3.7".length > 0) {
|
|
1634
|
+
cached = "0.3.7";
|
|
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.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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,
|
|
@@ -97,6 +97,7 @@
|
|
|
97
97
|
"lakebase-schema-migrate": "./dist/scripts/lakebase/schema-migrate.cli.js",
|
|
98
98
|
"lakebase-scm-abandon-feature": "./dist/scripts/lakebase/scm-abandon-feature.cli.js",
|
|
99
99
|
"lakebase-scm-adopt-state": "./dist/scripts/lakebase/scm-adopt-state.cli.js",
|
|
100
|
+
"lakebase-scm-park-feature": "./dist/scripts/lakebase/scm-park-feature.cli.js",
|
|
100
101
|
"lakebase-scm-claim-feature-branch": "./dist/scripts/lakebase/scm-claim-feature.cli.js",
|
|
101
102
|
"lakebase-scm-cleanup": "./dist/scripts/lakebase/scm-cleanup.cli.js",
|
|
102
103
|
"lakebase-scm-doctor": "./dist/scripts/lakebase/scm-doctor.cli.js",
|
|
@@ -202,6 +202,19 @@ const RUN_TESTS_E2E_MARKER = "# run Playwright E2E suite when configured";
|
|
|
202
202
|
*/
|
|
203
203
|
const CLIENT_E2E_MARKER = "# run client Playwright E2E suite when configured";
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The markers matched ONLY as standalone lines (the appended block's own opening
|
|
207
|
+
* comment lines), never as substrings. The base run-tests.sh TEMPLATE documents the
|
|
208
|
+
* E2E-assembly contract in a header comment that QUOTES RUN_TESTS_E2E_MARKER verbatim
|
|
209
|
+
* inside backticks; a substring `includes`/`indexOf` match there matched the prose
|
|
210
|
+
* mention and truncated the ENTIRE primary-suite body (the run-tests.sh clobber that
|
|
211
|
+
* silently made every honest-GREEN + deploy verify exit 0). A line-anchored (`^…$`,
|
|
212
|
+
* multiline) match only ever finds the real block, never the documentation comment.
|
|
213
|
+
*/
|
|
214
|
+
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
215
|
+
const RUN_TESTS_E2E_MARKER_LINE = new RegExp(`^${escapeRe(RUN_TESTS_E2E_MARKER)}$`, "m");
|
|
216
|
+
const CLIENT_E2E_MARKER_LINE = new RegExp(`^${escapeRe(CLIENT_E2E_MARKER)}$`, "m");
|
|
217
|
+
|
|
205
218
|
/** The full E2E block appended to run-tests.sh (project-root layout + Python +
|
|
206
219
|
* the client-workspace layout). Built once so the fresh-insert and the in-place
|
|
207
220
|
* upgrade write byte-identical content. */
|
|
@@ -307,16 +320,33 @@ export function addE2eToRunTestsScript(
|
|
|
307
320
|
return { patched: false, inserted: false };
|
|
308
321
|
}
|
|
309
322
|
const original = fs.readFileSync(scriptPath, "utf8");
|
|
310
|
-
|
|
311
|
-
|
|
323
|
+
// Match markers as standalone LINES, not substrings (see RUN_TESTS_E2E_MARKER_LINE):
|
|
324
|
+
// a prose mention in the template header must never be treated as the block boundary.
|
|
325
|
+
const baseMarker = RUN_TESTS_E2E_MARKER_LINE.exec(original);
|
|
326
|
+
const hasBase = baseMarker !== null;
|
|
327
|
+
const hasClient = CLIENT_E2E_MARKER_LINE.test(original);
|
|
312
328
|
if (hasBase && hasClient) {
|
|
313
329
|
return { patched: true, inserted: false }; // already the current block
|
|
314
330
|
}
|
|
315
331
|
// Truncate an OLD block (base marker present, client branch missing) so we replace
|
|
316
332
|
// rather than duplicate; else append to the end after the language-specific suite.
|
|
317
|
-
const cut = hasBase ? original.slice(0,
|
|
333
|
+
const cut = hasBase ? original.slice(0, baseMarker!.index) : original;
|
|
318
334
|
const base = cut.replace(/\n+$/, "\n");
|
|
319
|
-
|
|
335
|
+
const assembled = base + runTestsE2eBlock();
|
|
336
|
+
// FAIL-CLOSED: never ship a run-tests.sh that lost its primary suite. The base
|
|
337
|
+
// template assigns REPO_ROOT and runs the pytest/vitest body; if the ORIGINAL
|
|
338
|
+
// carried that body but the ASSEMBLED result would not, the cut dropped real
|
|
339
|
+
// content — throw rather than write a hollow, always-exit-0 harness (the clobber
|
|
340
|
+
// defect). A genuinely body-less original (nothing to preserve) is untouched.
|
|
341
|
+
const BODY_SENTINEL = 'REPO_ROOT="$(cd';
|
|
342
|
+
if (original.includes(BODY_SENTINEL) && !assembled.includes(BODY_SENTINEL)) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`addE2eToRunTestsScript: refusing to write ${scriptPath} — the E2E assembly would drop the ` +
|
|
345
|
+
`primary-suite body (REPO_ROOT/pytest/vitest). This is the marker-match clobber; the marker ` +
|
|
346
|
+
`must anchor to the appended block's own line, not a prose mention.`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
fs.writeFileSync(scriptPath, assembled, "utf8");
|
|
320
350
|
return { patched: true, inserted: true };
|
|
321
351
|
}
|
|
322
352
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { isCliEntry } from "../util/cli-entry.js";
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
ScmReleaseError,
|
|
9
9
|
abandonFeatureBranch,
|
|
10
10
|
type AbandonFeatureResult,
|
|
11
11
|
} from "./scm-abandon-feature.js";
|
|
@@ -103,7 +103,7 @@ function renderHuman(report: AbandonReport): string {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function exitCodeForError(err: Error): number {
|
|
106
|
-
if (err instanceof
|
|
106
|
+
if (err instanceof ScmReleaseError) {
|
|
107
107
|
if (err.code === "no-state-file") return 1;
|
|
108
108
|
return 2;
|
|
109
109
|
}
|
|
@@ -137,7 +137,7 @@ export async function runScmAbandonFeatureCli(
|
|
|
137
137
|
} catch (e) {
|
|
138
138
|
const err = e as Error;
|
|
139
139
|
const code =
|
|
140
|
-
err instanceof
|
|
140
|
+
err instanceof ScmReleaseError ? err.code : "substrate-failure";
|
|
141
141
|
const report: AbandonReport = {
|
|
142
142
|
ok: false,
|
|
143
143
|
error: { code, message: err.message },
|