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
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";
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Interactive hub entry — prefers Ink TUI, falls back to Clack menu.
3
+ * @module interactive
4
+ */
5
+
6
+ import { resolveConfig } from "./config.js";
7
+ import { dispatch } from "./dispatch.js";
8
+ import { classifyBranch, formatMergeTarget, getBaseBranchName } from "./flow.js";
9
+ import { getAheadBehind, getCurrentBranch, resolveRepoRoot } from "./git.js";
10
+ import { hint } from "./out.js";
11
+ import { confirmPrompt, inputPrompt, selectPrompt } from "./prompts.js";
12
+ import { type RecommendAction, recommend } from "./recommend.js";
13
+ import { readActiveRun } from "./run-state.js";
14
+ import type { BranchType } from "./types.js";
15
+ import { ansi, paint, rule } from "./ui.js";
16
+ import { printViz, type VizSnapshot } from "./viz.js";
17
+
18
+ const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
19
+
20
+ export { dispatch } from "./dispatch.js";
21
+
22
+ /**
23
+ * Runs the interactive hub until the user quits.
24
+ * Uses the Ink fullscreen TUI when stdin/stdout are TTYs; otherwise Clack menu.
25
+ */
26
+ export async function runHub(cwd: string): Promise<void> {
27
+ const { runTuiHub } = await import("./tui/hub.js");
28
+ const usedTui = await runTuiHub(cwd);
29
+ if (!usedTui) {
30
+ await runLegacyHub(cwd);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Legacy Clack select hub (when Ink/TUI cannot run).
36
+ */
37
+ async function runLegacyHub(cwd: string): Promise<void> {
38
+ for (;;) {
39
+ let snap: VizSnapshot | null = null;
40
+ try {
41
+ snap = await printViz(cwd);
42
+ } catch {
43
+ await printContextFallback(cwd);
44
+ }
45
+
46
+ const rec = snap ? recommend(snap) : null;
47
+ const options = buildLegacyOptions(rec?.action ?? null, snap);
48
+ const action = await selectPrompt({
49
+ message: rec ? `Recommended · ${rec.label}` : "What do you want to do?",
50
+ options,
51
+ initialValue: options[0]?.value,
52
+ });
53
+
54
+ if (action === "quit") {
55
+ console.log(paint(ansi.dim, "Bye."));
56
+ break;
57
+ }
58
+
59
+ try {
60
+ await runHubAction(cwd, action);
61
+ } catch (err) {
62
+ console.error("gflows:", err instanceof Error ? err.message : String(err));
63
+ }
64
+
65
+ console.log("");
66
+ console.log(rule(56));
67
+ const again = await confirmPrompt({ message: "Back to menu?", initialValue: true });
68
+ if (!again) break;
69
+ }
70
+ }
71
+
72
+ function buildLegacyOptions(
73
+ recommended: RecommendAction | null,
74
+ snap: VizSnapshot | null,
75
+ ): Array<{ value: string; label: string; hint?: string }> {
76
+ const items: Array<{ value: string; label: string; hint?: string }> = [];
77
+ if (snap?.needsInit) {
78
+ items.push({ value: "init", label: "Initialize repo", hint: "gflows init" });
79
+ }
80
+ items.push(
81
+ { value: "start", label: "Start new work" },
82
+ { value: "sync", label: "Sync this branch with base" },
83
+ { value: "pr", label: "Open a pull request" },
84
+ { value: "finish", label: "Finish / merge this branch" },
85
+ { value: "switch", label: "Switch branch" },
86
+ { value: "list", label: "List branches" },
87
+ { value: "viz", label: "Refresh map" },
88
+ { value: "doctor", label: "Doctor (check setup)" },
89
+ { value: "config", label: "Config" },
90
+ { value: "bump", label: "Bump version" },
91
+ { value: "continue", label: "Continue suspended operation" },
92
+ { value: "help", label: "Help" },
93
+ { value: "quit", label: "Quit" },
94
+ );
95
+
96
+ const prefer = recommended && recommended !== "commit" ? recommended : null;
97
+ if (prefer) {
98
+ const idx = items.findIndex((a) => a.value === prefer);
99
+ if (idx >= 0) {
100
+ const [item] = items.splice(idx, 1);
101
+ if (item) {
102
+ items.unshift({ ...item, label: `★ ${item.label}` });
103
+ }
104
+ }
105
+ }
106
+ return items;
107
+ }
108
+
109
+ async function runHubAction(cwd: string, action: string): Promise<void> {
110
+ if (action === "viz") {
111
+ await printViz(cwd);
112
+ return;
113
+ }
114
+ if (action === "start") {
115
+ await promptStart(cwd);
116
+ return;
117
+ }
118
+ if (action === "finish") {
119
+ const push = await confirmPrompt({ message: "Push after finish?", initialValue: false });
120
+ await dispatch(cwd, ["finish", "-y", push ? "-p" : "-P"]);
121
+ return;
122
+ }
123
+ if (action === "sync") {
124
+ const rebase = await confirmPrompt({
125
+ message: "Rebase onto base (instead of merge)?",
126
+ initialValue: false,
127
+ });
128
+ await dispatch(cwd, ["sync", ...(rebase ? ["--rebase"] : []), "--force"]);
129
+ return;
130
+ }
131
+ await dispatch(cwd, [action]);
132
+ }
133
+
134
+ /** Fallback text context when viz cannot run (e.g. not a repo). */
135
+ async function printContextFallback(cwd: string): Promise<void> {
136
+ try {
137
+ const root = await resolveRepoRoot(cwd);
138
+ const config = resolveConfig(root, {}, {});
139
+ const current = await getCurrentBranch(root, {});
140
+ const active = readActiveRun(root);
141
+ console.log("");
142
+ console.log(paint(ansi.cyan + ansi.bold, "╭─ gflows ──────────────────────────────╮"));
143
+ if (active) {
144
+ console.log(`│ Suspended: ${active.command} — use Continue, or gflows abort`);
145
+ }
146
+ if (!current) {
147
+ console.log("│ HEAD: detached");
148
+ console.log(paint(ansi.dim, "╰────────────────────────────────────────╯"));
149
+ return;
150
+ }
151
+ const kind = classifyBranch(current, config);
152
+ console.log(`│ Branch: ${current}`);
153
+ if (kind === "main" || kind === "dev") {
154
+ console.log(`│ Type: long-lived (${kind})`);
155
+ console.log(paint(ansi.dim, "╰────────────────────────────────────────╯"));
156
+ hint("Start new work from the menu, or switch to a workflow branch.");
157
+ return;
158
+ }
159
+ if (kind === null) {
160
+ console.log("│ Type: unknown");
161
+ console.log(paint(ansi.dim, "╰────────────────────────────────────────╯"));
162
+ return;
163
+ }
164
+ const base = getBaseBranchName(kind, false, config);
165
+ const { ahead, behind } = await getAheadBehind(root, base, current, {});
166
+ const meta = formatMergeTarget(
167
+ kind === "release" || kind === "hotfix" ? "main-then-dev" : "dev",
168
+ config,
169
+ );
170
+ console.log(`│ Type: ${kind} · base: ${base} · +${ahead}/-${behind}`);
171
+ console.log(`│ Merge: ${meta}`);
172
+ console.log(paint(ansi.dim, "╰────────────────────────────────────────╯"));
173
+ } catch {
174
+ console.log("");
175
+ console.log(paint(ansi.yellow, "╭─ gflows ──────────────────────────────╮"));
176
+ console.log("│ Not a git repo here");
177
+ console.log(paint(ansi.dim, "│ → cd into a repo, or git init + gflows init"));
178
+ console.log(paint(ansi.dim, "╰────────────────────────────────────────╯"));
179
+ console.log("");
180
+ }
181
+ }
182
+
183
+ async function promptStart(cwd: string): Promise<void> {
184
+ const { type, name } = await promptStartArgs();
185
+ const push = await confirmPrompt({ message: "Push after create?", initialValue: false });
186
+ const argv = ["start", type, name, push ? "-p" : "-P"];
187
+ if (type === "bugfix") {
188
+ const fromMain = await confirmPrompt({
189
+ message: "Base from main (production fix)?",
190
+ initialValue: false,
191
+ });
192
+ if (fromMain) argv.push("-o", "main");
193
+ }
194
+ await dispatch(cwd, argv);
195
+ }
196
+
197
+ /**
198
+ * Prompts for start type/name when missing (TTY).
199
+ */
200
+ export async function promptStartArgs(): Promise<{ type: BranchType; name: string }> {
201
+ const type = await selectPrompt({
202
+ message: "Branch type",
203
+ options: BRANCH_TYPES.map((t) => ({ value: t, label: t })),
204
+ });
205
+ const name = await inputPrompt({
206
+ message: type === "release" || type === "hotfix" ? "Version (vX.Y.Z)" : "Branch name",
207
+ });
208
+ return { type, name };
209
+ }
package/src/out.ts CHANGED
@@ -30,14 +30,21 @@ export function success(message: string): void {
30
30
  }
31
31
 
32
32
  /**
33
- * Prints a hint line to stdout (dim when TTY). Use after success messages to suggest next steps.
34
- * Skips color when not a TTY (e.g. CI). Omit when --quiet to keep output minimal.
33
+ * Whether stderr is a TTY and can safely use ANSI color codes for hints.
34
+ */
35
+ function isStderrColorCapable(): boolean {
36
+ return typeof process.stderr.isTTY === "boolean" && process.stderr.isTTY;
37
+ }
38
+
39
+ /**
40
+ * Prints a hint line to stderr (dim when TTY). Keeps stdout clean for scripting (`list`, pipes).
41
+ * Omit when --quiet to keep output minimal.
35
42
  *
36
43
  * @param message - One-line hint (e.g. "Run gflows start feature <name> to create a branch").
37
44
  */
38
45
  export function hint(message: string): void {
39
- const line = isColorCapable() ? `${DIM}Hint: ${message}${RESET}` : `Hint: ${message}`;
40
- console.log(line);
46
+ const line = isStderrColorCapable() ? `${DIM}Hint: ${message}${RESET}` : `Hint: ${message}`;
47
+ console.error(line);
41
48
  }
42
49
 
43
50
  const BANNER_INNER_WIDTH = 42;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Helpers for optional package.json script aliases (e.g. "g": "gflows").
3
+ * @module package-scripts
4
+ */
5
+
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ const PACKAGE_JSON = "package.json";
10
+ const SCRIPT_VALUE = "gflows";
11
+
12
+ /** Result of attempting to add a script alias. */
13
+ export type ScriptAliasResult =
14
+ | { status: "added"; name: string }
15
+ | { status: "unchanged"; name: string }
16
+ | { status: "conflict"; name: string; existing: string }
17
+ | { status: "no-package" }
18
+ | { status: "invalid"; name: string };
19
+
20
+ /**
21
+ * Validates a package.json script name (npm/bun safe subset).
22
+ */
23
+ export function isValidScriptAliasName(name: string): boolean {
24
+ return /^[a-z][a-z0-9:_-]*$/i.test(name) && name.length <= 64;
25
+ }
26
+
27
+ /**
28
+ * Ensures `scripts[name] = "gflows"` in the nearest package.json under repoRoot.
29
+ * Does not overwrite a different existing script value.
30
+ */
31
+ export function ensureGflowsScriptAlias(
32
+ repoRoot: string,
33
+ name: string,
34
+ options?: { dryRun?: boolean },
35
+ ): ScriptAliasResult {
36
+ if (!isValidScriptAliasName(name)) {
37
+ return { status: "invalid", name };
38
+ }
39
+
40
+ const pkgPath = join(repoRoot, PACKAGE_JSON);
41
+ if (!existsSync(pkgPath)) {
42
+ return { status: "no-package" };
43
+ }
44
+
45
+ let pkg: Record<string, unknown>;
46
+ try {
47
+ pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as Record<string, unknown>;
48
+ } catch {
49
+ return { status: "no-package" };
50
+ }
51
+
52
+ const scripts =
53
+ pkg.scripts && typeof pkg.scripts === "object" && !Array.isArray(pkg.scripts)
54
+ ? { ...(pkg.scripts as Record<string, unknown>) }
55
+ : {};
56
+
57
+ const existing = scripts[name];
58
+ if (typeof existing === "string") {
59
+ if (existing === SCRIPT_VALUE || existing.trim() === SCRIPT_VALUE) {
60
+ return { status: "unchanged", name };
61
+ }
62
+ return { status: "conflict", name, existing };
63
+ }
64
+
65
+ if (options?.dryRun) {
66
+ return { status: "added", name };
67
+ }
68
+
69
+ scripts[name] = SCRIPT_VALUE;
70
+ pkg.scripts = scripts;
71
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
72
+ return { status: "added", name };
73
+ }