gflows 0.1.17 → 1.0.0

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 (48) hide show
  1. package/AGENTS.md +78 -0
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +279 -502
  4. package/llms.txt +22 -0
  5. package/package.json +30 -23
  6. package/src/cli.ts +21 -364
  7. package/src/commands/abort.ts +39 -0
  8. package/src/commands/bump.ts +10 -10
  9. package/src/commands/completion.ts +14 -4
  10. package/src/commands/config.ts +123 -0
  11. package/src/commands/continue.ts +40 -0
  12. package/src/commands/delete.ts +4 -4
  13. package/src/commands/doctor.ts +143 -0
  14. package/src/commands/finish.ts +363 -137
  15. package/src/commands/help.ts +29 -20
  16. package/src/commands/init.ts +99 -18
  17. package/src/commands/list.ts +6 -1
  18. package/src/commands/mcp.ts +238 -0
  19. package/src/commands/pr.ts +120 -0
  20. package/src/commands/schema.ts +98 -0
  21. package/src/commands/start.ts +33 -31
  22. package/src/commands/status.ts +70 -51
  23. package/src/commands/switch.ts +50 -20
  24. package/src/commands/sync.ts +160 -0
  25. package/src/commands/undo.ts +78 -0
  26. package/src/commands/version.ts +3 -15
  27. package/src/commands/viz.ts +18 -0
  28. package/src/dispatch.ts +21 -0
  29. package/src/errors.ts +55 -12
  30. package/src/flow.ts +157 -0
  31. package/src/git.ts +135 -8
  32. package/src/index.ts +24 -1
  33. package/src/interactive.ts +209 -0
  34. package/src/out.ts +11 -4
  35. package/src/package-scripts.ts +73 -0
  36. package/src/parse.ts +430 -0
  37. package/src/prompts.ts +89 -0
  38. package/src/recommend.ts +132 -0
  39. package/src/run-state.ts +165 -0
  40. package/src/tui/HubHome.tsx +343 -0
  41. package/src/tui/HubShell.tsx +186 -0
  42. package/src/tui/flows.tsx +140 -0
  43. package/src/tui/hub.ts +89 -0
  44. package/src/tui/prompts.tsx +207 -0
  45. package/src/types.ts +63 -4
  46. package/src/ui.ts +132 -0
  47. package/src/version.ts +24 -0
  48. package/src/viz.ts +294 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Sync command: update current workflow branch from its base (fetch + merge or rebase).
3
+ * Stashes dirty work when needed; supports continue via run-state.
4
+ * @module commands/sync
5
+ */
6
+
7
+ import { getBranchTypeMeta, resolveConfig } from "../config.js";
8
+ import { EXIT_USER } from "../constants.js";
9
+ import { DirtyWorkingTreeError } from "../errors.js";
10
+ import { classifyBranch, getBaseBranchName } from "../flow.js";
11
+ import {
12
+ assertNotDetached,
13
+ fetch,
14
+ getCurrentBranch,
15
+ isClean,
16
+ resolveRepoRoot,
17
+ resolveSha,
18
+ runGit,
19
+ stashPopRef,
20
+ stashPushMove,
21
+ } from "../git.js";
22
+ import { hint, success } from "../out.js";
23
+ import {
24
+ completeRun,
25
+ type GflowsRunState,
26
+ startRun,
27
+ suspendRun,
28
+ writeActiveRun,
29
+ } from "../run-state.js";
30
+ import type { ParsedArgs } from "../types.js";
31
+
32
+ /**
33
+ * Executes remaining sync steps (used by continue).
34
+ */
35
+ export async function executeSyncSteps(
36
+ repoRoot: string,
37
+ state: GflowsRunState,
38
+ opts: { dryRun: boolean; verbose: boolean; quiet: boolean },
39
+ ): Promise<void> {
40
+ let i = state.nextStep;
41
+ while (i < state.steps.length) {
42
+ const step = state.steps[i];
43
+ if (!step) break;
44
+ try {
45
+ const base = String(state.context.base);
46
+ const rebase = Boolean(state.context.rebase);
47
+ if (step.id === "update") {
48
+ if (rebase) {
49
+ const r = await runGit(["rebase", base], { cwd: repoRoot, ...opts });
50
+ if (r.exitCode !== 0) {
51
+ throw new Error(`Rebase onto '${base}' conflicted.`);
52
+ }
53
+ } else {
54
+ const r = await runGit(["merge", base], { cwd: repoRoot, ...opts });
55
+ if (r.exitCode !== 0) {
56
+ throw new Error(`Merge from '${base}' conflicted.`);
57
+ }
58
+ }
59
+ } else if (step.id === "stash-pop") {
60
+ const ref = typeof state.undo.stashRef === "string" ? state.undo.stashRef : null;
61
+ if (ref) {
62
+ await stashPopRef(repoRoot, ref, opts).catch(() => undefined);
63
+ }
64
+ }
65
+ i += 1;
66
+ state.nextStep = i;
67
+ writeActiveRun(repoRoot, { ...state, status: "running" });
68
+ } catch (err) {
69
+ const msg = err instanceof Error ? err.message : String(err);
70
+ suspendRun(repoRoot, { ...state, nextStep: i }, msg);
71
+ throw err;
72
+ }
73
+ }
74
+ completeRun(repoRoot, state);
75
+ if (!opts.quiet && !opts.dryRun) {
76
+ success(`gflows: synced with '${String(state.context.base)}'.`);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Runs sync on the current workflow branch.
82
+ */
83
+ export async function run(args: ParsedArgs): Promise<void> {
84
+ const repoRoot = await resolveRepoRoot(args.cwd);
85
+ const config = resolveConfig(
86
+ repoRoot,
87
+ { main: args.main, dev: args.dev, remote: args.remote },
88
+ { verbose: args.verbose },
89
+ );
90
+ const opts = { dryRun: args.dryRun, verbose: args.verbose, quiet: args.quiet };
91
+
92
+ await assertNotDetached(repoRoot);
93
+ const current = await getCurrentBranch(repoRoot, opts);
94
+ if (!current) {
95
+ console.error("gflows sync: HEAD is detached.");
96
+ process.exit(EXIT_USER);
97
+ }
98
+
99
+ const classification = classifyBranch(current, config);
100
+ if (classification === "main" || classification === "dev") {
101
+ // Allow updating long-lived from remote tracking
102
+ await fetch(repoRoot, config.remote, opts);
103
+ const remoteRef = `${config.remote}/${current}`;
104
+ const r = await runGit(["merge", remoteRef], { cwd: repoRoot, ...opts });
105
+ if (r.exitCode !== 0) {
106
+ console.error(`gflows sync: could not update '${current}' from ${remoteRef}.`);
107
+ process.exit(2);
108
+ }
109
+ if (!args.quiet) success(`gflows: updated '${current}' from ${remoteRef}.`);
110
+ return;
111
+ }
112
+ if (classification === null) {
113
+ console.error(`gflows sync: '${current}' is not a known workflow branch.`);
114
+ hint("Use gflows start to create a typed branch, or checkout feature/bugfix/…");
115
+ process.exit(EXIT_USER);
116
+ }
117
+
118
+ const fromMain = classification === "bugfix" && args.fromMain;
119
+ const base = getBaseBranchName(classification, fromMain, config);
120
+ // For bugfix-from-main detection on sync: if type is bugfix, prefer meta base unless fromMain
121
+ const meta = getBranchTypeMeta(classification);
122
+ const baseBranch =
123
+ classification === "hotfix" || (classification === "bugfix" && args.fromMain)
124
+ ? config.main
125
+ : meta.base === "main"
126
+ ? config.main
127
+ : config.dev;
128
+
129
+ await fetch(repoRoot, config.remote, opts);
130
+
131
+ let stashed = false;
132
+ const clean = await isClean(repoRoot, opts);
133
+ if (!clean) {
134
+ if (!args.force && !args.yes && !process.stdin.isTTY) {
135
+ throw new DirtyWorkingTreeError(
136
+ "Working tree dirty. Commit, stash, or pass --force to stash automatically during sync.",
137
+ );
138
+ }
139
+ await stashPushMove(repoRoot, opts);
140
+ stashed = true;
141
+ }
142
+
143
+ const beforeSha = await resolveSha(repoRoot, current, opts);
144
+ const steps = [
145
+ { id: "update", label: args.rebase ? `Rebase onto ${baseBranch}` : `Merge ${baseBranch}` },
146
+ ...(stashed ? [{ id: "stash-pop", label: "Restore stash" }] : []),
147
+ ];
148
+
149
+ const state = startRun(
150
+ repoRoot,
151
+ "sync",
152
+ steps,
153
+ { base: baseBranch, rebase: args.rebase, branch: current },
154
+ { beforeSha, branch: current, stashRef: stashed ? "stash@{0}" : null },
155
+ );
156
+
157
+ // Prefer configured base; ensure local base exists
158
+ void base;
159
+ await executeSyncSteps(repoRoot, state, opts);
160
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Undo the last completed gflows operation when reversible.
3
+ * @module commands/undo
4
+ */
5
+
6
+ import { EXIT_USER } from "../constants.js";
7
+ import { checkout, deleteTag, resolveRepoRoot, runGit, tagExists } from "../git.js";
8
+ import { hint, success } from "../out.js";
9
+ import { clearLastRun, readLastRun } from "../run-state.js";
10
+ import type { ParsedArgs } from "../types.js";
11
+
12
+ /**
13
+ * Best-effort undo of the last completed gflows command.
14
+ */
15
+ export async function run(args: ParsedArgs): Promise<void> {
16
+ const repoRoot = await resolveRepoRoot(args.cwd);
17
+ const last = readLastRun(repoRoot);
18
+ if (last?.status !== "completed") {
19
+ console.error("gflows undo: nothing to undo.");
20
+ process.exit(EXIT_USER);
21
+ }
22
+
23
+ const opts = { dryRun: args.dryRun, verbose: args.verbose };
24
+ const undo = last.undo;
25
+
26
+ if (last.command === "finish") {
27
+ const createdTag = typeof undo.createdTag === "string" ? undo.createdTag : null;
28
+ const mainName = typeof undo.main === "string" ? undo.main : "main";
29
+ const devName = typeof undo.dev === "string" ? undo.dev : "dev";
30
+ const branchName = typeof undo.branchName === "string" ? undo.branchName : null;
31
+ const branchSha = typeof undo.branchSha === "string" ? undo.branchSha : null;
32
+ const prevBranch = typeof undo.prevBranch === "string" ? undo.prevBranch : null;
33
+
34
+ if (createdTag && (await tagExists(repoRoot, createdTag, opts))) {
35
+ await deleteTag(repoRoot, createdTag, opts);
36
+ if (!args.quiet) success(`gflows: deleted tag '${createdTag}'.`);
37
+ }
38
+
39
+ if (typeof undo.mainSha === "string") {
40
+ await runGit(["branch", "-f", mainName, undo.mainSha], { cwd: repoRoot, ...opts });
41
+ }
42
+ if (typeof undo.devSha === "string") {
43
+ await runGit(["branch", "-f", devName, undo.devSha], { cwd: repoRoot, ...opts });
44
+ }
45
+
46
+ if (branchName && branchSha && undo.shouldDelete) {
47
+ await runGit(["branch", branchName, branchSha], { cwd: repoRoot, ...opts });
48
+ if (!args.quiet) success(`gflows: restored branch '${branchName}'.`);
49
+ }
50
+
51
+ if (prevBranch) {
52
+ await checkout(repoRoot, prevBranch, opts).catch(() => undefined);
53
+ }
54
+
55
+ clearLastRun(repoRoot);
56
+ if (!args.quiet) {
57
+ success("gflows: undid last finish (best effort). Remote was not modified.");
58
+ }
59
+ hint("If you had pushed, reset remotes manually.");
60
+ return;
61
+ }
62
+
63
+ if (last.command === "sync") {
64
+ const branch = typeof undo.branch === "string" ? undo.branch : null;
65
+ const beforeSha = typeof undo.beforeSha === "string" ? undo.beforeSha : null;
66
+ if (branch && beforeSha) {
67
+ await runGit(["checkout", branch], { cwd: repoRoot, ...opts });
68
+ await runGit(["reset", "--hard", beforeSha], { cwd: repoRoot, ...opts });
69
+ clearLastRun(repoRoot);
70
+ if (!args.quiet) success(`gflows: reset '${branch}' to pre-sync state.`);
71
+ return;
72
+ }
73
+ }
74
+
75
+ console.error(`gflows undo: cannot undo command '${last.command}' automatically.`);
76
+ hint("Inspect .git/gflows/last.json and recover with git reflog if needed.");
77
+ process.exit(EXIT_USER);
78
+ }
@@ -3,25 +3,13 @@
3
3
  * @module commands/version
4
4
  */
5
5
 
6
- import { readFileSync } from "node:fs";
7
- import { dirname, join } from "node:path";
8
- import { fileURLToPath } from "node:url";
9
6
  import type { ParsedArgs } from "../types.js";
10
-
11
- const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ import { getVersion } from "../version.js";
12
8
 
13
9
  /**
14
- * Runs the version command: reads version from package.json (repo root when
15
- * developing, package root when installed) and prints it to stdout.
10
+ * Runs the version command: prints package version to stdout.
16
11
  * @param _args - Parsed CLI args (unused; kept for command signature consistency).
17
12
  */
18
13
  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
- }
14
+ console.log(getVersion());
27
15
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Visual map of main/dev + workflow branches and “you are here” next steps.
3
+ * @module commands/viz
4
+ */
5
+
6
+ import type { ParsedArgs } from "../types.js";
7
+ import { printViz } from "../viz.js";
8
+
9
+ /**
10
+ * Prints an interactive-friendly branch/flow visualization to stdout.
11
+ */
12
+ export async function run(args: ParsedArgs): Promise<void> {
13
+ await printViz(args.cwd, {
14
+ main: args.main,
15
+ dev: args.dev,
16
+ remote: args.remote,
17
+ });
18
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared command dispatch for the hub and TUI (parse argv → command module).
3
+ * @module dispatch
4
+ */
5
+
6
+ import { parse } from "./parse.js";
7
+ import type { ParsedArgs } from "./types.js";
8
+
9
+ /**
10
+ * Dispatches a command by re-parsing argv and calling the command module.
11
+ */
12
+ export async function dispatch(cwd: string, argv: string[]): Promise<void> {
13
+ const args: ParsedArgs = parse(["-C", cwd, ...argv]);
14
+ if (args.command === "help") {
15
+ const { run } = await import("./commands/help.js");
16
+ await run(args);
17
+ return;
18
+ }
19
+ const mod = await import(`./commands/${args.command}.js`);
20
+ await mod.run(args);
21
+ }
package/src/errors.ts CHANGED
@@ -11,26 +11,39 @@ import { EXIT_GIT, EXIT_USER } from "./constants.js";
11
11
  export class GflowsError extends Error {
12
12
  /** Exit code to use when this error is thrown (1 = user, 2 = git). */
13
13
  readonly exitCode: number;
14
+ /** Optional remediation hint printed after the message. */
15
+ readonly hint?: string;
14
16
 
15
- constructor(message: string, exitCode: number) {
17
+ constructor(message: string, exitCode: number, hint?: string) {
16
18
  super(message);
17
19
  this.name = this.constructor.name;
18
20
  Object.setPrototypeOf(this, new.target.prototype);
19
21
  this.exitCode = exitCode;
22
+ this.hint = hint;
20
23
  }
21
24
  }
22
25
 
23
26
  /** Thrown when cwd (or -C) is not a Git repository. */
24
27
  export class NotRepoError extends GflowsError {
25
- constructor(message = "Not a Git repository.") {
26
- super(message, EXIT_GIT);
28
+ constructor(
29
+ message = "Not a Git repository.",
30
+ hint = "Run from a directory that contains .git, or use -C <path> to point at the repo root.",
31
+ ) {
32
+ super(message, EXIT_GIT, hint);
27
33
  }
28
34
  }
29
35
 
30
36
  /** Thrown when a required branch does not exist (local or after fetch). */
31
37
  export class BranchNotFoundError extends GflowsError {
32
- constructor(message: string) {
33
- super(message, EXIT_GIT);
38
+ constructor(message: string, hint?: string) {
39
+ super(message, EXIT_GIT, hint);
40
+ }
41
+ }
42
+
43
+ /** Thrown when creating a branch that already exists. */
44
+ export class BranchExistsError extends GflowsError {
45
+ constructor(message: string, hint?: string) {
46
+ super(message, EXIT_GIT, hint);
34
47
  }
35
48
  }
36
49
 
@@ -38,31 +51,40 @@ export class BranchNotFoundError extends GflowsError {
38
51
  export class DirtyWorkingTreeError extends GflowsError {
39
52
  constructor(
40
53
  message = "Working tree has uncommitted changes. Commit or stash them, or use --force.",
54
+ hint = "--force creates/finishes anyway and keeps uncommitted changes in the working tree.",
41
55
  ) {
42
- super(message, EXIT_GIT);
56
+ super(message, EXIT_GIT, hint);
43
57
  }
44
58
  }
45
59
 
46
60
  /** Thrown when an operation requires a branch but HEAD is detached. */
47
61
  export class DetachedHeadError extends GflowsError {
48
- constructor(message = "HEAD is detached. Checkout a branch first.") {
49
- super(message, EXIT_GIT);
62
+ constructor(
63
+ message = "HEAD is detached. Checkout a branch first.",
64
+ hint = "Try: git checkout dev (or main)",
65
+ ) {
66
+ super(message, EXIT_GIT, hint);
50
67
  }
51
68
  }
52
69
 
53
70
  /** Thrown when a rebase or merge is in progress; user must complete or abort first. */
54
71
  export class RebaseMergeInProgressError extends GflowsError {
55
72
  constructor(
56
- message = "A rebase or merge is in progress. Complete or abort it before running this command.",
73
+ message = "A rebase or merge is in progress.",
74
+ hint = "Resolve conflicts then run gflows continue, or gflows abort / gflows undo.",
57
75
  ) {
58
- super(message, EXIT_GIT);
76
+ super(message, EXIT_GIT, hint);
59
77
  }
60
78
  }
61
79
 
62
80
  /** Thrown when merge fails due to conflicts; user must resolve manually. */
63
81
  export class MergeConflictError extends GflowsError {
64
- constructor(message: string) {
65
- super(message, EXIT_GIT);
82
+ constructor(message: string, hint?: string) {
83
+ super(
84
+ message,
85
+ EXIT_GIT,
86
+ hint ?? "Resolve conflicts, then: gflows continue. Or: gflows abort / gflows undo.",
87
+ );
66
88
  }
67
89
  }
68
90
 
@@ -87,6 +109,13 @@ export class CannotDeleteMainOrDevError extends GflowsError {
87
109
  }
88
110
  }
89
111
 
112
+ /** Thrown when finish has nothing to merge (0 commits ahead of target). */
113
+ export class NothingToFinishError extends GflowsError {
114
+ constructor(message: string) {
115
+ super(message, EXIT_GIT, "Commit your changes on the workflow branch, then finish again.");
116
+ }
117
+ }
118
+
90
119
  /**
91
120
  * Returns the exit code for an error: use error.exitCode if it's a GflowsError, else EXIT_GIT.
92
121
  */
@@ -96,3 +125,17 @@ export function exitCodeForError(error: unknown): number {
96
125
  }
97
126
  return EXIT_GIT;
98
127
  }
128
+
129
+ /**
130
+ * Prints a GflowsError (or Error) message and optional remediation hint to stderr.
131
+ */
132
+ export function printError(error: unknown): void {
133
+ if (error instanceof GflowsError) {
134
+ console.error("gflows:", error.message);
135
+ if (error.hint) {
136
+ console.error(`Hint: ${error.hint}`);
137
+ }
138
+ return;
139
+ }
140
+ console.error("gflows:", error instanceof Error ? error.message : String(error));
141
+ }
package/src/flow.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Shared branch classification and merge-target resolution for status/finish/sync/pr.
3
+ * @module flow
4
+ */
5
+
6
+ import { getBranchTypeMeta } from "./config.js";
7
+ import { VERSION_REGEX } from "./constants.js";
8
+ import { getMergeBase, isAncestor, resolveSha } from "./git.js";
9
+ import type { BranchType, MergeTarget, ResolvedConfig } from "./types.js";
10
+
11
+ /**
12
+ * Classifies a branch name into a workflow type, "main", "dev", or null (unknown).
13
+ */
14
+ export function classifyBranch(
15
+ branchName: string,
16
+ config: ResolvedConfig,
17
+ ): BranchType | "main" | "dev" | null {
18
+ if (branchName === config.main) return "main";
19
+ if (branchName === config.dev) return "dev";
20
+ const { prefixes } = config;
21
+ const order: BranchType[] = ["release", "hotfix", "feature", "bugfix", "chore", "spike"];
22
+ for (const type of order) {
23
+ const prefix = prefixes[type];
24
+ if (prefix && branchName.startsWith(prefix)) {
25
+ return type;
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * Infers branch type and optional version from branch name using config prefixes.
33
+ */
34
+ export function parseBranchTypeAndVersion(
35
+ branchName: string,
36
+ prefixes: ResolvedConfig["prefixes"],
37
+ ): { type: BranchType; version?: string } | null {
38
+ const order: BranchType[] = ["release", "hotfix", "feature", "bugfix", "chore", "spike"];
39
+ for (const type of order) {
40
+ const prefix = prefixes[type];
41
+ if (prefix && branchName.startsWith(prefix)) {
42
+ const suffix = branchName.slice(prefix.length);
43
+ if (type === "release" || type === "hotfix") {
44
+ const ver = suffix.trim();
45
+ return VERSION_REGEX.test(ver) ? { type, version: ver } : { type };
46
+ }
47
+ return { type };
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Formats merge target for display using actual branch names.
55
+ */
56
+ export function formatMergeTarget(mergeTarget: MergeTarget, config: ResolvedConfig): string {
57
+ if (mergeTarget === "main-then-dev") {
58
+ return `${config.main}, then ${config.dev}`;
59
+ }
60
+ return mergeTarget === "main" ? config.main : config.dev;
61
+ }
62
+
63
+ /**
64
+ * Primary compare/merge base branch for a merge target (for ahead/behind and empty-finish checks).
65
+ */
66
+ export function primaryTargetBranch(mergeTarget: MergeTarget, config: ResolvedConfig): string {
67
+ if (mergeTarget === "dev") return config.dev;
68
+ return config.main;
69
+ }
70
+
71
+ /**
72
+ * Resolves effective merge target for a workflow branch.
73
+ * Bugfix branches based on main (merge-base equals main tip, or only reachable from main) use main-then-dev.
74
+ */
75
+ export async function resolveMergeTarget(
76
+ repoRoot: string,
77
+ branchName: string,
78
+ type: BranchType,
79
+ config: ResolvedConfig,
80
+ opts: { dryRun?: boolean; verbose?: boolean } = {},
81
+ ): Promise<MergeTarget> {
82
+ const meta = getBranchTypeMeta(type);
83
+ if (type !== "bugfix") {
84
+ return meta.mergeTarget;
85
+ }
86
+
87
+ const mainSha = await resolveSha(repoRoot, config.main, opts);
88
+ const branchSha = await resolveSha(repoRoot, branchName, opts);
89
+ if (!mainSha || !branchSha) {
90
+ return meta.mergeTarget;
91
+ }
92
+
93
+ const mergeBase = await getMergeBase(repoRoot, config.main, branchName, opts);
94
+ if (mergeBase && mergeBase === mainSha) {
95
+ return "main-then-dev";
96
+ }
97
+
98
+ // Also treat as from-main when main is ancestor and dev is not the merge-base with branch
99
+ const mainIsAncestor = await isAncestor(repoRoot, config.main, branchName, opts);
100
+ const devSha = await resolveSha(repoRoot, config.dev, opts);
101
+ if (mainIsAncestor && devSha) {
102
+ const baseWithDev = await getMergeBase(repoRoot, config.dev, branchName, opts);
103
+ // If branch diverged from main more recently than from dev tip equality check:
104
+ // when merge-base(main, branch) === main tip, already handled; if branch was created
105
+ // from main while main had moved from where dev is, merge-base with main is still main's tip at start.
106
+ if (baseWithDev && mergeBase && baseWithDev !== mergeBase && mergeBase === mainSha) {
107
+ return "main-then-dev";
108
+ }
109
+ // Heuristic: if merge-base with main equals main and differs from merge-base with dev → from main
110
+ if (mergeBase === mainSha && baseWithDev !== mainSha) {
111
+ return "main-then-dev";
112
+ }
113
+ }
114
+
115
+ return meta.mergeTarget;
116
+ }
117
+
118
+ /**
119
+ * Default base branch name for a type (and optional fromMain).
120
+ */
121
+ export function getBaseBranchName(
122
+ type: BranchType,
123
+ fromMain: boolean,
124
+ config: ResolvedConfig,
125
+ ): string {
126
+ if (type === "hotfix") return config.main;
127
+ if (type === "bugfix" && fromMain) return config.main;
128
+ const meta = getBranchTypeMeta(type);
129
+ return meta.base === "main" ? config.main : config.dev;
130
+ }
131
+
132
+ /**
133
+ * Normalizes version to vX.Y.Z for tag name.
134
+ */
135
+ export function normalizeTagVersion(version: string): string {
136
+ const v = version.trim();
137
+ return v.startsWith("v") ? v : `v${v}`;
138
+ }
139
+
140
+ /**
141
+ * Lists local workflow branches matching configured prefixes.
142
+ */
143
+ export function filterWorkflowBranches(
144
+ all: string[],
145
+ prefixes: ResolvedConfig["prefixes"],
146
+ ): string[] {
147
+ const workflow: string[] = [];
148
+ for (const b of all) {
149
+ for (const prefix of Object.values(prefixes)) {
150
+ if (b.startsWith(prefix)) {
151
+ workflow.push(b);
152
+ break;
153
+ }
154
+ }
155
+ }
156
+ return workflow.sort();
157
+ }