gflows 0.1.18 → 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 -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 +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 +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 +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 +62 -3
  46. package/src/ui.ts +132 -0
  47. package/src/version.ts +24 -0
  48. package/src/viz.ts +294 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Print a machine-readable catalog of gflows commands, flags, types, and exit codes.
3
+ * @module commands/schema
4
+ */
5
+
6
+ import type { ParsedArgs } from "../types.js";
7
+ import { ALL_COMMANDS, BRANCH_TYPE_SHORTS } from "../types.js";
8
+
9
+ /**
10
+ * Emits JSON schema for agents and tooling.
11
+ */
12
+ export async function run(_args: ParsedArgs): Promise<void> {
13
+ const schema = {
14
+ name: "gflows",
15
+ version: "1.0.0",
16
+ description: "Git branching workflow CLI (main + dev + typed short-lived branches)",
17
+ exitCodes: {
18
+ "0": "success",
19
+ "1": "usage/validation",
20
+ "2": "git/system",
21
+ },
22
+ branchTypes: Object.entries(BRANCH_TYPE_SHORTS).map(([type, short]) => ({
23
+ type,
24
+ short,
25
+ })),
26
+ commands: ALL_COMMANDS.map((command) => ({
27
+ command,
28
+ ...commandMeta(command),
29
+ })),
30
+ flags: {
31
+ common: [
32
+ "-C/--path",
33
+ "-p/--push",
34
+ "-P/--no-push",
35
+ "-y/--yes",
36
+ "-d/--dry-run",
37
+ "-v/--verbose",
38
+ "-q/--quiet",
39
+ "--force",
40
+ "--json",
41
+ "--main",
42
+ "--dev",
43
+ "-R/--remote",
44
+ ],
45
+ finish: [
46
+ "-B/--branch",
47
+ "-D/--delete",
48
+ "-N/--no-delete",
49
+ "--no-ff",
50
+ "--squash",
51
+ "--preview",
52
+ "--bump",
53
+ "-s/--sign",
54
+ "-T/--no-tag",
55
+ "-M/--tag-message",
56
+ "-m/--message",
57
+ ],
58
+ start: ["-o/--from", "--force", "-p/--push"],
59
+ sync: ["--rebase", "--force"],
60
+ list: ["-r/--include-remote", "--json"],
61
+ status: ["--json"],
62
+ },
63
+ agentNotes: [
64
+ "Always pass explicit flags in non-TTY (never assume interactive hub).",
65
+ "Prefer -y to accept finish plan (delete defaults on); pass -P or -p for push.",
66
+ "On conflict: gflows continue | gflows abort | gflows undo.",
67
+ "Use gflows schema and AGENTS.md as source of truth.",
68
+ ],
69
+ };
70
+ console.log(JSON.stringify(schema, null, 2));
71
+ }
72
+
73
+ function commandMeta(command: string): { summary: string; interactiveOk: boolean } {
74
+ const map: Record<string, { summary: string; interactiveOk: boolean }> = {
75
+ init: { summary: "Ensure main; create dev", interactiveOk: true },
76
+ start: { summary: "Create workflow branch", interactiveOk: true },
77
+ finish: { summary: "Merge and close workflow branch", interactiveOk: true },
78
+ switch: { summary: "Switch workflow branch", interactiveOk: true },
79
+ delete: { summary: "Delete local workflow branches", interactiveOk: true },
80
+ list: { summary: "List workflow branches", interactiveOk: false },
81
+ bump: { summary: "Bump package version", interactiveOk: true },
82
+ sync: { summary: "Update branch from base", interactiveOk: true },
83
+ pr: { summary: "Open PR/MR via gh/glab", interactiveOk: true },
84
+ viz: { summary: "Visual branch map and flow diagram", interactiveOk: true },
85
+ doctor: { summary: "Repo health checks", interactiveOk: false },
86
+ config: { summary: "Get/set .gflows.json", interactiveOk: true },
87
+ schema: { summary: "Machine-readable command catalog", interactiveOk: false },
88
+ continue: { summary: "Resume suspended operation", interactiveOk: false },
89
+ undo: { summary: "Undo last completed operation", interactiveOk: false },
90
+ abort: { summary: "Abort suspended operation", interactiveOk: false },
91
+ completion: { summary: "Shell completion script", interactiveOk: false },
92
+ status: { summary: "Current branch flow info", interactiveOk: false },
93
+ help: { summary: "Usage", interactiveOk: false },
94
+ version: { summary: "CLI version", interactiveOk: false },
95
+ mcp: { summary: "Start MCP server for agents", interactiveOk: false },
96
+ };
97
+ return map[command] ?? { summary: command, interactiveOk: false };
98
+ }
@@ -6,7 +6,13 @@
6
6
 
7
7
  import { getPrefixForType, resolveConfig } from "../config.js";
8
8
  import { EXIT_USER, VERSION_REGEX } from "../constants.js";
9
- import { BranchNotFoundError, DirtyWorkingTreeError, InvalidVersionError } from "../errors.js";
9
+ import {
10
+ BranchExistsError,
11
+ BranchNotFoundError,
12
+ DirtyWorkingTreeError,
13
+ InvalidVersionError,
14
+ } from "../errors.js";
15
+ import { getBaseBranchName } from "../flow.js";
10
16
  import {
11
17
  assertNoRebaseOrMerge,
12
18
  assertNotDetached,
@@ -19,35 +25,30 @@ import {
19
25
  runGit,
20
26
  validateBranchName,
21
27
  } from "../git.js";
28
+ import { promptStartArgs } from "../interactive.js";
22
29
  import { hint, success } from "../out.js";
23
- import type { BranchType, ParsedArgs } from "../types.js";
24
-
25
- /**
26
- * Returns the base branch name for the given type and fromMain flag (main vs dev).
27
- */
28
- function getBaseBranch(type: BranchType, fromMain: boolean, main: string, dev: string): string {
29
- if (type === "hotfix") return main;
30
- if (type === "bugfix" && fromMain) return main;
31
- return dev;
32
- }
30
+ import type { ParsedArgs } from "../types.js";
33
31
 
34
32
  /**
35
33
  * Runs the start command: validate pre-conditions, ensure base exists, create branch, optional push.
36
- * Pre-checks: repo is git, not detached HEAD, no rebase/merge in progress, working tree clean (or --force), base exists.
37
- * Requires type and name (exit 1 if missing). For release/hotfix, name must match vX.Y.Z or X.Y.Z.
38
- *
39
- * @param args - Parsed CLI args (cwd, type, name, push, noPush, remote, dryRun, verbose, quiet, force, fromMain).
40
34
  */
41
35
  export async function run(args: ParsedArgs): Promise<void> {
42
- if (!args.type || args.name === undefined || args.name === "") {
36
+ let type = args.type;
37
+ let name = args.name?.trim();
38
+
39
+ if ((!type || !name) && process.stdin.isTTY) {
40
+ const prompted = await promptStartArgs();
41
+ type = type ?? prompted.type;
42
+ name = name || prompted.name;
43
+ }
44
+
45
+ if (!type || name === undefined || name === "") {
43
46
  console.error(
44
47
  "gflows start: requires type and name (e.g. gflows start feature my-feat). Use 'gflows help' for usage.",
45
48
  );
46
49
  process.exit(EXIT_USER);
47
50
  }
48
51
 
49
- const type = args.type;
50
- const name = args.name.trim();
51
52
  const repoRoot = await resolveRepoRoot(args.cwd);
52
53
  const config = resolveConfig(
53
54
  repoRoot,
@@ -60,11 +61,9 @@ export async function run(args: ParsedArgs): Promise<void> {
60
61
  verbose: args.verbose,
61
62
  };
62
63
 
63
- // Pre-checks: not detached HEAD, no rebase/merge in progress
64
64
  await assertNotDetached(repoRoot);
65
65
  assertNoRebaseOrMerge(repoRoot);
66
66
 
67
- // Working tree clean or --force
68
67
  if (!args.force) {
69
68
  const clean = await isClean(repoRoot, { dryRun: false, verbose: opts.verbose });
70
69
  if (!clean) {
@@ -72,7 +71,6 @@ export async function run(args: ParsedArgs): Promise<void> {
72
71
  }
73
72
  }
74
73
 
75
- // Validate name: version for release/hotfix, branch name for others
76
74
  if (type === "release" || type === "hotfix") {
77
75
  if (!VERSION_REGEX.test(name)) {
78
76
  throw new InvalidVersionError(
@@ -83,15 +81,18 @@ export async function run(args: ParsedArgs): Promise<void> {
83
81
  validateBranchName(name);
84
82
  }
85
83
 
86
- const base = getBaseBranch(type, args.fromMain, config.main, config.dev);
84
+ const fromMain =
85
+ args.fromMain ||
86
+ (typeof args.from === "string" && (args.from === config.main || args.from === "main"));
87
+
88
+ const base =
89
+ typeof args.from === "string" && args.from !== "main" && args.from !== config.main
90
+ ? args.from
91
+ : getBaseBranchName(type, fromMain, config);
87
92
 
88
- // Ensure base exists (local or create from remote after fetch)
89
- let _baseExists = false;
90
93
  try {
91
94
  await revParse(repoRoot, base, [], { dryRun: false, verbose: opts.verbose });
92
- _baseExists = true;
93
95
  } catch {
94
- // Fetch and try remote ref
95
96
  await fetch(repoRoot, config.remote, opts);
96
97
  const remoteRef = `${config.remote}/${base}`;
97
98
  try {
@@ -99,7 +100,6 @@ export async function run(args: ParsedArgs): Promise<void> {
99
100
  if (!opts.dryRun) {
100
101
  await runGit(["branch", base, remoteRef], { cwd: repoRoot, ...opts, dryRun: false });
101
102
  }
102
- _baseExists = true;
103
103
  } catch {
104
104
  throw new BranchNotFoundError(
105
105
  `Base branch '${base}' not found locally or on ${config.remote}. Create it or push it first.`,
@@ -112,8 +112,9 @@ export async function run(args: ParsedArgs): Promise<void> {
112
112
 
113
113
  const branches = await branchList(repoRoot, { ...opts, dryRun: false });
114
114
  if (branches.includes(fullBranchName)) {
115
- throw new BranchNotFoundError(
116
- `Branch '${fullBranchName}' already exists. Use a different name or switch to it.`,
115
+ throw new BranchExistsError(
116
+ `Branch '${fullBranchName}' already exists.`,
117
+ `Use a different name, or: gflows switch ${fullBranchName}`,
117
118
  );
118
119
  }
119
120
 
@@ -138,7 +139,8 @@ export async function run(args: ParsedArgs): Promise<void> {
138
139
  }
139
140
 
140
141
  if (!args.quiet && !args.dryRun) {
141
- // Hint: suggest next step — merge branch when done
142
- hint(`When done, run gflows finish ${type} to merge into the target branch.`);
142
+ hint(
143
+ `Commit your work, then gflows sync or gflows finish ${type}. Interactive: just run gflows`,
144
+ );
143
145
  }
144
146
  }
@@ -1,59 +1,26 @@
1
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.
2
+ * Status command: show current branch flow info and recovery hints.
6
3
  * @module commands/status
7
4
  */
8
5
 
9
- import { getBranchTypeMeta, resolveConfig } from "../config.js";
6
+ import { resolveConfig } from "../config.js";
10
7
  import { NotRepoError } from "../errors.js";
8
+ import {
9
+ classifyBranch,
10
+ formatMergeTarget,
11
+ getBaseBranchName,
12
+ resolveMergeTarget,
13
+ } from "../flow.js";
11
14
  import { getAheadBehind, getCurrentBranch, resolveRepoRoot } from "../git.js";
12
15
  import { hint } from "../out.js";
13
- import type { BranchType, ParsedArgs, ResolvedConfig } from "../types.js";
14
-
15
- const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
16
-
17
- /**
18
- * Classifies a branch name into a workflow type, "main", "dev", or null (unknown).
19
- * Uses resolved config for main/dev names and prefixes.
20
- */
21
- function classifyBranch(
22
- branchName: string,
23
- config: ResolvedConfig,
24
- ): BranchType | "main" | "dev" | null {
25
- if (branchName === config.main) return "main";
26
- if (branchName === config.dev) return "dev";
27
- const { prefixes } = config;
28
- for (const type of BRANCH_TYPES) {
29
- const prefix = prefixes[type];
30
- if (prefix && branchName.startsWith(prefix)) {
31
- return type;
32
- }
33
- }
34
- return null;
35
- }
36
-
37
- /**
38
- * Formats merge target for display (actual branch names).
39
- */
40
- function formatMergeTarget(
41
- mergeTarget: "main" | "dev" | "main-then-dev",
42
- config: ResolvedConfig,
43
- ): string {
44
- if (mergeTarget === "main-then-dev") {
45
- return `${config.main}, then ${config.dev}`;
46
- }
47
- return mergeTarget === "main" ? config.main : config.dev;
48
- }
16
+ import { readActiveRun } from "../run-state.js";
17
+ import type { ParsedArgs } from "../types.js";
49
18
 
50
19
  /**
51
20
  * Runs the status command.
52
- * Shows current branch, classification, base, merge target(s), and ahead/behind vs base.
53
- * Output goes to stdout for scripts. On detached HEAD or non-repo, reports clearly and exits.
54
21
  */
55
22
  export async function run(args: ParsedArgs): Promise<void> {
56
- const { cwd, dryRun, verbose, quiet } = args;
23
+ const { cwd, dryRun, verbose, quiet, json } = args;
57
24
 
58
25
  const root = await resolveRepoRoot(cwd).catch((err: unknown) => {
59
26
  if (err instanceof NotRepoError) throw err;
@@ -70,22 +37,62 @@ export async function run(args: ParsedArgs): Promise<void> {
70
37
  verbose: !!verbose,
71
38
  });
72
39
 
40
+ const active = readActiveRun(root);
41
+
42
+ if (json) {
43
+ const payload: Record<string, unknown> = {
44
+ branch: current,
45
+ suspended: active
46
+ ? { command: active.command, status: active.status, nextStep: active.nextStep }
47
+ : null,
48
+ };
49
+ if (current) {
50
+ const classification = classifyBranch(current, config);
51
+ payload.type = classification;
52
+ if (classification && classification !== "main" && classification !== "dev") {
53
+ const mergeTarget = await resolveMergeTarget(root, current, classification, config, {
54
+ dryRun: !!dryRun,
55
+ verbose: !!verbose,
56
+ });
57
+ const base = getBaseBranchName(classification, false, config);
58
+ const ab = await getAheadBehind(root, base, current, {
59
+ dryRun: !!dryRun,
60
+ verbose: !!verbose,
61
+ });
62
+ payload.base = base;
63
+ payload.mergeTarget = mergeTarget;
64
+ payload.mergeTargetDisplay = formatMergeTarget(mergeTarget, config);
65
+ payload.ahead = ab.ahead;
66
+ payload.behind = ab.behind;
67
+ }
68
+ }
69
+ console.log(JSON.stringify(payload, null, 2));
70
+ return;
71
+ }
72
+
73
+ if (active && !quiet) {
74
+ console.log(`Suspended: ${active.command} (${active.status})`);
75
+ hint("Run gflows continue after resolving conflicts, or gflows abort / undo.");
76
+ }
77
+
73
78
  if (current === null) {
74
79
  if (!quiet) {
75
80
  console.log("HEAD is detached.");
81
+ hint("Try: git checkout dev");
76
82
  }
77
83
  return;
78
84
  }
79
85
 
80
- const classification = classifyBranch(current, config);
81
-
82
86
  if (!quiet) {
83
87
  console.log(`Branch: ${current}`);
84
88
  }
85
89
 
90
+ const classification = classifyBranch(current, config);
91
+
86
92
  if (classification === "main") {
87
93
  if (!quiet) {
88
94
  console.log("Type: long-lived (main)");
95
+ hint("Run gflows start feature <name> or gflows start hotfix vX.Y.Z");
89
96
  }
90
97
  return;
91
98
  }
@@ -93,6 +100,7 @@ export async function run(args: ParsedArgs): Promise<void> {
93
100
  if (classification === "dev") {
94
101
  if (!quiet) {
95
102
  console.log("Type: long-lived (dev)");
103
+ hint("Run gflows start feature <name> to begin work.");
96
104
  }
97
105
  return;
98
106
  }
@@ -100,13 +108,21 @@ export async function run(args: ParsedArgs): Promise<void> {
100
108
  if (classification === null) {
101
109
  if (!quiet) {
102
110
  console.log("Type: unknown");
111
+ hint("Use a typed prefix (feature/, bugfix/, …) or gflows start …");
103
112
  }
104
113
  return;
105
114
  }
106
115
 
107
- const meta = getBranchTypeMeta(classification);
108
- const baseBranch = meta.base === "main" ? config.main : config.dev;
109
- const mergeTargetDisplay = formatMergeTarget(meta.mergeTarget, config);
116
+ const mergeTarget = await resolveMergeTarget(root, current, classification, config, {
117
+ dryRun: !!dryRun,
118
+ verbose: !!verbose,
119
+ });
120
+ const baseBranch = getBaseBranchName(
121
+ classification,
122
+ mergeTarget === "main-then-dev" && classification === "bugfix",
123
+ config,
124
+ );
125
+ const mergeTargetDisplay = formatMergeTarget(mergeTarget, config);
110
126
 
111
127
  if (!quiet) {
112
128
  console.log(`Type: ${classification}`);
@@ -121,7 +137,10 @@ export async function run(args: ParsedArgs): Promise<void> {
121
137
 
122
138
  if (!quiet) {
123
139
  console.log(`Ahead/behind: ${ahead} ahead, ${behind} behind`);
124
- // Hint: suggest next step — finish current branch
125
- hint(`Run gflows finish ${classification} to merge into ${mergeTargetDisplay}.`);
140
+ if (ahead === 0) {
141
+ hint("No commits to finish yet commit changes first.");
142
+ } else {
143
+ hint(`Run gflows finish ${classification} to merge into ${mergeTargetDisplay}.`);
144
+ }
126
145
  }
127
146
  }
@@ -92,19 +92,14 @@ export async function run(args: ParsedArgs): Promise<void> {
92
92
  dryRun,
93
93
  verbose: args.verbose,
94
94
  });
95
- const { select } = await import("@inquirer/prompts");
96
- const chosen = await select({
95
+ const { selectPrompt } = await import("../prompts.js");
96
+ targetBranch = await selectPrompt({
97
97
  message: "Switch to branch",
98
- choices: choices.map((b) => ({
99
- name: currentBranchForPicker && b === currentBranchForPicker ? `${b} (current)` : b,
98
+ options: choices.map((b) => ({
99
+ label: currentBranchForPicker && b === currentBranchForPicker ? `${b} (current)` : b,
100
100
  value: b,
101
101
  })),
102
102
  });
103
-
104
- if (typeof chosen !== "string") {
105
- process.exit(EXIT_USER);
106
- }
107
- targetBranch = chosen;
108
103
  }
109
104
 
110
105
  const gitOpts = { dryRun, verbose: args.verbose };
@@ -115,21 +110,21 @@ export async function run(args: ParsedArgs): Promise<void> {
115
110
  if (args.switchMode !== undefined) {
116
111
  whenUncommitted = args.switchMode;
117
112
  } else if (!treeClean && isTTY) {
118
- const { select: selectPrompt } = await import("@inquirer/prompts");
119
- whenUncommitted = await selectPrompt({
113
+ const { selectPrompt } = await import("../prompts.js");
114
+ whenUncommitted = await selectPrompt<"move" | "restore" | "clean" | "destroy" | "cancel">({
120
115
  message: "Working tree has uncommitted changes. What do you want to do?",
121
- choices: [
122
- { name: "Move — Move current changes to the target branch", value: "move" as const },
116
+ options: [
117
+ { label: "Move — Move current changes to the target branch", value: "move" },
123
118
  {
124
- name: "Restore — Save changes for this branch; restore target's saved state (if any)",
125
- value: "restore" as const,
119
+ label: "Restore — Save changes for this branch; restore target's saved state (if any)",
120
+ value: "restore",
126
121
  },
127
- { name: "Clean — Discard changes and switch clean at HEAD", value: "clean" as const },
122
+ { label: "Clean — Discard changes and switch clean at HEAD", value: "clean" },
128
123
  {
129
- name: "Destroy — Delete current branch and switch to the target branch",
130
- value: "destroy" as const,
124
+ label: "Destroy — Delete current branch and switch to the target branch",
125
+ value: "destroy",
131
126
  },
132
- { name: "Cancel — Abort switching", value: "cancel" as const },
127
+ { label: "Cancel — Abort switching", value: "cancel" },
133
128
  ],
134
129
  });
135
130
  } else if (!treeClean) {
@@ -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
+ }