@databricks-solutions/lakebase-scm-utils 0.3.6 → 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 +40 -21
- package/dist/scripts/index.d.cts +1 -1
- package/dist/scripts/index.d.ts +1 -1
- package/dist/scripts/index.js +39 -21
- 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 +40 -21
- package/dist/scripts/lakebase/index.d.cts +13 -4
- package/dist/scripts/lakebase/index.d.ts +13 -4
- package/dist/scripts/lakebase/index.js +39 -21
- 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/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
|
@@ -13,28 +13,19 @@
|
|
|
13
13
|
// completes the state reset.
|
|
14
14
|
|
|
15
15
|
import { deletePairedBranch } from "./paired-branch.js";
|
|
16
|
-
import {
|
|
17
|
-
import { isDirty } from "../git/status.js";
|
|
18
|
-
import { exec } from "../util/exec.js";
|
|
16
|
+
import { type ScmWorkflowState } from "./scm-workflow-state.js";
|
|
19
17
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from "./scm-
|
|
18
|
+
ScmReleaseError,
|
|
19
|
+
prepareClaimRelease,
|
|
20
|
+
resetToScaffoldComplete,
|
|
21
|
+
} from "./scm-release-claim.js";
|
|
24
22
|
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
| "dirty-working-tree"
|
|
32
|
-
| "missing-claim-fields",
|
|
33
|
-
) {
|
|
34
|
-
super(message);
|
|
35
|
-
this.name = "ScmAbandonError";
|
|
36
|
-
}
|
|
37
|
-
}
|
|
23
|
+
export { ScmReleaseError };
|
|
24
|
+
|
|
25
|
+
/** Back-compat alias: abandon historically threw its own error class. It is now a subclass of the
|
|
26
|
+
* shared `ScmReleaseError` so existing `instanceof ScmAbandonError` checks still narrow, while the
|
|
27
|
+
* shared release core (which both abandon and park use) throws the base. */
|
|
28
|
+
export class ScmAbandonError extends ScmReleaseError {}
|
|
38
29
|
|
|
39
30
|
export interface AbandonFeatureArgs {
|
|
40
31
|
projectDir: string;
|
|
@@ -64,76 +55,21 @@ export interface AbandonFeatureResult {
|
|
|
64
55
|
export async function abandonFeatureBranch(
|
|
65
56
|
args: AbandonFeatureArgs,
|
|
66
57
|
): Promise<AbandonFeatureResult> {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
if (current.state !== "feature-claimed") {
|
|
75
|
-
throw new ScmAbandonError(
|
|
76
|
-
`abandon refuses state "${current.state}". Only feature-claimed is abandonable; later states must complete or be reverted via gh.`,
|
|
77
|
-
"bad-precondition",
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
if (
|
|
81
|
-
!current.feature_id ||
|
|
82
|
-
!current.branch ||
|
|
83
|
-
!current.parent_branch ||
|
|
84
|
-
!current.lakebase_branch_uid
|
|
85
|
-
) {
|
|
86
|
-
throw new ScmAbandonError(
|
|
87
|
-
"feature-claimed row is missing required invariants. Cannot abandon safely; consider re-adopting state first.",
|
|
88
|
-
"missing-claim-fields",
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (!args.force) {
|
|
93
|
-
const dirty = await isDirty({ cwd: args.projectDir });
|
|
94
|
-
if (dirty) {
|
|
95
|
-
throw new ScmAbandonError(
|
|
96
|
-
"Working tree has uncommitted changes; refusing to abandon (the branch delete would lose them). Commit / stash / discard first, or pass --force.",
|
|
97
|
-
"dirty-working-tree",
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const instance = args.instance ?? current.project_id;
|
|
103
|
-
const switchTo = args.switchTo ?? current.parent_branch;
|
|
104
|
-
const warnings: string[] = [];
|
|
105
|
-
|
|
106
|
-
// Switch HEAD off the feature branch first so deletePairedBranch can
|
|
107
|
-
// delete the local git branch (it skips when the branch is checked out).
|
|
108
|
-
const headBranch = await getCurrentBranch({ cwd: args.projectDir });
|
|
109
|
-
if (headBranch === current.branch) {
|
|
110
|
-
try {
|
|
111
|
-
await exec(`git checkout ${JSON.stringify(switchTo)}`, {
|
|
112
|
-
cwd: args.projectDir,
|
|
113
|
-
timeout: 10_000,
|
|
114
|
-
});
|
|
115
|
-
} catch (err) {
|
|
116
|
-
warnings.push(
|
|
117
|
-
`git checkout ${switchTo} failed: ${err instanceof Error ? err.message : String(err)}. Local branch delete may be skipped.`,
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
58
|
+
// Shared preamble: validate the claim is releasable + switch HEAD off the feature branch.
|
|
59
|
+
const { current, warnings } = await prepareClaimRelease(
|
|
60
|
+
{ projectDir: args.projectDir, force: args.force, switchTo: args.switchTo },
|
|
61
|
+
"abandon",
|
|
62
|
+
);
|
|
121
63
|
|
|
64
|
+
// Abandon's distinguishing step: tear down the paired Lakebase + git branch.
|
|
122
65
|
const del = await deletePairedBranch({
|
|
123
|
-
instance,
|
|
66
|
+
instance: args.instance ?? current.project_id,
|
|
124
67
|
branch: current.branch,
|
|
125
68
|
cwd: args.projectDir,
|
|
126
69
|
});
|
|
127
70
|
warnings.push(...del.warnings);
|
|
128
71
|
|
|
129
|
-
const reset: ScmWorkflowState =
|
|
130
|
-
$schema: current.$schema,
|
|
131
|
-
version: 1,
|
|
132
|
-
state: "scaffold-complete",
|
|
133
|
-
tier_topology: current.tier_topology,
|
|
134
|
-
project_id: current.project_id,
|
|
135
|
-
};
|
|
136
|
-
writeWorkflowState(args.projectDir, reset);
|
|
72
|
+
const reset: ScmWorkflowState = resetToScaffoldComplete(args.projectDir, current);
|
|
137
73
|
|
|
138
74
|
return {
|
|
139
75
|
state: reset,
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI: park the in-flight feature claim (free it, KEEP the branch) and reset to scaffold-complete.
|
|
3
|
+
// The resume-later counterpart to abandon. phase B+.
|
|
4
|
+
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { isCliEntry } from "../util/cli-entry.js";
|
|
7
|
+
import { ScmReleaseError } from "./scm-release-claim.js";
|
|
8
|
+
import {
|
|
9
|
+
parkFeatureBranch,
|
|
10
|
+
type ParkFeatureResult,
|
|
11
|
+
} from "./scm-park-feature.js";
|
|
12
|
+
|
|
13
|
+
interface ParsedArgs {
|
|
14
|
+
projectDir?: string;
|
|
15
|
+
switchTo?: string;
|
|
16
|
+
force?: boolean;
|
|
17
|
+
json?: boolean;
|
|
18
|
+
pretty?: boolean;
|
|
19
|
+
help?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
23
|
+
const out: ParsedArgs = {};
|
|
24
|
+
for (let i = 0; i < argv.length; i++) {
|
|
25
|
+
const a = argv[i];
|
|
26
|
+
switch (a) {
|
|
27
|
+
case "--project-dir":
|
|
28
|
+
case "--cwd":
|
|
29
|
+
out.projectDir = argv[++i];
|
|
30
|
+
break;
|
|
31
|
+
case "--switch-to":
|
|
32
|
+
out.switchTo = argv[++i];
|
|
33
|
+
break;
|
|
34
|
+
case "--force":
|
|
35
|
+
out.force = true;
|
|
36
|
+
break;
|
|
37
|
+
case "--json":
|
|
38
|
+
out.json = true;
|
|
39
|
+
break;
|
|
40
|
+
case "--pretty":
|
|
41
|
+
out.pretty = true;
|
|
42
|
+
break;
|
|
43
|
+
case "--help":
|
|
44
|
+
case "-h":
|
|
45
|
+
out.help = true;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const HELP = `lakebase-scm-park-feature (phase B+)
|
|
53
|
+
|
|
54
|
+
Release a feature claim but KEEP its paired branch: switch HEAD to the parent
|
|
55
|
+
tier, reset workflow-state to scaffold-complete, leave the Lakebase + git
|
|
56
|
+
branch intact. Frees the single SCM claim so another feature can be claimed
|
|
57
|
+
and driven; resume the parked feature later with
|
|
58
|
+
'lakebase-scm-claim-feature-branch <feature>' (it reuses the kept branch).
|
|
59
|
+
|
|
60
|
+
Usage:
|
|
61
|
+
lakebase-scm-park-feature [flags]
|
|
62
|
+
|
|
63
|
+
Flags:
|
|
64
|
+
--project-dir <dir> Project root (default: cwd)
|
|
65
|
+
--switch-to <branch> Branch to checkout after releasing (default: parent_branch)
|
|
66
|
+
--force Allow park even with a dirty working tree
|
|
67
|
+
(edits are kept but stranded on the feature branch)
|
|
68
|
+
--json Machine-readable JSON output
|
|
69
|
+
--pretty Pretty-print JSON (only with --json)
|
|
70
|
+
-h, --help Show this help
|
|
71
|
+
|
|
72
|
+
Exit codes:
|
|
73
|
+
0 = parked (claim freed, branch kept, state reset to scaffold-complete)
|
|
74
|
+
1 = no state file
|
|
75
|
+
2 = precondition refused (wrong state, dirty tree without --force, missing fields)
|
|
76
|
+
3 = substrate failure
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
interface ParkReport {
|
|
80
|
+
ok: boolean;
|
|
81
|
+
result?: ParkFeatureResult;
|
|
82
|
+
error?: { code: string; message: string };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderHuman(report: ParkReport): string {
|
|
86
|
+
if (!report.ok) {
|
|
87
|
+
return `lakebase-scm-park-feature: ${report.error?.code}\n\n ${report.error?.message}`;
|
|
88
|
+
}
|
|
89
|
+
const r = report.result!;
|
|
90
|
+
const lines: string[] = ["Feature parked (branch kept):"];
|
|
91
|
+
lines.push(` state : ${r.state.state}`);
|
|
92
|
+
lines.push(` parked_feature : ${r.parkedFeatureId}`);
|
|
93
|
+
lines.push(` kept_branch : ${r.keptBranch}`);
|
|
94
|
+
lines.push(` switched_to : ${r.switchedTo}`);
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push(` resume with : lakebase-scm-claim-feature-branch ${r.parkedFeatureId}`);
|
|
97
|
+
if (r.warnings.length > 0) {
|
|
98
|
+
lines.push("");
|
|
99
|
+
lines.push("warnings:");
|
|
100
|
+
for (const w of r.warnings) lines.push(` - ${w}`);
|
|
101
|
+
}
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function exitCodeForError(err: Error): number {
|
|
106
|
+
if (err instanceof ScmReleaseError) {
|
|
107
|
+
if (err.code === "no-state-file") return 1;
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
return 3;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function runScmParkFeatureCli(argv: string[]): Promise<number> {
|
|
114
|
+
const args = parseArgs(argv);
|
|
115
|
+
if (args.help) {
|
|
116
|
+
process.stdout.write(`${HELP}\n`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
120
|
+
try {
|
|
121
|
+
const result = await parkFeatureBranch({
|
|
122
|
+
projectDir,
|
|
123
|
+
switchTo: args.switchTo,
|
|
124
|
+
force: args.force,
|
|
125
|
+
});
|
|
126
|
+
const report: ParkReport = { ok: true, result };
|
|
127
|
+
if (args.json) {
|
|
128
|
+
const indent = args.pretty ? 2 : 0;
|
|
129
|
+
process.stdout.write(`${JSON.stringify(report, null, indent)}\n`);
|
|
130
|
+
} else {
|
|
131
|
+
process.stdout.write(`${renderHuman(report)}\n`);
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
const err = e as Error;
|
|
136
|
+
const code = err instanceof ScmReleaseError ? err.code : "substrate-failure";
|
|
137
|
+
const report: ParkReport = { ok: false, error: { code, message: err.message } };
|
|
138
|
+
if (args.json) {
|
|
139
|
+
const indent = args.pretty ? 2 : 0;
|
|
140
|
+
process.stdout.write(`${JSON.stringify(report, null, indent)}\n`);
|
|
141
|
+
} else {
|
|
142
|
+
process.stderr.write(`${renderHuman(report)}\n`);
|
|
143
|
+
}
|
|
144
|
+
return exitCodeForError(err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isCliEntry(import.meta.url)) {
|
|
149
|
+
void runScmParkFeatureCli(process.argv.slice(2)).then((c) => process.exit(c));
|
|
150
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SCM workflow park (phase B+): release a feature claim WITHOUT tearing down the branch.
|
|
2
|
+
//
|
|
3
|
+
// Transitions feature-claimed -> scaffold-complete and switches HEAD to the parent tier, but — unlike
|
|
4
|
+
// abandon — KEEPS the paired Lakebase + git branch intact. This frees the single SCM claim so another
|
|
5
|
+
// feature can be claimed and driven, while the parked feature's work sits safely on its branch. The
|
|
6
|
+
// claim model makes RESUMING free: `lakebase-scm-claim-feature-branch <same-feature>` re-claims from
|
|
7
|
+
// scaffold-complete and `createFeaturePairedBranch` reuses the existing paired branch as-is (DB and
|
|
8
|
+
// all), picking the feature back up where it stopped.
|
|
9
|
+
//
|
|
10
|
+
// Use when a feature is built but NOT yet deployed/merged and you want to shelve it and go work on
|
|
11
|
+
// another. (Abandon is the throwaway counterpart: it deletes the branch.) Refuses on a dirty working
|
|
12
|
+
// tree by default (switching HEAD would strand uncommitted edits) unless --force.
|
|
13
|
+
//
|
|
14
|
+
// The preamble (precondition + dirty-guard + HEAD switch) and the scaffold-complete reset are the
|
|
15
|
+
// shared release core in scm-release-claim.ts, so park and abandon cannot drift.
|
|
16
|
+
|
|
17
|
+
import { type ScmWorkflowState } from "./scm-workflow-state.js";
|
|
18
|
+
import {
|
|
19
|
+
ScmReleaseError,
|
|
20
|
+
prepareClaimRelease,
|
|
21
|
+
resetToScaffoldComplete,
|
|
22
|
+
} from "./scm-release-claim.js";
|
|
23
|
+
|
|
24
|
+
export { ScmReleaseError };
|
|
25
|
+
|
|
26
|
+
export interface ParkFeatureArgs {
|
|
27
|
+
/** Project root (must contain .lakebase/workflow-state.json + .git/). */
|
|
28
|
+
projectDir: string;
|
|
29
|
+
/**
|
|
30
|
+
* Skip the dirty-working-tree refusal. Uncommitted edits are NOT lost (the branch is kept), but
|
|
31
|
+
* switching HEAD off the feature branch would leave them stranded on it, so the default refuses.
|
|
32
|
+
*/
|
|
33
|
+
force?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Switch HEAD to this branch after releasing the claim. Defaults to the workflow row's
|
|
36
|
+
* parent_branch (the tier the feature forked from).
|
|
37
|
+
*/
|
|
38
|
+
switchTo?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ParkFeatureResult {
|
|
42
|
+
state: ScmWorkflowState;
|
|
43
|
+
/** The branch left intact (git name) — resume with `lakebase-scm-claim-feature-branch`. */
|
|
44
|
+
keptBranch: string;
|
|
45
|
+
/** The feature id that was parked (for logging / the consort bookkeeping repoint). */
|
|
46
|
+
parkedFeatureId: string;
|
|
47
|
+
switchedTo: string;
|
|
48
|
+
warnings: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function parkFeatureBranch(
|
|
52
|
+
args: ParkFeatureArgs,
|
|
53
|
+
): Promise<ParkFeatureResult> {
|
|
54
|
+
// Shared preamble: validate the claim is releasable + switch HEAD off the feature branch.
|
|
55
|
+
const { current, switchTo, warnings } = await prepareClaimRelease(
|
|
56
|
+
{ projectDir: args.projectDir, force: args.force, switchTo: args.switchTo },
|
|
57
|
+
"park",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Park's distinguishing (non-)step: the paired branch is DELIBERATELY left in place — this is the
|
|
61
|
+
// whole point. Just reset the claim row so the next feature can be claimed.
|
|
62
|
+
const reset = resetToScaffoldComplete(args.projectDir, current);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
state: reset,
|
|
66
|
+
keptBranch: current.branch,
|
|
67
|
+
parkedFeatureId: current.feature_id,
|
|
68
|
+
switchedTo: switchTo,
|
|
69
|
+
warnings,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Shared core for the two ways to RELEASE an in-flight feature claim back to scaffold-complete:
|
|
2
|
+
// - abandon (scm-abandon-feature): free the claim AND tear down the paired branch.
|
|
3
|
+
// - park (scm-park-feature): free the claim but KEEP the paired branch, so the feature can
|
|
4
|
+
// be resumed later (a re-claim reuses the existing branch as-is).
|
|
5
|
+
//
|
|
6
|
+
// Both share the exact same preamble — read state, require feature-claimed, require the claim's
|
|
7
|
+
// invariants, refuse a dirty tree (switching HEAD or deleting the branch would strand uncommitted
|
|
8
|
+
// work) unless --force, then switch HEAD off the feature branch — and the exact same tail: reset the
|
|
9
|
+
// workflow-state row to a bare scaffold-complete. The ONLY difference is whether the paired branch is
|
|
10
|
+
// deleted in between. Factored here so the two commands cannot drift (DRY); each command owns only its
|
|
11
|
+
// middle step and its own CLI surface.
|
|
12
|
+
|
|
13
|
+
import { getCurrentBranch } from "../git/inspect.js";
|
|
14
|
+
import { isDirty } from "../git/status.js";
|
|
15
|
+
import { exec } from "../util/exec.js";
|
|
16
|
+
import {
|
|
17
|
+
readWorkflowState,
|
|
18
|
+
writeWorkflowState,
|
|
19
|
+
type ScmWorkflowState,
|
|
20
|
+
} from "./scm-workflow-state.js";
|
|
21
|
+
|
|
22
|
+
export type ScmReleaseErrorCode =
|
|
23
|
+
| "no-state-file"
|
|
24
|
+
| "bad-precondition"
|
|
25
|
+
| "dirty-working-tree"
|
|
26
|
+
| "missing-claim-fields";
|
|
27
|
+
|
|
28
|
+
/** The shared error for a claim release. `abandon` keeps its own `ScmAbandonError` as a subclass for
|
|
29
|
+
* back-compat; CLIs that map codes to exit statuses check `instanceof ScmReleaseError` (the base). */
|
|
30
|
+
export class ScmReleaseError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
message: string,
|
|
33
|
+
public readonly code: ScmReleaseErrorCode,
|
|
34
|
+
) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "ScmReleaseError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ReleaseClaimArgs {
|
|
41
|
+
/** Project root (must contain .lakebase/workflow-state.json + .git/). */
|
|
42
|
+
projectDir: string;
|
|
43
|
+
/** Skip the dirty-working-tree refusal. */
|
|
44
|
+
force?: boolean;
|
|
45
|
+
/** Branch to checkout before releasing. Defaults to the workflow row's parent_branch. */
|
|
46
|
+
switchTo?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A feature-claimed row with its claim invariants proven present, plus the resolved HEAD target. */
|
|
50
|
+
export interface PreparedRelease {
|
|
51
|
+
current: ScmWorkflowState & {
|
|
52
|
+
feature_id: string;
|
|
53
|
+
branch: string;
|
|
54
|
+
parent_branch: string;
|
|
55
|
+
lakebase_branch_uid: string;
|
|
56
|
+
};
|
|
57
|
+
switchTo: string;
|
|
58
|
+
warnings: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The shared preamble for abandon + park: validate the claim is releasable and switch HEAD off the
|
|
63
|
+
* feature branch so the next step (delete, or just reset) is safe. `verb` ("abandon" | "park") only
|
|
64
|
+
* shapes the human-facing refusal messages. Throws `ScmReleaseError` on any precondition failure.
|
|
65
|
+
*/
|
|
66
|
+
export async function prepareClaimRelease(
|
|
67
|
+
args: ReleaseClaimArgs,
|
|
68
|
+
verb: "abandon" | "park",
|
|
69
|
+
): Promise<PreparedRelease> {
|
|
70
|
+
const current = readWorkflowState(args.projectDir);
|
|
71
|
+
if (!current) {
|
|
72
|
+
throw new ScmReleaseError(
|
|
73
|
+
`No SCM workflow state found at ${args.projectDir}/.lakebase/workflow-state.json.`,
|
|
74
|
+
"no-state-file",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (current.state !== "feature-claimed") {
|
|
78
|
+
throw new ScmReleaseError(
|
|
79
|
+
`${verb} refuses state "${current.state}". Only feature-claimed is ${verb}able; later states must complete or be reverted via gh.`,
|
|
80
|
+
"bad-precondition",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
!current.feature_id ||
|
|
85
|
+
!current.branch ||
|
|
86
|
+
!current.parent_branch ||
|
|
87
|
+
!current.lakebase_branch_uid
|
|
88
|
+
) {
|
|
89
|
+
throw new ScmReleaseError(
|
|
90
|
+
`feature-claimed row is missing required invariants. Cannot ${verb} safely; consider re-adopting state first.`,
|
|
91
|
+
"missing-claim-fields",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!args.force) {
|
|
96
|
+
const dirty = await isDirty({ cwd: args.projectDir });
|
|
97
|
+
if (dirty) {
|
|
98
|
+
throw new ScmReleaseError(
|
|
99
|
+
`Working tree has uncommitted changes; refusing to ${verb} (switching HEAD off the feature branch would strand them). Commit / stash / discard first, or pass --force.`,
|
|
100
|
+
"dirty-working-tree",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const switchTo = args.switchTo ?? current.parent_branch;
|
|
106
|
+
const warnings: string[] = [];
|
|
107
|
+
|
|
108
|
+
// Switch HEAD off the feature branch first: abandon needs it so the local git branch can be
|
|
109
|
+
// deleted (delete skips a checked-out branch); park needs it so the tree sits on the tier, ready
|
|
110
|
+
// for the next claim. Best-effort — a checkout failure is a warning, not a hard stop.
|
|
111
|
+
const headBranch = await getCurrentBranch({ cwd: args.projectDir });
|
|
112
|
+
if (headBranch === current.branch) {
|
|
113
|
+
try {
|
|
114
|
+
await exec(`git checkout ${JSON.stringify(switchTo)}`, {
|
|
115
|
+
cwd: args.projectDir,
|
|
116
|
+
timeout: 10_000,
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
warnings.push(
|
|
120
|
+
`git checkout ${switchTo} failed: ${err instanceof Error ? err.message : String(err)}. Local branch state may be left on the feature branch.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { current: current as PreparedRelease["current"], switchTo, warnings };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reset the workflow-state row to a bare scaffold-complete (no feature in flight), preserving only
|
|
130
|
+
* the project-scoped facts (tier topology, project id). The shared tail of both abandon and park:
|
|
131
|
+
* after this the next claim can proceed.
|
|
132
|
+
*/
|
|
133
|
+
export function resetToScaffoldComplete(
|
|
134
|
+
projectDir: string,
|
|
135
|
+
current: ScmWorkflowState,
|
|
136
|
+
): ScmWorkflowState {
|
|
137
|
+
const reset: ScmWorkflowState = {
|
|
138
|
+
$schema: current.$schema,
|
|
139
|
+
version: 1,
|
|
140
|
+
state: "scaffold-complete",
|
|
141
|
+
tier_topology: current.tier_topology,
|
|
142
|
+
project_id: current.project_id,
|
|
143
|
+
};
|
|
144
|
+
writeWorkflowState(projectDir, reset);
|
|
145
|
+
return reset;
|
|
146
|
+
}
|