gflows 0.1.1

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.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Status command: show current branch flow info.
3
+ * Classifies branch (feature/bugfix/chore/release/hotfix/spike or unknown),
4
+ * shows base and merge target(s), and optionally ahead/behind vs base.
5
+ * No write operations.
6
+ * @module commands/status
7
+ */
8
+
9
+ import type { BranchType, ParsedArgs } from "../types.js";
10
+ import type { ResolvedConfig } from "../types.js";
11
+ import {
12
+ getBranchTypeMeta,
13
+ resolveConfig,
14
+ } from "../config.js";
15
+ import { NotRepoError } from "../errors.js";
16
+ import {
17
+ getAheadBehind,
18
+ getCurrentBranch,
19
+ resolveRepoRoot,
20
+ } from "../git.js";
21
+
22
+ const BRANCH_TYPES: BranchType[] = [
23
+ "feature",
24
+ "bugfix",
25
+ "chore",
26
+ "release",
27
+ "hotfix",
28
+ "spike",
29
+ ];
30
+
31
+ /**
32
+ * Classifies a branch name into a workflow type, "main", "dev", or null (unknown).
33
+ * Uses resolved config for main/dev names and prefixes.
34
+ */
35
+ function classifyBranch(
36
+ branchName: string,
37
+ config: ResolvedConfig
38
+ ): BranchType | "main" | "dev" | null {
39
+ if (branchName === config.main) return "main";
40
+ if (branchName === config.dev) return "dev";
41
+ const { prefixes } = config;
42
+ for (const type of BRANCH_TYPES) {
43
+ const prefix = prefixes[type];
44
+ if (prefix && branchName.startsWith(prefix)) {
45
+ return type;
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Formats merge target for display (actual branch names).
53
+ */
54
+ function formatMergeTarget(
55
+ mergeTarget: "main" | "dev" | "main-then-dev",
56
+ config: ResolvedConfig
57
+ ): string {
58
+ if (mergeTarget === "main-then-dev") {
59
+ return `${config.main}, then ${config.dev}`;
60
+ }
61
+ return mergeTarget === "main" ? config.main : config.dev;
62
+ }
63
+
64
+ /**
65
+ * Runs the status command.
66
+ * Shows current branch, classification, base, merge target(s), and ahead/behind vs base.
67
+ * Output goes to stdout for scripts. On detached HEAD or non-repo, reports clearly and exits.
68
+ */
69
+ export async function run(args: ParsedArgs): Promise<void> {
70
+ const { cwd, dryRun, verbose, quiet } = args;
71
+
72
+ const root = await resolveRepoRoot(cwd).catch((err: unknown) => {
73
+ if (err instanceof NotRepoError) throw err;
74
+ throw err;
75
+ });
76
+
77
+ const config = resolveConfig(root, undefined, { verbose: !!verbose });
78
+ const current = await getCurrentBranch(root, {
79
+ dryRun: !!dryRun,
80
+ verbose: !!verbose,
81
+ });
82
+
83
+ if (current === null) {
84
+ if (!quiet) {
85
+ console.log("HEAD is detached.");
86
+ }
87
+ return;
88
+ }
89
+
90
+ const classification = classifyBranch(current, config);
91
+
92
+ if (!quiet) {
93
+ console.log(`Branch: ${current}`);
94
+ }
95
+
96
+ if (classification === "main") {
97
+ if (!quiet) {
98
+ console.log("Type: long-lived (main)");
99
+ }
100
+ return;
101
+ }
102
+
103
+ if (classification === "dev") {
104
+ if (!quiet) {
105
+ console.log("Type: long-lived (dev)");
106
+ }
107
+ return;
108
+ }
109
+
110
+ if (classification === null) {
111
+ if (!quiet) {
112
+ console.log("Type: unknown");
113
+ }
114
+ return;
115
+ }
116
+
117
+ const meta = getBranchTypeMeta(classification);
118
+ const baseBranch = meta.base === "main" ? config.main : config.dev;
119
+ const mergeTargetDisplay = formatMergeTarget(meta.mergeTarget, config);
120
+
121
+ if (!quiet) {
122
+ console.log(`Type: ${classification}`);
123
+ console.log(`Base: ${baseBranch}`);
124
+ console.log(`Merge target(s): ${mergeTargetDisplay}`);
125
+ }
126
+
127
+ const { ahead, behind } = await getAheadBehind(
128
+ root,
129
+ baseBranch,
130
+ current,
131
+ { dryRun: !!dryRun, verbose: !!verbose }
132
+ );
133
+
134
+ if (!quiet) {
135
+ console.log(`Ahead/behind: ${ahead} ahead, ${behind} behind`);
136
+ }
137
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Switch command: switch to a workflow branch (picker when TTY and no name, else branch name).
3
+ * @module commands/switch
4
+ */
5
+
6
+ import type { BranchType } from "../types.js";
7
+ import type { ParsedArgs } from "../types.js";
8
+ import { resolveConfig } from "../config.js";
9
+ import { EXIT_OK, EXIT_USER } from "../constants.js";
10
+ import { NotRepoError } from "../errors.js";
11
+ import {
12
+ branchList,
13
+ checkout,
14
+ resolveRepoRoot,
15
+ } from "../git.js";
16
+
17
+ const BRANCH_TYPES: BranchType[] = [
18
+ "feature",
19
+ "bugfix",
20
+ "chore",
21
+ "release",
22
+ "hotfix",
23
+ "spike",
24
+ ];
25
+
26
+ /**
27
+ * Returns local branch names that match any workflow prefix (feature/, bugfix/, etc.).
28
+ */
29
+ function getWorkflowBranches(
30
+ allBranches: string[],
31
+ prefixes: Record<BranchType, string>
32
+ ): string[] {
33
+ const prefixed = BRANCH_TYPES.map((t) => prefixes[t]).filter(Boolean);
34
+ return allBranches.filter((b) =>
35
+ prefixed.some((p) => p && b.startsWith(p))
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Run the switch command.
41
+ * With a branch name (positional or -B): checkout that branch.
42
+ * With no name and TTY: show a select picker of workflow branches; if none, exit 0 with message.
43
+ * With no name and not TTY: exit 1 with message to provide a branch name.
44
+ */
45
+ export async function run(args: ParsedArgs): Promise<void> {
46
+ const { cwd, name, branch, dryRun, quiet } = args;
47
+
48
+ const root = await resolveRepoRoot(cwd).catch((err) => {
49
+ if (err instanceof NotRepoError) throw err;
50
+ throw err;
51
+ });
52
+ const config = resolveConfig(root);
53
+
54
+ const branchName = (branch?.trim() || name?.trim() || "").trim() || undefined;
55
+
56
+ if (branchName) {
57
+ await checkout(root, branchName, {
58
+ dryRun,
59
+ verbose: args.verbose,
60
+ });
61
+ if (!quiet && !dryRun) {
62
+ console.error(`Switched to branch '${branchName}'.`);
63
+ }
64
+ return;
65
+ }
66
+
67
+ const isTTY = typeof process.stdin.isTTY === "boolean" && process.stdin.isTTY;
68
+ if (!isTTY) {
69
+ console.error(
70
+ "gflows switch: no branch name given and stdin is not a TTY. Pass a branch name (e.g. gflows switch feature/my-branch) or run from an interactive terminal."
71
+ );
72
+ process.exit(EXIT_USER);
73
+ }
74
+
75
+ const allLocal = await branchList(root, { dryRun, verbose: args.verbose });
76
+ const workflowBranches = getWorkflowBranches(allLocal, config.prefixes);
77
+
78
+ if (workflowBranches.length === 0) {
79
+ if (!quiet) {
80
+ console.error("No workflow branches found. Create one with 'gflows start <type> <name>'.");
81
+ }
82
+ process.exit(EXIT_OK);
83
+ }
84
+
85
+ const { select } = await import("@inquirer/prompts");
86
+ const chosen = await select({
87
+ message: "Switch to branch",
88
+ choices: workflowBranches.map((b) => ({ name: b, value: b })),
89
+ });
90
+
91
+ if (typeof chosen !== "string") {
92
+ process.exit(EXIT_USER);
93
+ }
94
+
95
+ await checkout(root, chosen, {
96
+ dryRun,
97
+ verbose: args.verbose,
98
+ });
99
+ if (!quiet && !dryRun) {
100
+ console.error(`Switched to branch '${chosen}'.`);
101
+ }
102
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Version command: print version from the CLI's package.json.
3
+ * @module commands/version
4
+ */
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import type { ParsedArgs } from "../types.js";
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+
13
+ /**
14
+ * Runs the version command: reads version from package.json (repo root when
15
+ * developing, package root when installed) and prints it to stdout.
16
+ * @param _args - Parsed CLI args (unused; kept for command signature consistency).
17
+ */
18
+ export async function run(_args: ParsedArgs): Promise<void> {
19
+ const pkgPath = join(__dirname, "..", "..", "package.json");
20
+ try {
21
+ const raw = readFileSync(pkgPath, "utf-8");
22
+ const pkg = JSON.parse(raw) as { version?: string };
23
+ console.log(pkg.version ?? "0.0.0");
24
+ } catch {
25
+ console.log("0.0.0");
26
+ }
27
+ }
package/src/config.ts ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Config resolution for gflows: defaults → repo config file → env → CLI overrides.
3
+ * Exposes resolved main, dev, remote, and branch type prefixes for use by commands.
4
+ * @module config
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import type {
10
+ BranchPrefixes,
11
+ BranchType,
12
+ BranchTypeMeta,
13
+ GflowsConfigFile,
14
+ ResolvedConfig,
15
+ } from "./types.js";
16
+ import {
17
+ DEFAULT_DEV,
18
+ DEFAULT_MAIN,
19
+ DEFAULT_PREFIXES,
20
+ DEFAULT_REMOTE,
21
+ } from "./constants.js";
22
+
23
+ const CONFIG_FILE = ".gflows.json";
24
+ const PACKAGE_JSON = "package.json";
25
+ const GFLOWS_KEY = "gflows";
26
+
27
+ const ENV_MAIN = "GFLOWS_MAIN";
28
+ const ENV_DEV = "GFLOWS_DEV";
29
+ const ENV_REMOTE = "GFLOWS_REMOTE";
30
+
31
+ /** CLI overrides for main, dev, and remote (e.g. from -R/--remote or future flags). */
32
+ export interface ConfigCliOverrides {
33
+ main?: string;
34
+ dev?: string;
35
+ remote?: string;
36
+ }
37
+
38
+ /** Result of reading config file: config if valid, plus whether parsing failed (for verbose warning). */
39
+ export interface ReadConfigResult {
40
+ config: GflowsConfigFile | null;
41
+ /** True when a config file or package.json "gflows" key was present but invalid. */
42
+ invalid: boolean;
43
+ }
44
+
45
+ /** Options for config resolution. */
46
+ export interface ResolveConfigOptions {
47
+ /** If true, warn to stderr when config file is invalid. */
48
+ verbose?: boolean;
49
+ }
50
+
51
+ /**
52
+ * Reads and parses repo config from a directory: .gflows.json or package.json "gflows" key.
53
+ * Returns config when valid; invalid JSON or wrong types yield null with invalid: true for warning.
54
+ */
55
+ export function readConfigFile(dir: string): ReadConfigResult {
56
+ const gflowsPath = join(dir, CONFIG_FILE);
57
+ if (existsSync(gflowsPath)) {
58
+ try {
59
+ const raw = readFileSync(gflowsPath, "utf-8");
60
+ const data = JSON.parse(raw) as unknown;
61
+ const config = normalizeConfigFile(data);
62
+ return { config, invalid: config === null };
63
+ } catch {
64
+ return { config: null, invalid: true };
65
+ }
66
+ }
67
+
68
+ const pkgPath = join(dir, PACKAGE_JSON);
69
+ if (existsSync(pkgPath)) {
70
+ try {
71
+ const raw = readFileSync(pkgPath, "utf-8");
72
+ const pkg = JSON.parse(raw) as Record<string, unknown>;
73
+ const data = pkg[GFLOWS_KEY];
74
+ if (data === undefined || data === null) {
75
+ return { config: null, invalid: false };
76
+ }
77
+ const config = normalizeConfigFile(data);
78
+ return { config, invalid: config === null };
79
+ } catch {
80
+ return { config: null, invalid: true };
81
+ }
82
+ }
83
+
84
+ return { config: null, invalid: false };
85
+ }
86
+
87
+ /**
88
+ * Normalizes and validates parsed config data. Returns a valid partial config or null if invalid.
89
+ */
90
+ function normalizeConfigFile(data: unknown): GflowsConfigFile | null {
91
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
92
+ return null;
93
+ }
94
+ const obj = data as Record<string, unknown>;
95
+ const out: GflowsConfigFile = {};
96
+
97
+ if (typeof obj.main === "string" && obj.main.trim() !== "") {
98
+ out.main = obj.main.trim();
99
+ }
100
+ if (typeof obj.dev === "string" && obj.dev.trim() !== "") {
101
+ out.dev = obj.dev.trim();
102
+ }
103
+ if (typeof obj.remote === "string" && obj.remote.trim() !== "") {
104
+ out.remote = obj.remote.trim();
105
+ }
106
+ if (obj.prefixes !== undefined && obj.prefixes !== null && typeof obj.prefixes === "object" && !Array.isArray(obj.prefixes)) {
107
+ const prefs = obj.prefixes as Record<string, unknown>;
108
+ const prefixes: BranchPrefixes = {};
109
+ const keys: (keyof BranchPrefixes)[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
110
+ for (const k of keys) {
111
+ const v = prefs[k];
112
+ if (typeof v === "string" && v.trim() !== "") {
113
+ prefixes[k] = v.trim();
114
+ }
115
+ }
116
+ if (Object.keys(prefixes).length > 0) {
117
+ out.prefixes = prefixes;
118
+ }
119
+ }
120
+
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * Returns config overrides from environment (GFLOWS_MAIN, GFLOWS_DEV, GFLOWS_REMOTE).
126
+ */
127
+ export function getEnvConfigOverrides(): ConfigCliOverrides {
128
+ const overrides: ConfigCliOverrides = {};
129
+ const main = process.env[ENV_MAIN];
130
+ if (typeof main === "string" && main.trim() !== "") {
131
+ overrides.main = main.trim();
132
+ }
133
+ const dev = process.env[ENV_DEV];
134
+ if (typeof dev === "string" && dev.trim() !== "") {
135
+ overrides.dev = dev.trim();
136
+ }
137
+ const remote = process.env[ENV_REMOTE];
138
+ if (typeof remote === "string" && remote.trim() !== "") {
139
+ overrides.remote = remote.trim();
140
+ }
141
+ return overrides;
142
+ }
143
+
144
+ /**
145
+ * Merges prefix overrides into a full Required<BranchPrefixes> (defaults + overrides).
146
+ */
147
+ function mergePrefixes(overrides?: BranchPrefixes): Required<BranchPrefixes> {
148
+ const result: Required<BranchPrefixes> = { ...DEFAULT_PREFIXES };
149
+ if (!overrides) return result;
150
+ const keys: (keyof BranchPrefixes)[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
151
+ for (const k of keys) {
152
+ if (typeof overrides[k] === "string" && overrides[k]!.trim() !== "") {
153
+ result[k] = overrides[k]!.trim();
154
+ }
155
+ }
156
+ return result;
157
+ }
158
+
159
+ /**
160
+ * Resolves full config for the given directory: defaults → file → env → CLI.
161
+ * Uses dir as the repo root for locating .gflows.json and package.json.
162
+ *
163
+ * @param dir - Directory to read config from (e.g. cwd or resolved -C path).
164
+ * @param cliOverrides - Optional overrides from CLI (e.g. --remote).
165
+ * @param options - Optional { verbose } to warn when config file is missing or invalid.
166
+ * @returns Resolved config with main, dev, remote, and full prefixes.
167
+ */
168
+ export function resolveConfig(
169
+ dir: string,
170
+ cliOverrides?: ConfigCliOverrides,
171
+ options?: ResolveConfigOptions
172
+ ): ResolvedConfig {
173
+ const verbose = options?.verbose === true;
174
+
175
+ let main = DEFAULT_MAIN;
176
+ let dev = DEFAULT_DEV;
177
+ let remote = DEFAULT_REMOTE;
178
+ let prefixes = mergePrefixes(undefined);
179
+
180
+ const readResult = readConfigFile(dir);
181
+ if (readResult.invalid && verbose) {
182
+ console.error("gflows: invalid or empty config file; using defaults.");
183
+ }
184
+ const file = readResult.config;
185
+ if (file) {
186
+ if (file.main !== undefined) main = file.main;
187
+ if (file.dev !== undefined) dev = file.dev;
188
+ if (file.remote !== undefined) remote = file.remote;
189
+ if (file.prefixes !== undefined) prefixes = mergePrefixes(file.prefixes);
190
+ }
191
+
192
+ const envOverrides = getEnvConfigOverrides();
193
+ if (envOverrides.main !== undefined) main = envOverrides.main;
194
+ if (envOverrides.dev !== undefined) dev = envOverrides.dev;
195
+ if (envOverrides.remote !== undefined) remote = envOverrides.remote;
196
+
197
+ if (cliOverrides?.main !== undefined) main = cliOverrides.main;
198
+ if (cliOverrides?.dev !== undefined) dev = cliOverrides.dev;
199
+ if (cliOverrides?.remote !== undefined) remote = cliOverrides.remote;
200
+
201
+ return { main, dev, remote, prefixes };
202
+ }
203
+
204
+ /**
205
+ * Returns the branch name prefix for a given branch type from resolved config.
206
+ */
207
+ export function getPrefixForType(
208
+ config: ResolvedConfig,
209
+ type: BranchType
210
+ ): string {
211
+ return config.prefixes[type] ?? DEFAULT_PREFIXES[type];
212
+ }
213
+
214
+ /** Metadata per branch type: base, merge target, and whether to tag on finish. */
215
+ const BRANCH_TYPE_META: Record<BranchType, BranchTypeMeta> = {
216
+ feature: { base: "dev", mergeTarget: "dev", tagOnFinish: false },
217
+ bugfix: { base: "dev", mergeTarget: "dev", tagOnFinish: false },
218
+ chore: { base: "dev", mergeTarget: "dev", tagOnFinish: false },
219
+ release: { base: "dev", mergeTarget: "main-then-dev", tagOnFinish: true },
220
+ hotfix: { base: "main", mergeTarget: "main-then-dev", tagOnFinish: true },
221
+ spike: { base: "dev", mergeTarget: "dev", tagOnFinish: false },
222
+ };
223
+
224
+ /**
225
+ * Returns metadata for a branch type (base, merge target, tag on finish).
226
+ */
227
+ export function getBranchTypeMeta(type: BranchType): BranchTypeMeta {
228
+ return BRANCH_TYPE_META[type];
229
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Exit codes and default branch names/prefixes for gflows.
3
+ * @module constants
4
+ */
5
+
6
+ /** Exit code: success. */
7
+ export const EXIT_OK = 0;
8
+
9
+ /** Exit code: usage/validation error (missing args, invalid type, invalid branch name/version). */
10
+ export const EXIT_USER = 1;
11
+
12
+ /** Exit code: Git or system error (not a repo, branch missing, merge failed, tag exists, etc.). */
13
+ export const EXIT_GIT = 2;
14
+
15
+ /** Default long-lived main branch name. */
16
+ export const DEFAULT_MAIN = "main";
17
+
18
+ /** Default long-lived development branch name. */
19
+ export const DEFAULT_DEV = "dev";
20
+
21
+ /** Default remote name. */
22
+ export const DEFAULT_REMOTE = "origin";
23
+
24
+ /** Default branch prefix per type (trailing slash for consistency). */
25
+ export const DEFAULT_PREFIXES = {
26
+ feature: "feature/",
27
+ bugfix: "bugfix/",
28
+ chore: "chore/",
29
+ release: "release/",
30
+ hotfix: "hotfix/",
31
+ spike: "spike/",
32
+ } as const;
33
+
34
+ /** Semver version pattern: optional leading 'v', then X.Y.Z. */
35
+ export const VERSION_REGEX = /^v?\d+\.\d+\.\d+$/;
36
+
37
+ /** Characters invalid in Git ref names (branch names). */
38
+ export const INVALID_BRANCH_CHARS = /\.\.|[\s~^?:*\[\]\\]/;
package/src/errors.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Typed errors for gflows CLI. Each maps to an exit code:
3
+ * - Validation/usage (InvalidVersionError, InvalidBranchNameError) → EXIT_USER (1)
4
+ * - Git/repo/state (others) → EXIT_GIT (2)
5
+ * @module errors
6
+ */
7
+
8
+ import { EXIT_GIT, EXIT_USER } from "./constants.js";
9
+
10
+ /** Base error for gflows with a stable exit code. */
11
+ export class GflowsError extends Error {
12
+ /** Exit code to use when this error is thrown (1 = user, 2 = git). */
13
+ readonly exitCode: number;
14
+
15
+ constructor(message: string, exitCode: number) {
16
+ super(message);
17
+ this.name = this.constructor.name;
18
+ Object.setPrototypeOf(this, new.target.prototype);
19
+ this.exitCode = exitCode;
20
+ }
21
+ }
22
+
23
+ /** Thrown when cwd (or -C) is not a Git repository. */
24
+ export class NotRepoError extends GflowsError {
25
+ constructor(message = "Not a Git repository.") {
26
+ super(message, EXIT_GIT);
27
+ }
28
+ }
29
+
30
+ /** Thrown when a required branch does not exist (local or after fetch). */
31
+ export class BranchNotFoundError extends GflowsError {
32
+ constructor(message: string) {
33
+ super(message, EXIT_GIT);
34
+ }
35
+ }
36
+
37
+ /** Thrown when start is run with uncommitted changes and without --force. */
38
+ export class DirtyWorkingTreeError extends GflowsError {
39
+ constructor(message = "Working tree has uncommitted changes. Commit or stash them, or use --force.") {
40
+ super(message, EXIT_GIT);
41
+ }
42
+ }
43
+
44
+ /** Thrown when an operation requires a branch but HEAD is detached. */
45
+ export class DetachedHeadError extends GflowsError {
46
+ constructor(message = "HEAD is detached. Checkout a branch first.") {
47
+ super(message, EXIT_GIT);
48
+ }
49
+ }
50
+
51
+ /** Thrown when a rebase or merge is in progress; user must complete or abort first. */
52
+ export class RebaseMergeInProgressError extends GflowsError {
53
+ constructor(
54
+ message = "A rebase or merge is in progress. Complete or abort it before running this command."
55
+ ) {
56
+ super(message, EXIT_GIT);
57
+ }
58
+ }
59
+
60
+ /** Thrown when merge fails due to conflicts; user must resolve manually. */
61
+ export class MergeConflictError extends GflowsError {
62
+ constructor(message: string) {
63
+ super(message, EXIT_GIT);
64
+ }
65
+ }
66
+
67
+ /** Thrown when release/hotfix version does not match expected format (vX.Y.Z or X.Y.Z). */
68
+ export class InvalidVersionError extends GflowsError {
69
+ constructor(message: string) {
70
+ super(message, EXIT_USER);
71
+ }
72
+ }
73
+
74
+ /** Thrown when branch name is empty, whitespace, or contains invalid ref characters. */
75
+ export class InvalidBranchNameError extends GflowsError {
76
+ constructor(message: string) {
77
+ super(message, EXIT_USER);
78
+ }
79
+ }
80
+
81
+ /** Thrown when delete is attempted on the configured main or dev branch. */
82
+ export class CannotDeleteMainOrDevError extends GflowsError {
83
+ constructor(message = "Cannot delete the long-lived branch main or dev.") {
84
+ super(message, EXIT_GIT);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Returns the exit code for an error: use error.exitCode if it's a GflowsError, else EXIT_GIT.
90
+ */
91
+ export function exitCodeForError(error: unknown): number {
92
+ if (error instanceof GflowsError) {
93
+ return error.exitCode;
94
+ }
95
+ return EXIT_GIT;
96
+ }