gflows 0.1.18 → 1.0.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.
Files changed (49) hide show
  1. package/AGENTS.md +78 -0
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +279 -505
  4. package/llms.txt +22 -0
  5. package/package.json +29 -23
  6. package/src/cli.ts +21 -367
  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 -21
  16. package/src/commands/init.ts +99 -18
  17. package/src/commands/list.ts +6 -1
  18. package/src/commands/mcp.ts +239 -0
  19. package/src/commands/pr.ts +120 -0
  20. package/src/commands/schema.ts +99 -0
  21. package/src/commands/start.ts +33 -31
  22. package/src/commands/status.ts +70 -51
  23. package/src/commands/switch.ts +14 -19
  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 +391 -0
  41. package/src/tui/HubShell.tsx +193 -0
  42. package/src/tui/flows.tsx +229 -0
  43. package/src/tui/hub.ts +114 -0
  44. package/src/tui/prompts.tsx +207 -0
  45. package/src/tui/slash.ts +63 -0
  46. package/src/types.ts +62 -3
  47. package/src/ui.ts +132 -0
  48. package/src/version.ts +24 -0
  49. package/src/viz.ts +294 -0
@@ -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
+ }
package/src/git.ts CHANGED
@@ -198,16 +198,34 @@ export async function checkout(
198
198
  export async function merge(
199
199
  cwd: string,
200
200
  ref: string,
201
- options: GitRunOptions & { noFf?: boolean } = {},
201
+ options: GitRunOptions & { noFf?: boolean; message?: string; squash?: boolean } = {},
202
202
  ): Promise<void> {
203
- const { noFf = false, ...opts } = options;
204
- const args = noFf ? ["merge", "--no-ff", ref] : ["merge", ref];
203
+ const { noFf = false, message, squash = false, ...opts } = options;
204
+ const args = ["merge"];
205
+ if (squash) args.push("--squash");
206
+ else if (noFf) args.push("--no-ff");
207
+ if (message && !squash) args.push("-m", message);
208
+ args.push(ref);
205
209
  const result = await runGit(args, { cwd, ...opts });
206
210
 
207
211
  if (result.exitCode !== 0) {
208
- const hint =
209
- "Resolve conflicts in the working tree, then run `git add` and `git merge --continue`, or `git merge --abort` to cancel. Re-run `gflows finish` after resolving if needed.";
210
- throw new MergeConflictError(`Merge conflict while merging ${ref}. ${hint}`);
212
+ throw new MergeConflictError(
213
+ `Merge conflict while merging ${ref}.`,
214
+ "Resolve conflicts, then: gflows continue. Or: gflows abort / gflows undo.",
215
+ );
216
+ }
217
+
218
+ if (squash && !opts.dryRun) {
219
+ const commitArgs = message
220
+ ? ["commit", "-m", message]
221
+ : ["commit", "-m", `Squash merge ${ref}`];
222
+ const commitResult = await runGit(commitArgs, { cwd, ...opts });
223
+ if (commitResult.exitCode !== 0) {
224
+ throw new MergeConflictError(
225
+ `Squash merge of ${ref} staged but commit failed.`,
226
+ commitResult.stderr.trim() || "Check git status and commit manually, then gflows continue.",
227
+ );
228
+ }
211
229
  }
212
230
  }
213
231
 
@@ -294,14 +312,123 @@ export async function tagExists(
294
312
  export async function deleteBranch(
295
313
  cwd: string,
296
314
  branch: string,
297
- options: GitRunOptions = {},
315
+ options: GitRunOptions & { force?: boolean } = {},
298
316
  ): Promise<void> {
299
- const result = await runGit(["branch", "-d", branch], { cwd, ...options });
317
+ const flag = options.force ? "-D" : "-d";
318
+ const result = await runGit(["branch", flag, branch], { cwd, ...options });
300
319
  if (result.exitCode !== 0) {
301
320
  throw new BranchNotFoundError(result.stderr.trim() || `Could not delete branch '${branch}'.`);
302
321
  }
303
322
  }
304
323
 
324
+ /**
325
+ * Deletes a remote branch (git push remote --delete branch).
326
+ *
327
+ * @param cwd - Repo root.
328
+ * @param remote - Remote name.
329
+ * @param branch - Branch name on remote.
330
+ * @param options - dryRun, verbose.
331
+ * @returns Exit code from git push.
332
+ */
333
+ export async function deleteRemoteBranch(
334
+ cwd: string,
335
+ remote: string,
336
+ branch: string,
337
+ options: GitRunOptions = {},
338
+ ): Promise<number> {
339
+ const result = await runGit(["push", remote, "--delete", branch], { cwd, ...options });
340
+ return result.exitCode;
341
+ }
342
+
343
+ /**
344
+ * Returns the merge-base SHA of two refs, or null if unavailable.
345
+ */
346
+ export async function getMergeBase(
347
+ cwd: string,
348
+ a: string,
349
+ b: string,
350
+ options: GitRunOptions = {},
351
+ ): Promise<string | null> {
352
+ const result = await runGit(["merge-base", a, b], { cwd, ...options });
353
+ if (result.exitCode !== 0) return null;
354
+ const sha = result.stdout.trim();
355
+ return sha.length > 0 ? sha : null;
356
+ }
357
+
358
+ /**
359
+ * Returns true if `ancestor` is an ancestor of `commit` (git merge-base --is-ancestor).
360
+ */
361
+ export async function isAncestor(
362
+ cwd: string,
363
+ ancestor: string,
364
+ commit: string,
365
+ options: GitRunOptions = {},
366
+ ): Promise<boolean> {
367
+ const result = await runGit(["merge-base", "--is-ancestor", ancestor, commit], {
368
+ cwd,
369
+ ...options,
370
+ });
371
+ return result.exitCode === 0;
372
+ }
373
+
374
+ /**
375
+ * Aborts an in-progress merge if MERGE_HEAD exists.
376
+ */
377
+ export async function mergeAbort(cwd: string, options: GitRunOptions = {}): Promise<void> {
378
+ await runGit(["merge", "--abort"], { cwd, ...options });
379
+ }
380
+
381
+ /**
382
+ * Aborts an in-progress rebase if one is active.
383
+ */
384
+ export async function rebaseAbort(cwd: string, options: GitRunOptions = {}): Promise<void> {
385
+ await runGit(["rebase", "--abort"], { cwd, ...options });
386
+ }
387
+
388
+ /**
389
+ * Deletes a local tag.
390
+ */
391
+ export async function deleteTag(
392
+ cwd: string,
393
+ name: string,
394
+ options: GitRunOptions = {},
395
+ ): Promise<void> {
396
+ await runGit(["tag", "-d", name], { cwd, ...options });
397
+ }
398
+
399
+ /**
400
+ * Returns upstream tracking branch (e.g. origin/feature/x) or null.
401
+ */
402
+ export async function getUpstream(
403
+ cwd: string,
404
+ branch: string,
405
+ options: GitRunOptions = {},
406
+ ): Promise<string | null> {
407
+ const result = await runGit(["rev-parse", "--abbrev-ref", `${branch}@{upstream}`], {
408
+ cwd,
409
+ ...options,
410
+ });
411
+ if (result.exitCode !== 0) return null;
412
+ const up = result.stdout.trim();
413
+ return up.length > 0 ? up : null;
414
+ }
415
+
416
+ /**
417
+ * Resolves a ref to a full SHA, or null if missing.
418
+ */
419
+ export async function resolveSha(
420
+ cwd: string,
421
+ ref: string,
422
+ options: GitRunOptions = {},
423
+ ): Promise<string | null> {
424
+ try {
425
+ const out = await revParse(cwd, ref, [], options);
426
+ return out.trim() || null;
427
+ } catch {
428
+ return null;
429
+ }
430
+ }
431
+
305
432
  /**
306
433
  * Returns true if the working tree is clean (no uncommitted changes).
307
434
  *
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  VERSION_REGEX,
29
29
  } from "./constants.js";
30
30
  export {
31
+ BranchExistsError,
31
32
  BranchNotFoundError,
32
33
  CannotDeleteMainOrDevError,
33
34
  DetachedHeadError,
@@ -37,9 +38,17 @@ export {
37
38
  InvalidBranchNameError,
38
39
  InvalidVersionError,
39
40
  MergeConflictError,
41
+ NothingToFinishError,
40
42
  NotRepoError,
43
+ printError,
41
44
  RebaseMergeInProgressError,
42
45
  } from "./errors.js";
46
+ export {
47
+ classifyBranch,
48
+ formatMergeTarget,
49
+ parseBranchTypeAndVersion,
50
+ resolveMergeTarget,
51
+ } from "./flow.js";
43
52
  export type { GitOptions, GitRunOptions, GitRunResult } from "./git.js";
44
53
  export {
45
54
  assertNoRebaseOrMerge,
@@ -64,6 +73,9 @@ export {
64
73
  tagExists,
65
74
  validateBranchName,
66
75
  } from "./git.js";
76
+ export { parse } from "./parse.js";
77
+ export type { RecommendAction, Recommendation } from "./recommend.js";
78
+ export { recommend } from "./recommend.js";
67
79
  export type {
68
80
  BranchPrefixes,
69
81
  BranchType,
@@ -77,4 +89,15 @@ export type {
77
89
  ParsedArgs,
78
90
  ResolvedConfig,
79
91
  } from "./types.js";
80
- export { BRANCH_TYPE_SHORTS } from "./types.js";
92
+ export { ALL_COMMANDS, BRANCH_TYPE_SHORTS } from "./types.js";
93
+ export { paint, renderPanel, section } from "./ui.js";
94
+ export type { VizBranchRow, VizSnapshot } from "./viz.js";
95
+ export {
96
+ collectVizSnapshot,
97
+ printViz,
98
+ renderBranchMap,
99
+ renderFlowLegend,
100
+ renderLifecycle,
101
+ renderVizPanel,
102
+ renderYouAreHere,
103
+ } from "./viz.js";