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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Help command: print quick reference from the spec and list commands/flags.
2
+ * Help command: print quick reference and list commands/flags.
3
3
  * @module commands/help
4
4
  */
5
5
 
@@ -7,58 +7,67 @@ import type { ParsedArgs } from "../types.js";
7
7
 
8
8
  /**
9
9
  * Runs the help command: prints usage, commands, types, flags, and exit codes to stdout.
10
- * @param _args - Parsed CLI args (unused; kept for command signature consistency).
11
10
  */
12
11
  export async function run(_args: ParsedArgs): Promise<void> {
13
12
  const out = `
14
13
  gflows — Modern Git branching workflow CLI
15
14
 
16
15
  Usage: gflows <command> [type] [name] [flags]
16
+ gflows (TTY: fullscreen hub)
17
17
 
18
18
  Commands:
19
- init, -I Ensure main, create dev
20
- start, -S Create workflow branch
21
- finish, -F Merge and close branch
19
+ init, -I Ensure main, create dev (TTY: wizard; optional package.json script alias)
20
+ start, -S Create workflow branch (TTY: prompts if args missing)
21
+ finish, -F Merge and close branch (plan + delete by default)
22
+ sync Update current branch from its base
23
+ pr Open PR/MR via gh or glab (correct base)
22
24
  switch, -W Switch branch (picker or name)
23
25
  delete, -L Delete local branch(es)
24
26
  list, -l List branches by type
25
- bump Bump or rollback package version
26
- completion Print shell completion script
27
27
  status, -t Show current branch flow info
28
+ viz Visual branch map + flow (also shown in interactive hub)
29
+ doctor Repo health checks
30
+ config get/set .gflows.json
31
+ bump, -U Bump or rollback package version
32
+ continue Resume after merge conflict
33
+ undo Undo last completed gflows operation
34
+ abort Abort suspended operation
35
+ schema Machine-readable command catalog (JSON)
36
+ mcp Start MCP server for AI agents
37
+ completion Print shell completion script
28
38
  help, -h Show this usage
29
39
  version, -V Show version
30
40
 
31
41
  Types: feature (-f), bugfix (-b), chore (-c), release (-r), hotfix (-x), spike (-e)
32
42
 
33
43
  Common flags:
34
- -p, --push Push after init/start/finish
35
- -P, --no-push Do not push (init/start: push is default; finish: prompts when neither -p nor -P)
44
+ -p, --push Push after init/start/finish/pr
45
+ -P, --no-push Do not push (init defaults to push; start does not)
36
46
  --main <name> Main branch (init: persist to .gflows.json)
37
47
  --dev <name> Dev branch (init: persist to .gflows.json)
38
48
  -R, --remote <name> Remote for push (init: persist to .gflows.json)
39
49
  -o, --from <branch> Base branch override (e.g. -o main for bugfix)
40
- -B, --branch <name> Branch name (finish: branch to finish)
41
- -y, --yes Skip confirmations
50
+ -B, --branch <name> Branch name (finish/pr)
51
+ -y, --yes Accept plan / skip confirmations (finish: delete default on)
42
52
  -d, --dry-run Log actions only, no writes
43
53
  -v, --verbose Verbose output
44
54
  -q, --quiet Minimal output
45
55
  -C, --path <dir> Run as if in <dir>
56
+ --json Machine-readable output (status/list/doctor/config)
46
57
 
58
+ Init: --script-alias <name> Add package.json script (e.g. g → "gflows")
59
+ --no-script-alias Never add a script alias
47
60
  Start: --force Allow dirty working tree
48
61
  Finish: --no-ff Always create merge commit; -D/--delete, -N/--no-delete;
49
- -s/--sign, -T/--no-tag, -M/--tag-message, -m/--message
62
+ --squash, --preview, --bump; -s/--sign, -T/--no-tag, -M/--tag-message, -m/--message
63
+ Sync: --rebase, --force
50
64
  List: -r, --include-remote Include remote-tracking branches
51
- Switch: --move Move current changes to the target branch
52
- --restore Save for this branch; restore target's saved state (if any)
53
- --clean Discard changes and switch clean at HEAD
54
- --cancel Abort switching
65
+ Switch: --move | --restore | --clean | --destroy | --cancel
55
66
 
56
67
  Exit codes: 0 success, 1 usage/validation, 2 Git or system error.
57
68
 
58
- Hints:
59
- gflows init then gflows start feature <name> — set up and create first branch
60
- • gflows finish <type> — merge current workflow branch (use -B <name> to specify branch)
61
- • gflows list -r — include remote branches; gflows status — show current branch flow
69
+ Stuck? gflows continue | gflows abort | gflows undo | gflows doctor
70
+ Agents: see AGENTS.md, gflows schema, gflows mcp
62
71
  `;
63
72
  console.log(out.trim());
64
73
  }
@@ -8,6 +8,7 @@ import { resolveConfig, writeConfigFile } from "../config.js";
8
8
  import { BranchNotFoundError, NotRepoError } from "../errors.js";
9
9
  import { branchList, push, resolveRepoRoot, revParse, runGit } from "../git.js";
10
10
  import { banner, hint, success } from "../out.js";
11
+ import { ensureGflowsScriptAlias } from "../package-scripts.js";
11
12
  import type { ParsedArgs } from "../types.js";
12
13
 
13
14
  /**
@@ -20,12 +21,56 @@ import type { ParsedArgs } from "../types.js";
20
21
  */
21
22
  export async function run(args: ParsedArgs): Promise<void> {
22
23
  const repoRoot = await resolveRepoRoot(args.cwd);
24
+
25
+ // TTY setup wizard when no explicit config flags
26
+ let main = args.main;
27
+ let dev = args.dev;
28
+ let remote = args.remote;
29
+ let scriptAlias = args.noScriptAlias ? undefined : args.scriptAlias;
30
+ if (
31
+ process.stdin.isTTY &&
32
+ main === undefined &&
33
+ dev === undefined &&
34
+ remote === undefined &&
35
+ !args.yes
36
+ ) {
37
+ const { confirmPrompt, inputPrompt, selectPrompt } = await import("../prompts.js");
38
+ const defaults = resolveConfig(repoRoot, {}, { verbose: args.verbose });
39
+ main = await inputPrompt({ message: "Main branch name", defaultValue: defaults.main });
40
+ dev = await inputPrompt({ message: "Dev branch name", defaultValue: defaults.dev });
41
+ remote = await inputPrompt({ message: "Remote name", defaultValue: defaults.remote });
42
+ const persist = await confirmPrompt({ message: "Write .gflows.json?", initialValue: true });
43
+ if (persist && !args.dryRun) {
44
+ writeConfigFile(repoRoot, { main, dev, remote });
45
+ }
46
+ if (!args.noScriptAlias && scriptAlias === undefined) {
47
+ const aliasChoice = await selectPrompt({
48
+ message: "Add a short script in package.json? (shell alias is still nicer — see docs)",
49
+ options: [
50
+ {
51
+ value: "g",
52
+ label: '"g": "gflows"',
53
+ hint: "bun run g -- start feature x",
54
+ },
55
+ {
56
+ value: "gflows",
57
+ label: '"gflows": "gflows"',
58
+ hint: "bun run gflows -- …",
59
+ },
60
+ { value: "skip", label: "Skip", hint: "use a shell alias instead" },
61
+ ],
62
+ initialValue: "g",
63
+ });
64
+ scriptAlias = aliasChoice === "skip" ? undefined : aliasChoice;
65
+ }
66
+ }
67
+
23
68
  const config = resolveConfig(
24
69
  repoRoot,
25
70
  {
26
- main: args.main,
27
- dev: args.dev,
28
- remote: args.remote,
71
+ main,
72
+ dev,
73
+ remote,
29
74
  },
30
75
  { verbose: args.verbose },
31
76
  );
@@ -72,32 +117,68 @@ export async function run(args: ParsedArgs): Promise<void> {
72
117
 
73
118
  const doPush = !args.noPush;
74
119
  if (doPush) {
75
- const pushCode = await push(repoRoot, config.remote, [config.dev], false, opts);
76
- if (pushCode !== 0) {
77
- throw new Error(
78
- `Push failed. Local branch '${config.dev}' was created. Retry with \`git push ${config.remote} ${config.dev}\` or \`gflows init\`.`,
79
- );
80
- }
81
- if (!args.quiet && !args.dryRun) {
82
- success(`gflows: pushed '${config.dev}' to '${config.remote}'.`);
120
+ const remotes = await runGit(["remote"], { cwd: repoRoot, ...opts });
121
+ const hasRemote = remotes.stdout
122
+ .split("\n")
123
+ .map((s) => s.trim())
124
+ .includes(config.remote);
125
+ if (!hasRemote) {
126
+ if (!args.quiet) {
127
+ hint(
128
+ `Remote '${config.remote}' not found — skipped push. Add a remote or re-run with --no-push.`,
129
+ );
130
+ }
131
+ } else {
132
+ const pushCode = await push(repoRoot, config.remote, [config.dev], false, opts);
133
+ if (pushCode !== 0) {
134
+ throw new Error(
135
+ `Push failed. Local branch '${config.dev}' was created. Retry with \`git push ${config.remote} ${config.dev}\` or \`gflows init --no-push\`.`,
136
+ );
137
+ }
138
+ if (!args.quiet && !args.dryRun) {
139
+ success(`gflows: pushed '${config.dev}' to '${config.remote}'.`);
140
+ }
83
141
  }
84
142
  }
85
143
 
86
- const hasConfigFlags =
87
- args.main !== undefined || args.dev !== undefined || args.remote !== undefined;
144
+ const hasConfigFlags = main !== undefined || dev !== undefined || remote !== undefined;
88
145
  if (!args.dryRun && hasConfigFlags) {
89
146
  writeConfigFile(repoRoot, {
90
- ...(args.main !== undefined && { main: args.main }),
91
- ...(args.dev !== undefined && { dev: args.dev }),
92
- ...(args.remote !== undefined && { remote: args.remote }),
147
+ ...(main !== undefined && { main }),
148
+ ...(dev !== undefined && { dev }),
149
+ ...(remote !== undefined && { remote }),
93
150
  });
94
151
  if (!args.quiet) {
95
152
  success("gflows: updated .gflows.json with provided options.");
96
153
  }
97
154
  }
98
155
 
156
+ if (scriptAlias && !args.noScriptAlias) {
157
+ const result = ensureGflowsScriptAlias(repoRoot, scriptAlias, { dryRun: args.dryRun });
158
+ if (!args.quiet) {
159
+ if (result.status === "added") {
160
+ success(
161
+ args.dryRun
162
+ ? `gflows: would add scripts["${result.name}"] = "gflows" to package.json.`
163
+ : `gflows: added scripts["${result.name}"] = "gflows" to package.json.`,
164
+ );
165
+ hint(`Run with: bun run ${result.name} -- start feature <name>`);
166
+ hint("Even shorter daily use: alias g=gflows (or alias g='bunx gflows')");
167
+ } else if (result.status === "unchanged") {
168
+ hint(`package.json already has scripts["${result.name}"] → gflows.`);
169
+ } else if (result.status === "conflict") {
170
+ hint(`Skipped script alias: scripts["${result.name}"] is already "${result.existing}".`);
171
+ } else if (result.status === "no-package") {
172
+ hint("No package.json here — skipped script alias. Use: alias g=gflows");
173
+ } else if (result.status === "invalid") {
174
+ hint(`Invalid script alias name '${result.name}'. Use letters/numbers/_-: (e.g. g).`);
175
+ }
176
+ }
177
+ } else if (!args.quiet) {
178
+ hint("Tip: alias g=gflows in your shell, or re-run init with --script-alias g");
179
+ }
180
+
99
181
  if (!args.quiet) {
100
- // Hint: suggest next step — create first workflow branch
101
- hint("Run gflows start feature <name> to create a workflow branch.");
182
+ hint("Next: gflows start feature <name>");
102
183
  }
103
184
  }
@@ -71,12 +71,17 @@ export async function run(args: ParsedArgs): Promise<void> {
71
71
  const mainAndDev = [config.main, config.dev].filter((b) => allBranches.includes(b));
72
72
  const sorted = [...mainAndDev, ...[...workflowBranches].sort()];
73
73
 
74
+ if (args.json) {
75
+ console.log(JSON.stringify({ branches: sorted }, null, 2));
76
+ return;
77
+ }
78
+
74
79
  for (const b of sorted) {
75
80
  console.log(b);
76
81
  }
77
82
 
78
83
  if (!quiet && sorted.length > 0) {
79
- // Hint: suggest switching to a listed branch
84
+ // Hint on stderr stdout stays script-friendly
80
85
  hint("Use gflows switch <branch> to switch to a branch.");
81
86
  }
82
87
  }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Thin stdio MCP server exposing gflows tools for coding agents.
3
+ * @module commands/mcp
4
+ */
5
+
6
+ import { EXIT_OK } from "../constants.js";
7
+ import type { ParsedArgs } from "../types.js";
8
+
9
+ interface JsonRpcRequest {
10
+ jsonrpc?: string;
11
+ id?: string | number | null;
12
+ method?: string;
13
+ params?: Record<string, unknown>;
14
+ }
15
+
16
+ const TOOLS = [
17
+ {
18
+ name: "gflows_status",
19
+ description: "Show current branch flow info (non-interactive).",
20
+ inputSchema: {
21
+ type: "object",
22
+ properties: { path: { type: "string", description: "Repo path" }, json: { type: "boolean" } },
23
+ },
24
+ },
25
+ {
26
+ name: "gflows_doctor",
27
+ description: "Run repo health checks.",
28
+ inputSchema: {
29
+ type: "object",
30
+ properties: { path: { type: "string" } },
31
+ },
32
+ },
33
+ {
34
+ name: "gflows_list",
35
+ description: "List workflow branches.",
36
+ inputSchema: {
37
+ type: "object",
38
+ properties: {
39
+ path: { type: "string" },
40
+ type: { type: "string" },
41
+ includeRemote: { type: "boolean" },
42
+ },
43
+ },
44
+ },
45
+ {
46
+ name: "gflows_start",
47
+ description: "Create a workflow branch. Requires type and name.",
48
+ inputSchema: {
49
+ type: "object",
50
+ properties: {
51
+ path: { type: "string" },
52
+ type: { type: "string" },
53
+ name: { type: "string" },
54
+ from: { type: "string" },
55
+ push: { type: "boolean" },
56
+ },
57
+ required: ["type", "name"],
58
+ },
59
+ },
60
+ {
61
+ name: "gflows_sync",
62
+ description: "Sync current workflow branch from its base.",
63
+ inputSchema: {
64
+ type: "object",
65
+ properties: {
66
+ path: { type: "string" },
67
+ rebase: { type: "boolean" },
68
+ force: { type: "boolean" },
69
+ },
70
+ },
71
+ },
72
+ {
73
+ name: "gflows_finish",
74
+ description: "Finish a workflow branch (non-interactive; pass -y and push flag).",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ path: { type: "string" },
79
+ branch: { type: "string" },
80
+ type: { type: "string" },
81
+ push: { type: "boolean" },
82
+ noDelete: { type: "boolean" },
83
+ preview: { type: "boolean" },
84
+ },
85
+ },
86
+ },
87
+ {
88
+ name: "gflows_schema",
89
+ description: "Return the gflows command schema JSON.",
90
+ inputSchema: { type: "object", properties: {} },
91
+ },
92
+ ] as const;
93
+
94
+ function respond(id: string | number | null | undefined, result: unknown): void {
95
+ const msg = { jsonrpc: "2.0", id: id ?? null, result };
96
+ console.log(JSON.stringify(msg));
97
+ }
98
+
99
+ function respondError(id: string | number | null | undefined, message: string): void {
100
+ console.log(
101
+ JSON.stringify({
102
+ jsonrpc: "2.0",
103
+ id: id ?? null,
104
+ error: { code: -32000, message },
105
+ }),
106
+ );
107
+ }
108
+
109
+ async function runTool(
110
+ name: string,
111
+ args: Record<string, unknown>,
112
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
113
+ const cliPath = new URL("../cli.ts", import.meta.url).pathname;
114
+ const path = typeof args.path === "string" ? args.path : process.cwd();
115
+ const argv: string[] = ["-C", path, "-y"];
116
+
117
+ switch (name) {
118
+ case "gflows_status":
119
+ argv.push("status");
120
+ if (args.json !== false) argv.push("--json");
121
+ break;
122
+ case "gflows_doctor":
123
+ argv.push("doctor", "--json");
124
+ break;
125
+ case "gflows_list":
126
+ argv.push("list", "-q");
127
+ if (typeof args.type === "string") argv.push(args.type);
128
+ if (args.includeRemote) argv.push("-r");
129
+ break;
130
+ case "gflows_start":
131
+ argv.push("start", String(args.type), String(args.name));
132
+ if (typeof args.from === "string") argv.push("-o", args.from);
133
+ if (args.push) argv.push("-p");
134
+ else argv.push("-P");
135
+ break;
136
+ case "gflows_sync":
137
+ argv.push("sync");
138
+ if (args.rebase) argv.push("--rebase");
139
+ if (args.force) argv.push("--force");
140
+ break;
141
+ case "gflows_finish":
142
+ argv.push("finish");
143
+ if (typeof args.type === "string") argv.push(args.type);
144
+ if (typeof args.branch === "string") argv.push("-B", args.branch);
145
+ if (args.preview) argv.push("--preview");
146
+ if (args.noDelete) argv.push("-N");
147
+ if (args.push) argv.push("-p");
148
+ else argv.push("-P");
149
+ break;
150
+ case "gflows_schema":
151
+ argv.push("schema");
152
+ break;
153
+ default:
154
+ return { stdout: "", stderr: `Unknown tool ${name}`, exitCode: 1 };
155
+ }
156
+
157
+ const proc = Bun.spawn(["bun", cliPath, ...argv], {
158
+ cwd: path,
159
+ stdout: "pipe",
160
+ stderr: "pipe",
161
+ env: { ...process.env },
162
+ });
163
+ const [stdout, stderr] = await Promise.all([
164
+ new Response(proc.stdout).text(),
165
+ new Response(proc.stderr).text(),
166
+ ]);
167
+ const exitCode = await proc.exited;
168
+ return { stdout, stderr, exitCode };
169
+ }
170
+
171
+ /**
172
+ * Starts a minimal JSON-RPC MCP-like stdio loop.
173
+ */
174
+ export async function run(_args: ParsedArgs): Promise<void> {
175
+ console.error(
176
+ "gflows mcp: listening on stdio (JSON-RPC). Tools: status, doctor, list, start, sync, finish, schema.",
177
+ );
178
+
179
+ const decoder = new TextDecoder();
180
+ let buffer = "";
181
+
182
+ for await (const chunk of Bun.stdin.stream()) {
183
+ buffer += decoder.decode(chunk);
184
+ const lines = buffer.split("\n");
185
+ buffer = lines.pop() ?? "";
186
+ for (const line of lines) {
187
+ const trimmed = line.trim();
188
+ if (!trimmed) continue;
189
+ let req: JsonRpcRequest;
190
+ try {
191
+ req = JSON.parse(trimmed) as JsonRpcRequest;
192
+ } catch {
193
+ continue;
194
+ }
195
+
196
+ if (req.method === "initialize") {
197
+ respond(req.id, {
198
+ protocolVersion: "2024-11-05",
199
+ capabilities: { tools: {} },
200
+ serverInfo: { name: "gflows", version: "1.0.0" },
201
+ });
202
+ continue;
203
+ }
204
+ if (req.method === "notifications/initialized") {
205
+ continue;
206
+ }
207
+ if (req.method === "tools/list") {
208
+ respond(req.id, { tools: TOOLS });
209
+ continue;
210
+ }
211
+ if (req.method === "tools/call") {
212
+ const params = req.params ?? {};
213
+ const name = String(params.name ?? "");
214
+ const toolArgs = (params.arguments ?? {}) as Record<string, unknown>;
215
+ try {
216
+ const result = await runTool(name, toolArgs);
217
+ respond(req.id, {
218
+ content: [
219
+ {
220
+ type: "text",
221
+ text: JSON.stringify(result),
222
+ },
223
+ ],
224
+ isError: result.exitCode !== 0,
225
+ });
226
+ } catch (err) {
227
+ respondError(req.id, err instanceof Error ? err.message : String(err));
228
+ }
229
+ continue;
230
+ }
231
+ if (req.method === "ping") {
232
+ respond(req.id, {});
233
+ }
234
+ }
235
+ }
236
+
237
+ process.exit(EXIT_OK);
238
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Open a pull/merge request against the correct merge target via gh or glab.
3
+ * @module commands/pr
4
+ */
5
+
6
+ import { resolveConfig } from "../config.js";
7
+ import { EXIT_GIT, EXIT_USER } from "../constants.js";
8
+ import {
9
+ classifyBranch,
10
+ formatMergeTarget,
11
+ parseBranchTypeAndVersion,
12
+ primaryTargetBranch,
13
+ resolveMergeTarget,
14
+ } from "../flow.js";
15
+ import { assertNotDetached, getCurrentBranch, push, resolveRepoRoot, runGit } from "../git.js";
16
+ import { hint, success } from "../out.js";
17
+ import type { ParsedArgs } from "../types.js";
18
+
19
+ async function which(cmd: string): Promise<boolean> {
20
+ const r = Bun.spawn(["which", cmd], { stdout: "pipe", stderr: "pipe" });
21
+ await r.exited;
22
+ return r.exitCode === 0;
23
+ }
24
+
25
+ /**
26
+ * Creates a PR/MR for the current (or named) workflow branch.
27
+ */
28
+ export async function run(args: ParsedArgs): Promise<void> {
29
+ const repoRoot = await resolveRepoRoot(args.cwd);
30
+ const config = resolveConfig(
31
+ repoRoot,
32
+ { main: args.main, dev: args.dev, remote: args.remote },
33
+ { verbose: args.verbose },
34
+ );
35
+ const opts = { dryRun: args.dryRun, verbose: args.verbose };
36
+
37
+ await assertNotDetached(repoRoot);
38
+ const branch =
39
+ (typeof args.branch === "string" && args.branch.trim()) ||
40
+ args.name ||
41
+ (await getCurrentBranch(repoRoot, opts));
42
+ if (!branch) {
43
+ console.error("gflows pr: no branch. Checkout a workflow branch or pass -B <name>.");
44
+ process.exit(EXIT_USER);
45
+ }
46
+
47
+ const classification = classifyBranch(branch, config);
48
+ if (classification === "main" || classification === "dev" || classification === null) {
49
+ console.error(`gflows pr: '${branch}' is not a workflow branch.`);
50
+ process.exit(EXIT_USER);
51
+ }
52
+
53
+ const parsed = parseBranchTypeAndVersion(branch, config.prefixes);
54
+ const type = parsed?.type ?? classification;
55
+ const mergeTarget = await resolveMergeTarget(repoRoot, branch, type, config, opts);
56
+ const base = primaryTargetBranch(mergeTarget, config);
57
+
58
+ const hasGh = await which("gh");
59
+ const hasGlab = await which("glab");
60
+ if (!hasGh && !hasGlab) {
61
+ console.error("gflows pr: neither `gh` nor `glab` found on PATH.");
62
+ hint("Install GitHub CLI (gh) or GitLab CLI (glab), then retry.");
63
+ process.exit(EXIT_GIT);
64
+ }
65
+
66
+ console.error("gflows pr plan:");
67
+ console.error(` head: ${branch}`);
68
+ console.error(` base: ${base}`);
69
+ console.error(` via: ${hasGh ? "gh" : "glab"}`);
70
+
71
+ if (args.dryRun || args.preview) {
72
+ success("gflows: preview only (no PR created).");
73
+ return;
74
+ }
75
+
76
+ // Ensure upstream
77
+ const remote = args.remote ?? config.remote;
78
+ const pushCode = await push(repoRoot, remote, [branch], false, opts);
79
+ if (pushCode !== 0) {
80
+ // try set upstream
81
+ const r = await runGit(["push", "-u", remote, branch], { cwd: repoRoot, ...opts });
82
+ if (r.exitCode !== 0) {
83
+ console.error("gflows pr: failed to push branch to remote.");
84
+ process.exit(EXIT_GIT);
85
+ }
86
+ }
87
+
88
+ const title = branch;
89
+ if (hasGh) {
90
+ const proc = Bun.spawn(
91
+ ["gh", "pr", "create", "--base", base, "--head", branch, "--title", title, "--body", ""],
92
+ { cwd: repoRoot, stdout: "inherit", stderr: "inherit" },
93
+ );
94
+ const code = await proc.exited;
95
+ if (code !== 0) process.exit(EXIT_GIT);
96
+ } else {
97
+ const proc = Bun.spawn(
98
+ [
99
+ "glab",
100
+ "mr",
101
+ "create",
102
+ "--target-branch",
103
+ base,
104
+ "--source-branch",
105
+ branch,
106
+ "--title",
107
+ title,
108
+ "--yes",
109
+ ],
110
+ { cwd: repoRoot, stdout: "inherit", stderr: "inherit" },
111
+ );
112
+ const code = await proc.exited;
113
+ if (code !== 0) process.exit(EXIT_GIT);
114
+ }
115
+
116
+ if (!args.quiet) {
117
+ success(`gflows: opened PR/MR for '${branch}' → ${base}.`);
118
+ hint(`Merge target(s) for this type: ${formatMergeTarget(mergeTarget, config)}.`);
119
+ }
120
+ }