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
@@ -1,81 +1,234 @@
1
1
  /**
2
2
  * Finish command: merge workflow branch into target(s), optional tag, delete branch, and push.
3
- * Resolves branch (current, -B <name>, or picker when -B with no value and TTY).
4
- * Uses normal merge with optional --no-ff; on conflict exits with clear message. Release/hotfix: merge to main then dev, create tag.
3
+ * Guards empty/dirty finishes, delete-by-default, bugfix-from-main, plan preview, run-state.
5
4
  * @module commands/finish
6
5
  */
7
6
 
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
8
9
  import { getBranchTypeMeta, resolveConfig } from "../config.js";
9
- import { EXIT_USER, VERSION_REGEX } from "../constants.js";
10
- import { BranchNotFoundError } from "../errors.js";
10
+ import { EXIT_USER } from "../constants.js";
11
+ import { BranchNotFoundError, DirtyWorkingTreeError, NothingToFinishError } from "../errors.js";
12
+ import {
13
+ filterWorkflowBranches,
14
+ formatMergeTarget,
15
+ normalizeTagVersion,
16
+ parseBranchTypeAndVersion,
17
+ primaryTargetBranch,
18
+ resolveMergeTarget,
19
+ } from "../flow.js";
11
20
  import {
12
21
  assertNoRebaseOrMerge,
13
22
  assertNotDetached,
14
23
  branchList,
15
24
  checkout,
16
25
  deleteBranch,
26
+ deleteRemoteBranch,
27
+ getAheadBehind,
17
28
  getCurrentBranch,
29
+ getUpstream,
30
+ isClean,
18
31
  merge,
19
32
  push,
20
33
  resolveRepoRoot,
34
+ resolveSha,
35
+ runGit,
21
36
  tag,
22
37
  tagExists,
23
38
  } from "../git.js";
24
39
  import { hint, success } from "../out.js";
25
- import type { BranchType, ParsedArgs } from "../types.js";
40
+ import {
41
+ completeRun,
42
+ type GflowsRunState,
43
+ startRun,
44
+ suspendRun,
45
+ writeActiveRun,
46
+ } from "../run-state.js";
47
+ import type { BranchType, MergeTarget, ParsedArgs, ResolvedConfig } from "../types.js";
26
48
 
27
- /** Normalizes version to vX.Y.Z for tag name. */
28
- function normalizeTagVersion(version: string): string {
29
- const v = version.trim();
30
- return v.startsWith("v") ? v : `v${v}`;
49
+ interface FinishContext {
50
+ branchToFinish: string;
51
+ type: BranchType;
52
+ version?: string;
53
+ mergeTarget: MergeTarget;
54
+ shouldDelete: boolean;
55
+ doPush: boolean;
56
+ noFf: boolean;
57
+ squash: boolean;
58
+ message?: string;
59
+ signTag: boolean;
60
+ noTag: boolean;
61
+ tagMessage?: string;
62
+ remote: string;
63
+ main: string;
64
+ dev: string;
65
+ bumpOnFinish: boolean;
31
66
  }
32
67
 
33
68
  /**
34
- * Returns workflow branches (local) that match any configured prefix.
69
+ * Runs remaining finish steps from run state (also used by continue).
35
70
  */
36
- async function getWorkflowBranches(
37
- cwd: string,
38
- prefixes: Record<BranchType, string>,
39
- ): Promise<string[]> {
40
- const all = await branchList(cwd, { dryRun: false, verbose: false });
41
- const workflow: string[] = [];
42
- for (const b of all) {
43
- for (const prefix of Object.values(prefixes)) {
44
- if (b.startsWith(prefix)) {
45
- workflow.push(b);
46
- break;
47
- }
71
+ export async function executeFinishSteps(
72
+ repoRoot: string,
73
+ state: GflowsRunState,
74
+ opts: { dryRun: boolean; verbose: boolean; quiet: boolean },
75
+ ): Promise<void> {
76
+ const ctx = state.context as unknown as FinishContext;
77
+ let i = state.nextStep;
78
+ while (i < state.steps.length) {
79
+ const step = state.steps[i];
80
+ if (!step) break;
81
+ try {
82
+ await runFinishStep(repoRoot, step.id, ctx, opts);
83
+ i += 1;
84
+ state.nextStep = i;
85
+ writeActiveRun(repoRoot, { ...state, status: "running" });
86
+ } catch (err) {
87
+ const msg = err instanceof Error ? err.message : String(err);
88
+ suspendRun(repoRoot, { ...state, nextStep: i }, msg);
89
+ throw err;
48
90
  }
49
91
  }
50
- return workflow.sort();
92
+ completeRun(repoRoot, state);
93
+ if (!opts.quiet && !opts.dryRun) {
94
+ const tagName = typeof state.undo.createdTag === "string" ? state.undo.createdTag : undefined;
95
+ const tagSuffix = tagName ? ` (tag ${tagName})` : "";
96
+ const targets = formatMergeTarget(ctx.mergeTarget, {
97
+ main: ctx.main,
98
+ dev: ctx.dev,
99
+ remote: ctx.remote,
100
+ prefixes: {
101
+ feature: "feature/",
102
+ bugfix: "bugfix/",
103
+ chore: "chore/",
104
+ release: "release/",
105
+ hotfix: "hotfix/",
106
+ spike: "spike/",
107
+ },
108
+ });
109
+ success(`gflows: finished '${ctx.branchToFinish}' into ${targets}${tagSuffix}.`);
110
+ hint("Run gflows start <type> <name> to create a new workflow branch.");
111
+ }
51
112
  }
52
113
 
53
- /**
54
- * Infers branch type and optional version from branch name using config prefixes.
55
- */
56
- function parseBranchTypeAndVersion(
57
- branchName: string,
58
- prefixes: Record<BranchType, string>,
59
- ): { type: BranchType; version?: string } | null {
60
- for (const type of ["release", "hotfix", "feature", "bugfix", "chore", "spike"] as BranchType[]) {
61
- const prefix = prefixes[type];
62
- if (prefix && branchName.startsWith(prefix)) {
63
- const suffix = branchName.slice(prefix.length);
64
- if (type === "release" || type === "hotfix") {
65
- const ver = suffix.trim();
66
- return VERSION_REGEX.test(ver) ? { type, version: ver } : { type };
114
+ async function runFinishStep(
115
+ repoRoot: string,
116
+ stepId: string,
117
+ ctx: FinishContext,
118
+ opts: { dryRun: boolean; verbose: boolean; quiet: boolean },
119
+ ): Promise<void> {
120
+ const mergeOpts = {
121
+ ...opts,
122
+ noFf: ctx.noFf,
123
+ squash: ctx.squash,
124
+ message: ctx.message,
125
+ };
126
+
127
+ switch (stepId) {
128
+ case "bump": {
129
+ if (!ctx.bumpOnFinish) return;
130
+ // Bump is handled before run starts when requested; step reserved for resume
131
+ return;
132
+ }
133
+ case "merge-dev": {
134
+ await checkout(repoRoot, ctx.dev, opts);
135
+ await merge(repoRoot, ctx.branchToFinish, mergeOpts);
136
+ return;
137
+ }
138
+ case "merge-main": {
139
+ await checkout(repoRoot, ctx.main, opts);
140
+ await merge(repoRoot, ctx.branchToFinish, mergeOpts);
141
+ return;
142
+ }
143
+ case "tag": {
144
+ if (ctx.noTag || !ctx.version) return;
145
+ const tagName = normalizeTagVersion(ctx.version);
146
+ await tag(repoRoot, tagName, {
147
+ ...opts,
148
+ sign: ctx.signTag,
149
+ tagMessage: ctx.tagMessage,
150
+ });
151
+ if (!opts.quiet && !opts.dryRun) {
152
+ success(`gflows: created tag '${tagName}'.`);
67
153
  }
68
- return { type };
154
+ return;
155
+ }
156
+ case "merge-main-into-dev": {
157
+ await checkout(repoRoot, ctx.dev, opts);
158
+ await merge(repoRoot, ctx.main, { ...opts, noFf: ctx.noFf, message: ctx.message });
159
+ return;
69
160
  }
161
+ case "delete": {
162
+ if (!ctx.shouldDelete) return;
163
+ const upstream = await getUpstream(repoRoot, ctx.branchToFinish, opts).catch(() => null);
164
+ await deleteBranch(repoRoot, ctx.branchToFinish, { ...opts, force: true });
165
+ if (!opts.quiet && !opts.dryRun) {
166
+ success(`gflows: deleted branch '${ctx.branchToFinish}'.`);
167
+ }
168
+ if (!opts.dryRun && upstream) {
169
+ const code = await deleteRemoteBranch(repoRoot, ctx.remote, ctx.branchToFinish, opts);
170
+ if (code === 0 && !opts.quiet) {
171
+ success(`gflows: deleted remote branch '${ctx.remote}/${ctx.branchToFinish}'.`);
172
+ }
173
+ }
174
+ return;
175
+ }
176
+ case "push": {
177
+ if (!ctx.doPush) return;
178
+ const refsToPush: string[] = [ctx.dev];
179
+ if (ctx.mergeTarget === "main-then-dev") {
180
+ refsToPush.push(ctx.main);
181
+ }
182
+ const didCreateTag = Boolean(ctx.version && !ctx.noTag);
183
+ const pushCode = await push(repoRoot, ctx.remote, refsToPush, didCreateTag, opts);
184
+ if (pushCode !== 0) {
185
+ throw new Error(
186
+ "Merge and tag succeeded locally, but push failed. Retry with `git push` or `gflows continue` / `gflows finish ... --push`.",
187
+ );
188
+ }
189
+ if (!opts.quiet && !opts.dryRun) {
190
+ success(`gflows: pushed to ${ctx.remote}.`);
191
+ }
192
+ return;
193
+ }
194
+ case "changelog": {
195
+ maybeTouchChangelog(repoRoot, ctx.version, opts);
196
+ return;
197
+ }
198
+ default:
199
+ return;
200
+ }
201
+ }
202
+
203
+ function maybeTouchChangelog(
204
+ repoRoot: string,
205
+ version: string | undefined,
206
+ opts: { dryRun: boolean; quiet: boolean },
207
+ ): void {
208
+ if (!version) return;
209
+ const path = join(repoRoot, "CHANGELOG.md");
210
+ if (!existsSync(path)) return;
211
+ if (opts.dryRun) return;
212
+ try {
213
+ const raw = readFileSync(path, "utf-8");
214
+ if (!raw.includes("## [Unreleased]")) return;
215
+ const tag = normalizeTagVersion(version);
216
+ if (raw.includes(`## [${tag.replace(/^v/, "")}]`) || raw.includes(`## [${tag}]`)) return;
217
+ const date = new Date().toISOString().slice(0, 10);
218
+ const ver = tag.replace(/^v/, "");
219
+ const stub = `## [Unreleased]\n\n## [${ver}] - ${date}\n\n`;
220
+ const updated = raw.replace("## [Unreleased]\n", stub);
221
+ writeFileSync(path, updated, "utf-8");
222
+ if (!opts.quiet) {
223
+ success(`gflows: updated CHANGELOG.md for ${ver}.`);
224
+ }
225
+ } catch {
226
+ // ignore changelog failures
70
227
  }
71
- return null;
72
228
  }
73
229
 
74
230
  /**
75
- * Runs the finish command: resolve branch, run pre-checks, merge to target(s), optional tag/delete/push.
76
- * Pre-checks: repo, not detached HEAD, no rebase/merge, branch is not main/dev; for release/hotfix tag must not exist.
77
- *
78
- * @param args - Parsed CLI args (cwd, type, branch, push, noPush, remote, dryRun, verbose, quiet, yes, noFf, deleteAfterFinish, noDeleteAfterFinish, signTag, noTag, tagMessage).
231
+ * Runs the finish command.
79
232
  */
80
233
  export async function run(args: ParsedArgs): Promise<void> {
81
234
  const repoRoot = await resolveRepoRoot(args.cwd);
@@ -85,14 +238,9 @@ export async function run(args: ParsedArgs): Promise<void> {
85
238
  { verbose: args.verbose },
86
239
  );
87
240
 
88
- const opts: { dryRun: boolean; verbose: boolean } = {
89
- dryRun: args.dryRun,
90
- verbose: args.verbose,
91
- };
92
-
241
+ const opts = { dryRun: args.dryRun, verbose: args.verbose, quiet: args.quiet };
93
242
  const isTTY = Boolean(process.stdin.isTTY);
94
243
 
95
- // Resolve branch to finish (and guard main/dev early so we can error before picker)
96
244
  let branchToFinish: string;
97
245
  const explicitBranch =
98
246
  typeof args.branch === "string" && args.branch.trim() !== "" ? args.branch.trim() : undefined;
@@ -100,15 +248,24 @@ export async function run(args: ParsedArgs): Promise<void> {
100
248
  if (explicitBranch) {
101
249
  branchToFinish = explicitBranch;
102
250
  } else if (isTTY) {
103
- const workflow = await getWorkflowBranches(repoRoot, config.prefixes);
251
+ const workflow = filterWorkflowBranches(
252
+ await branchList(repoRoot, { dryRun: false, verbose: false }),
253
+ config.prefixes,
254
+ );
104
255
  if (workflow.length === 0) {
105
256
  console.error("gflows finish: no workflow branches found.");
257
+ hint("Create one with: gflows start feature <name>");
106
258
  process.exit(EXIT_USER);
107
259
  }
108
- const { select } = await import("@inquirer/prompts");
109
- branchToFinish = await select({
260
+ const current = await getCurrentBranch(repoRoot, opts);
261
+ const { selectPrompt } = await import("../prompts.js");
262
+ branchToFinish = await selectPrompt({
110
263
  message: "Branch to finish",
111
- choices: workflow.map((b) => ({ name: b, value: b })),
264
+ options: workflow.map((b) => ({
265
+ label: b === current ? `${b} (current)` : b,
266
+ value: b,
267
+ })),
268
+ initialValue: current && workflow.includes(current) ? current : workflow[0],
112
269
  });
113
270
  } else {
114
271
  const current = await getCurrentBranch(repoRoot, opts);
@@ -121,7 +278,6 @@ export async function run(args: ParsedArgs): Promise<void> {
121
278
  branchToFinish = current;
122
279
  }
123
280
 
124
- // Refuse finish on main or dev
125
281
  if (branchToFinish === config.main || branchToFinish === config.dev) {
126
282
  console.error(
127
283
  `gflows finish: cannot finish the long-lived branch '${branchToFinish}'. Finish a workflow branch (feature, bugfix, etc.) instead.`,
@@ -129,12 +285,11 @@ export async function run(args: ParsedArgs): Promise<void> {
129
285
  process.exit(2);
130
286
  }
131
287
 
132
- // Infer type from branch name (or use args.type if provided and consistent)
133
288
  const parsed = parseBranchTypeAndVersion(branchToFinish, config.prefixes);
134
289
  const type: BranchType | undefined = args.type ?? parsed?.type ?? undefined;
135
290
  if (!type) {
136
291
  console.error(
137
- `gflows finish: cannot determine branch type for '${branchToFinish}'. Specify type (e.g. gflows finish feature) or use a branch name with a known prefix.`,
292
+ `gflows finish: cannot determine branch type for '${branchToFinish}'. Specify type (e.g. gflows finish feature) or use a known prefix (${Object.values(config.prefixes).join(", ")}).`,
138
293
  );
139
294
  process.exit(EXIT_USER);
140
295
  }
@@ -145,19 +300,38 @@ export async function run(args: ParsedArgs): Promise<void> {
145
300
  process.exit(EXIT_USER);
146
301
  }
147
302
 
148
- const meta = getBranchTypeMeta(type);
149
303
  const version = parsed?.version;
304
+ const meta = getBranchTypeMeta(type);
305
+ const mergeTarget = await resolveMergeTarget(repoRoot, branchToFinish, type, config, opts);
150
306
 
151
- // Pre-checks: not detached, no rebase/merge
152
307
  await assertNotDetached(repoRoot);
153
308
  assertNoRebaseOrMerge(repoRoot);
154
309
 
155
- // For release/hotfix: tag must not already exist
310
+ if (!args.force) {
311
+ const current = await getCurrentBranch(repoRoot, opts);
312
+ if (current === branchToFinish) {
313
+ const clean = await isClean(repoRoot, opts);
314
+ if (!clean) {
315
+ throw new DirtyWorkingTreeError(
316
+ "Working tree has uncommitted changes. Commit or stash them before finish, or use --force.",
317
+ );
318
+ }
319
+ }
320
+ }
321
+
322
+ const primary = primaryTargetBranch(mergeTarget, config);
323
+ const { ahead } = await getAheadBehind(repoRoot, primary, branchToFinish, opts);
324
+ if (ahead === 0) {
325
+ throw new NothingToFinishError(
326
+ `Nothing to finish: '${branchToFinish}' has no commits beyond '${primary}'.`,
327
+ );
328
+ }
329
+
156
330
  if (meta.tagOnFinish && version) {
157
331
  const tagName = normalizeTagVersion(version);
158
- const exists = await tagExists(repoRoot, tagName, opts);
159
- if (exists) {
332
+ if (await tagExists(repoRoot, tagName, opts)) {
160
333
  console.error(`gflows finish: tag '${tagName}' already exists.`);
334
+ hint("Use a new version, or delete the tag if you intend to recreate it.");
161
335
  process.exit(2);
162
336
  }
163
337
  } else if (meta.tagOnFinish && !version) {
@@ -167,7 +341,6 @@ export async function run(args: ParsedArgs): Promise<void> {
167
341
  process.exit(EXIT_USER);
168
342
  }
169
343
 
170
- // Ensure the branch we're finishing exists (e.g. if -B was used)
171
344
  const branches = await branchList(repoRoot, { ...opts, dryRun: false });
172
345
  if (!branches.includes(branchToFinish)) {
173
346
  throw new BranchNotFoundError(
@@ -175,92 +348,145 @@ export async function run(args: ParsedArgs): Promise<void> {
175
348
  );
176
349
  }
177
350
 
178
- const noFf = args.noFf;
179
- if (meta.mergeTarget === "dev") {
180
- await checkout(repoRoot, config.dev, opts);
181
- await merge(repoRoot, branchToFinish, { ...opts, noFf });
182
- } else {
183
- // main-then-dev: merge into main first, then merge main into dev
184
- await checkout(repoRoot, config.main, opts);
185
- await merge(repoRoot, branchToFinish, { ...opts, noFf });
186
-
187
- if (meta.tagOnFinish && version && !args.noTag) {
188
- const tagName = normalizeTagVersion(version);
189
- await tag(repoRoot, tagName, {
190
- ...opts,
191
- sign: args.signTag,
192
- tagMessage: args.tagMessage,
193
- });
194
- if (!args.quiet && !args.dryRun) {
195
- success(`gflows: created tag '${tagName}'.`);
196
- }
197
- }
198
-
199
- await checkout(repoRoot, config.dev, opts);
200
- await merge(repoRoot, config.main, { ...opts, noFf });
351
+ // Delete default ON
352
+ let shouldDelete = true;
353
+ if (args.noDeleteAfterFinish) shouldDelete = false;
354
+ else if (args.deleteAfterFinish) shouldDelete = true;
355
+ else if (!args.yes && isTTY) {
356
+ const { confirmPrompt } = await import("../prompts.js");
357
+ shouldDelete = await confirmPrompt({
358
+ message: "Delete branch after finish?",
359
+ initialValue: true,
360
+ });
201
361
  }
362
+ // -y accepts plan including default delete
202
363
 
203
- // Optional: delete the finished branch
204
- let shouldDelete = args.deleteAfterFinish;
205
- if (!args.deleteAfterFinish && !args.noDeleteAfterFinish) {
206
- if (args.yes) {
207
- shouldDelete = false;
208
- } else if (isTTY) {
209
- const { confirm } = await import("@inquirer/prompts");
210
- shouldDelete = await confirm({
211
- message: "Delete branch after finish?",
212
- default: false,
213
- });
214
- }
364
+ let doPush = false;
365
+ if (args.push && !args.noPush) doPush = true;
366
+ else if (args.noPush) doPush = false;
367
+ else if (!isTTY) {
368
+ console.error("gflows finish: specify --push (-p) or --no-push (-P) when not interactive.");
369
+ process.exit(EXIT_USER);
370
+ } else if (!args.yes) {
371
+ const { confirmPrompt } = await import("../prompts.js");
372
+ doPush = await confirmPrompt({
373
+ message: "Push after finish?",
374
+ initialValue: false,
375
+ });
215
376
  }
216
- if (args.noDeleteAfterFinish) {
217
- shouldDelete = false;
377
+
378
+ const targetsDisplay = formatMergeTarget(mergeTarget, config);
379
+ const tagName =
380
+ meta.tagOnFinish && version && !args.noTag ? normalizeTagVersion(version) : undefined;
381
+
382
+ console.error("gflows finish plan:");
383
+ console.error(` branch: ${branchToFinish}`);
384
+ console.error(` merge: → ${targetsDisplay}`);
385
+ if (tagName) console.error(` tag: ${tagName}`);
386
+ console.error(` delete: ${shouldDelete ? "yes" : "no"}`);
387
+ console.error(` push: ${doPush ? "yes" : "no"}`);
388
+ if (args.squash) console.error(" squash: yes");
389
+
390
+ if (args.preview) {
391
+ success("gflows: preview only (no changes).");
392
+ return;
218
393
  }
219
394
 
220
- if (shouldDelete && !opts.dryRun) {
221
- await deleteBranch(repoRoot, branchToFinish, opts);
222
- if (!args.quiet) {
223
- success(`gflows: deleted branch '${branchToFinish}'.`);
224
- }
395
+ if (args.bumpOnFinish && (type === "release" || type === "hotfix") && version) {
396
+ await bumpAndCommit(repoRoot, version, args, config);
225
397
  }
226
398
 
227
- const createdTagName =
228
- meta.mergeTarget === "main-then-dev" && meta.tagOnFinish && version && !args.noTag
229
- ? normalizeTagVersion(version)
230
- : undefined;
231
- const didCreateTag = Boolean(createdTagName);
399
+ const steps =
400
+ mergeTarget === "dev"
401
+ ? [
402
+ { id: "merge-dev", label: `Merge into ${config.dev}` },
403
+ ...(shouldDelete ? [{ id: "delete", label: "Delete branch" }] : []),
404
+ ...(doPush ? [{ id: "push", label: "Push" }] : []),
405
+ ]
406
+ : [
407
+ { id: "merge-main", label: `Merge into ${config.main}` },
408
+ ...(tagName ? [{ id: "tag", label: `Tag ${tagName}` }] : []),
409
+ { id: "merge-main-into-dev", label: `Merge ${config.main} into ${config.dev}` },
410
+ ...(tagName ? [{ id: "changelog", label: "Update CHANGELOG" }] : []),
411
+ ...(shouldDelete ? [{ id: "delete", label: "Delete branch" }] : []),
412
+ ...(doPush ? [{ id: "push", label: "Push" }] : []),
413
+ ];
232
414
 
233
- let doPush = args.push && !args.noPush;
234
- if (!args.push && !args.noPush && isTTY) {
235
- const { confirm } = await import("@inquirer/prompts");
236
- doPush = await confirm({
237
- message: "Do you want to push?",
238
- default: true,
239
- });
240
- }
415
+ const branchSha = await resolveSha(repoRoot, branchToFinish, opts);
416
+ const mainSha = await resolveSha(repoRoot, config.main, opts);
417
+ const devSha = await resolveSha(repoRoot, config.dev, opts);
418
+ const prevBranch = await getCurrentBranch(repoRoot, opts);
419
+
420
+ const ctx: FinishContext = {
421
+ branchToFinish,
422
+ type,
423
+ version,
424
+ mergeTarget,
425
+ shouldDelete,
426
+ doPush,
427
+ noFf: args.noFf,
428
+ squash: args.squash,
429
+ message: args.message,
430
+ signTag: args.signTag,
431
+ noTag: args.noTag,
432
+ tagMessage: args.tagMessage,
433
+ remote: args.remote ?? config.remote,
434
+ main: config.main,
435
+ dev: config.dev,
436
+ bumpOnFinish: args.bumpOnFinish,
437
+ };
241
438
 
242
- if (doPush) {
243
- const remote = args.remote ?? config.remote;
244
- const refsToPush: string[] = [config.dev];
245
- if (meta.mergeTarget === "main-then-dev") {
246
- refsToPush.push(config.main);
439
+ const state = startRun(repoRoot, "finish", steps, ctx as unknown as Record<string, unknown>, {
440
+ branchSha,
441
+ mainSha,
442
+ devSha,
443
+ prevBranch,
444
+ createdTag: tagName ?? null,
445
+ branchName: branchToFinish,
446
+ shouldDelete,
447
+ main: config.main,
448
+ dev: config.dev,
449
+ });
450
+
451
+ await executeFinishSteps(repoRoot, state, opts);
452
+ }
453
+
454
+ async function bumpAndCommit(
455
+ repoRoot: string,
456
+ version: string,
457
+ args: ParsedArgs,
458
+ _config: ResolvedConfig,
459
+ ): Promise<void> {
460
+ const ver = version.replace(/^v/, "");
461
+ // Use bump command logic via spawning package files — light inline for finish --bump
462
+ const pkgPath = join(repoRoot, "package.json");
463
+ if (!existsSync(pkgPath)) return;
464
+ try {
465
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string };
466
+ pkg.version = ver;
467
+ if (!args.dryRun) {
468
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
247
469
  }
248
- const pushCode = await push(repoRoot, remote, refsToPush, didCreateTag, opts);
249
- if (pushCode !== 0) {
250
- console.error(
251
- "gflows: merge and tag succeeded locally, but push failed. Retry with `git push` or `gflows finish ... --push`.",
252
- );
253
- process.exit(2);
470
+ const jsrPath = join(repoRoot, "jsr.json");
471
+ if (existsSync(jsrPath)) {
472
+ let jsrRaw = readFileSync(jsrPath, "utf-8");
473
+ jsrRaw = jsrRaw.replace(/"version"\s*:\s*"[^"]*"/, `"version": "${ver}"`);
474
+ if (!args.dryRun) writeFileSync(jsrPath, jsrRaw, "utf-8");
254
475
  }
255
- if (!args.quiet && !args.dryRun) {
256
- success(`gflows: pushed to ${remote}.`);
476
+ if (!args.dryRun) {
477
+ await runGit(["add", "package.json", "jsr.json"], {
478
+ cwd: repoRoot,
479
+ dryRun: args.dryRun,
480
+ verbose: args.verbose,
481
+ });
482
+ await runGit(["commit", "-m", `chore: bump to ${ver}`], {
483
+ cwd: repoRoot,
484
+ dryRun: args.dryRun,
485
+ verbose: args.verbose,
486
+ });
487
+ if (!args.quiet) success(`gflows: bumped version to ${ver} and committed.`);
257
488
  }
258
- }
259
-
260
- if (!args.quiet && !args.dryRun) {
261
- const tagSuffix = createdTagName ? ` (tag ${createdTagName})` : "";
262
- success(`gflows: finished '${branchToFinish}' into ${meta.mergeTarget}${tagSuffix}.`);
263
- // Hint: suggest next step — create a new workflow branch
264
- hint("Run gflows start <type> <name> to create a new workflow branch.");
489
+ } catch {
490
+ hint("finish --bump: could not bump/commit version files; continuing finish.");
265
491
  }
266
492
  }