gflows 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -9,7 +9,7 @@ gflows is a **local Git branching workflow CLI** (main + dev + typed short-lived
9
9
  3. **Do not finish empty branches** — if there are no commits beyond the merge target, finish exits `2`. Commit first.
10
10
  4. **Conflicts** — resolve files, then `gflows continue`. Or `gflows abort` / `gflows undo`.
11
11
  5. **Discover the API** — run `gflows schema` (JSON) or read this file + README.
12
- 6. **MCP** — `gflows mcp` (stdio JSON-RPC) exposes status/doctor/info/list/start/sync/finish/schema tools.
12
+ 6. **MCP** — `gflows mcp` (stdio JSON-RPC) exposes status/doctor/info/list/start/sync/finish/release/schema tools.
13
13
  7. **Hub / viz** — bare `gflows` (TTY) opens an Ink fullscreen hub (`/` commands). Prompts use Clack. `gflows viz` prints the scrollback status panel.
14
14
 
15
15
  ## Exit codes
@@ -39,13 +39,17 @@ gflows start bugfix hotfix-login -o main
39
39
  # … commit …
40
40
  gflows finish bugfix -y -p # merges main, then main→dev
41
41
 
42
- # Release
42
+ # Release (branch workflow)
43
43
  gflows bump up minor
44
44
  # commit version files, or: gflows finish release --bump …
45
45
  gflows start release v1.2.0
46
46
  # … changelog / commits …
47
47
  gflows finish release -y -p
48
48
 
49
+ # Quick release from dev (bump or keep version + merge main + tag, no release/* branch)
50
+ gflows release up patch -y -p # or -P; must be on dev
51
+ gflows release current -y -p # after a manual bump; tags package version as-is
52
+
49
53
  # Stuck
50
54
  gflows doctor --json
51
55
  gflows continue
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.1] - 2026-07-25
11
+
12
+ ### Added
13
+
14
+ - Quick release can keep the package version already on disk: `gflows release current -y -p` (or choose **keep current** in the hub / interactive prompt)
15
+ - MCP `gflows_release` accepts `keepCurrent: true` as an alternative to `bumpType`
16
+
17
+ ## [1.2.0] - 2026-07-25
18
+
19
+ ### Added
20
+
21
+ - `gflows release` — quick release from `dev`: bump package version, merge into `main`, tag, sync `main` → `dev` (`up patch|minor|major`, `-y`, `-p`/`-P`)
22
+ - Hub `/release` wizard and MCP tool `gflows_release`
23
+
24
+ ### Fixed
25
+
26
+ - `gflows bump` (and finish `--bump`) update `package.json` version in place so tab/space formatting is preserved (Biome-friendly)
27
+
10
28
  ## [1.1.1] - 2026-07-25
11
29
 
12
30
  ### Fixed
package/README.md CHANGED
@@ -49,7 +49,7 @@ Then:
49
49
  g # or: gflows
50
50
  ```
51
51
 
52
- You get a fullscreen menu: what’s next, branch map, actions. **Select** an item (or type `/start`, `/finish`, `/sync`, …). Follow-up questions (type, name, push, rebase) stay **inside the Ink UI** — no drop-out to a separate prompt tool. Only the git command itself runs on the main screen, then you press enter to return.
52
+ You get a fullscreen menu: what’s next, branch map, actions. **Select** an item (or type `/start`, `/finish`, `/release`, `/sync`, …). Follow-up questions (type, name, push, rebase) stay **inside the Ink UI** — no drop-out to a separate prompt tool. Only the git command itself runs on the main screen, then you press enter to return.
53
53
 
54
54
  ```
55
55
  ❯ ★ Start new work
@@ -130,6 +130,7 @@ main ← release / hotfix only (production)
130
130
  | **dev** | Integration (created by `init` from main) |
131
131
  | **feature / bugfix / chore / spike** | Day-to-day work → merge to **dev** |
132
132
  | **release / hotfix** | Versioned (`vX.Y.Z`) → **main**, then **main → dev**, + tag |
133
+ | **quick release** (`gflows release`) | From **dev** only: bump or keep → **main** → tag → sync **dev** |
133
134
 
134
135
 
135
136
  **Lifecycle:** `init` → `start` → commit → (`sync`) → (`pr`) → `finish`
@@ -216,6 +217,14 @@ Hub: `g` → select the action. Typed short form assumes `alias g=gflows`.
216
217
  | `gflows start release v1.4.0` | `g -S -r v1.4.0` |
217
218
  | `gflows finish release -y -p` | `g -F -r -y -p` |
218
219
 
220
+ **Quick release from `dev`** — merge **main**, tag, sync **main → dev** (no `release/*` branch). Choose a bump, or keep a version you already set manually.
221
+
222
+ | Long | Short |
223
+ |------|-------|
224
+ | `gflows release up patch -y -p` | — (bump, then ship) |
225
+ | `gflows release up minor -y -p` | — |
226
+ | `gflows release current -y -p` | — (keep `package.json` version; no bump commit) |
227
+
219
228
  **Hotfix**
220
229
 
221
230
  | Long | Short |
@@ -247,7 +256,7 @@ Bare `gflows` / `g` in a **TTY** opens the Ink hub (see [Way 1](#way-1--hub-reco
247
256
  | Input | Action |
248
257
  | ------------------------------------------ | -------------------------------------- |
249
258
  | ↑↓ Enter | Select an action — Ink wizard if needed |
250
- | `/start` `/finish` `/sync` … | Slash command; wizards stay in the hub |
259
+ | `/start` `/finish` `/release` `/sync` … | Slash command; wizards stay in the hub |
251
260
  | `?` | Shortcut help |
252
261
  | `q` / Esc / Ctrl+C | Quit |
253
262
 
@@ -267,6 +276,7 @@ Bare `gflows` / `g` in a **TTY** opens the Ink hub (see [Way 1](#way-1--hub-reco
267
276
  | `init` `-I` | Ensure main; create dev |
268
277
  | `start` `-S` | Create typed branch |
269
278
  | `finish` `-F` | Merge + close (plan; delete default **on**) |
279
+ | `release` | Quick release from `dev` (bump/keep + main + tag)|
270
280
  | `sync` | Update branch from its base |
271
281
  | `pr` | Open PR/MR (`gh` / `glab`) |
272
282
  | `switch` `-W` | Switch branch |
package/llms.txt CHANGED
@@ -12,7 +12,9 @@
12
12
 
13
13
  - `gflows` (TTY): Ink fullscreen hub (frame, tips, actions, `/` slash commands, status bar); Clack prompts
14
14
  - `gflows viz`: scrollback status panel — lifecycle, branches, recommended next step
15
- - `gflows init` / `start` / `finish` / `sync` / `pr`
15
+ - `gflows init` / `start` / `finish` / `release` / `sync` / `pr`
16
+ - `gflows release up patch -y -p` — quick ship from `dev` (bump + merge main + tag)
17
+ - `gflows release current -y -p` — same, but keep the package version already on disk (after a manual bump)
16
18
  - `gflows continue` / `undo` / `abort` — recovery after conflicts
17
19
  - `gflows doctor` / `info` / `config` / `status --json` / `mcp`
18
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gflows",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "author": "Ali AlNaghmoush (https://github.com/alialnaghmoush)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -5,142 +5,10 @@
5
5
  * @module commands/bump
6
6
  */
7
7
 
8
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
- import { join, relative } from "node:path";
10
8
  import { EXIT_OK, EXIT_USER } from "../constants.js";
11
- import { InvalidVersionError } from "../errors.js";
12
9
  import { hint, success } from "../out.js";
13
- import { findPackageRoots, PACKAGE_JSON } from "../packages.js";
14
10
  import type { BumpDirection, BumpType, ParsedArgs } from "../types.js";
15
-
16
- const JSR_JSON = "jsr.json";
17
-
18
- /** Semver triplet. */
19
- interface Semver {
20
- major: number;
21
- minor: number;
22
- patch: number;
23
- }
24
-
25
- /**
26
- * Parses a version string (vX.Y.Z or X.Y.Z) into components.
27
- * @throws InvalidVersionError if format is invalid
28
- */
29
- function parseVersion(version: string): Semver {
30
- const trimmed = version.trim();
31
- const normalized = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
32
- const parts = normalized.split(".");
33
- if (parts.length !== 3 || parts.some((p) => !/^\d+$/.test(p))) {
34
- throw new InvalidVersionError(
35
- `Invalid version '${version}'. Expected format: X.Y.Z or vX.Y.Z (e.g. 1.2.3 or v1.2.3).`,
36
- );
37
- }
38
- const [p0, p1, p2] = parts;
39
- return {
40
- major: parseInt(p0 ?? "0", 10),
41
- minor: parseInt(p1 ?? "0", 10),
42
- patch: parseInt(p2 ?? "0", 10),
43
- };
44
- }
45
-
46
- /**
47
- * Formats semver as string (no leading v, for package.json).
48
- */
49
- function formatVersion(semver: Semver): string {
50
- return `${semver.major}.${semver.minor}.${semver.patch}`;
51
- }
52
-
53
- /**
54
- * Computes new version for bump up (patch/minor/major).
55
- */
56
- function bumpUp(semver: Semver, type: BumpType): Semver {
57
- switch (type) {
58
- case "patch":
59
- return { ...semver, patch: semver.patch + 1 };
60
- case "minor":
61
- return { major: semver.major, minor: semver.minor + 1, patch: 0 };
62
- case "major":
63
- return { major: semver.major + 1, minor: 0, patch: 0 };
64
- default:
65
- return semver;
66
- }
67
- }
68
-
69
- /**
70
- * Computes new version for rollback (down), with floor at 0.
71
- */
72
- function bumpDown(semver: Semver, type: BumpType): Semver {
73
- switch (type) {
74
- case "patch":
75
- return {
76
- ...semver,
77
- patch: Math.max(0, semver.patch - 1),
78
- };
79
- case "minor":
80
- return {
81
- major: semver.major,
82
- minor: Math.max(0, semver.minor - 1),
83
- patch: 0,
84
- };
85
- case "major":
86
- return {
87
- major: Math.max(0, semver.major - 1),
88
- minor: semver.minor,
89
- patch: semver.patch,
90
- };
91
- default:
92
- return semver;
93
- }
94
- }
95
-
96
- /**
97
- * Reads version from package.json in dir.
98
- * @throws InvalidVersionError if version is missing or invalid
99
- */
100
- function readPackageVersion(dir: string): { raw: string; semver: Semver } {
101
- const path = join(dir, PACKAGE_JSON);
102
- if (!existsSync(path)) {
103
- throw new InvalidVersionError(
104
- `No package.json found at ${path}. Run from project root or use -C <dir>.`,
105
- );
106
- }
107
- const raw = readFileSync(path, "utf-8");
108
- const data = JSON.parse(raw) as Record<string, unknown>;
109
- const version = data.version;
110
- if (typeof version !== "string" || version.trim() === "") {
111
- throw new InvalidVersionError(
112
- 'package.json has no valid \'version\' field. Add a "version" field (e.g. "0.0.0") to package.json.',
113
- );
114
- }
115
- const semver = parseVersion(version);
116
- return { raw: version.trim(), semver };
117
- }
118
-
119
- /**
120
- * Writes version to package.json, preserving other keys and formatting as much as possible.
121
- * Uses JSON.stringify with 2 spaces for consistency.
122
- */
123
- function writePackageVersion(dir: string, newVersion: string): void {
124
- const path = join(dir, PACKAGE_JSON);
125
- const raw = readFileSync(path, "utf-8");
126
- const data = JSON.parse(raw) as Record<string, unknown>;
127
- data.version = newVersion;
128
- writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
129
- }
130
-
131
- /**
132
- * Updates version in jsr.json if the file exists. Only the version value is changed;
133
- * the rest of the file (format, commas, spacing) is left unchanged.
134
- */
135
- function syncJsrVersion(dir: string, newVersion: string): boolean {
136
- const path = join(dir, JSR_JSON);
137
- if (!existsSync(path)) return false;
138
- const raw = readFileSync(path, "utf-8");
139
- const updated = raw.replace(/"version":\s*"[^"]*"/, `"version": "${newVersion}"`);
140
- if (updated === raw) return false;
141
- writeFileSync(path, updated, "utf-8");
142
- return true;
143
- }
11
+ import { applyVersionToPackages, computeBump } from "../version-bump.js";
144
12
 
145
13
  /**
146
14
  * Run the bump command.
@@ -182,34 +50,7 @@ export async function run(args: ParsedArgs): Promise<void> {
182
50
  });
183
51
  }
184
52
 
185
- let roots = findPackageRoots(cwd);
186
- roots = [...roots].sort((a, b) => {
187
- if (a === cwd && b !== cwd) return -1;
188
- if (a !== cwd && b === cwd) return 1;
189
- return a.localeCompare(b);
190
- });
191
- if (roots.length === 0) {
192
- throw new InvalidVersionError(
193
- `No package.json found under ${cwd}. Run from project root or use -C <dir>.`,
194
- );
195
- }
196
- const primaryRoot = roots[0];
197
- if (primaryRoot === undefined) {
198
- throw new InvalidVersionError(
199
- `No package.json found under ${cwd}. Run from project root or use -C <dir>.`,
200
- );
201
- }
202
- const { raw: oldVersion, semver } = readPackageVersion(primaryRoot);
203
- const newSemver = direction === "up" ? bumpUp(semver, type) : bumpDown(semver, type);
204
- const newVersion = formatVersion(newSemver);
205
-
206
- const filesToUpdate: string[] = [];
207
- for (const dir of roots) {
208
- filesToUpdate.push(relative(cwd, join(dir, PACKAGE_JSON)) || PACKAGE_JSON);
209
- if (existsSync(join(dir, JSR_JSON))) {
210
- filesToUpdate.push(relative(cwd, join(dir, JSR_JSON)) || JSR_JSON);
211
- }
212
- }
53
+ const { oldVersion, newVersion, roots, filesToUpdate } = computeBump(cwd, direction, type);
213
54
 
214
55
  if (dryRun) {
215
56
  if (!quiet) {
@@ -219,18 +60,13 @@ export async function run(args: ParsedArgs): Promise<void> {
219
60
  process.exit(EXIT_OK);
220
61
  }
221
62
 
222
- const updated: string[] = [];
223
- for (const dir of roots) {
224
- writePackageVersion(dir, newVersion);
225
- updated.push(relative(cwd, join(dir, PACKAGE_JSON)) || PACKAGE_JSON);
226
- if (syncJsrVersion(dir, newVersion)) {
227
- updated.push(relative(cwd, join(dir, JSR_JSON)) || JSR_JSON);
228
- }
229
- }
63
+ const updated = applyVersionToPackages(cwd, roots, newVersion);
230
64
 
231
65
  if (!quiet) {
232
66
  success(`Bumped version: ${oldVersion} → ${newVersion}`);
233
67
  success(`Updated: ${updated.join(", ")}`);
234
- hint("Commit the change, then run gflows start release vX.Y.Z to release.");
68
+ hint(
69
+ "Commit the change, then run gflows start release vX.Y.Z — or gflows release up <type> from dev.",
70
+ );
235
71
  }
236
72
  }
@@ -12,6 +12,7 @@ const COMMANDS = [
12
12
  "init",
13
13
  "start",
14
14
  "finish",
15
+ "release",
15
16
  "sync",
16
17
  "pr",
17
18
  "viz",
@@ -132,6 +133,15 @@ _gflows() {
132
133
  COMPREPLY=()
133
134
  fi
134
135
  ;;
136
+ release)
137
+ if (( cword == cmd_idx + 1 )); then
138
+ COMPREPLY=($(compgen -W "up current" -- "$cur"))
139
+ elif (( cword == cmd_idx + 2 )) && [[ "${D}words[cmd_idx + 1]}" == "up" ]]; then
140
+ COMPREPLY=($(compgen -W "${BUMP_TYPES.join(" ")}" -- "$cur"))
141
+ else
142
+ COMPREPLY=()
143
+ fi
144
+ ;;
135
145
  *)
136
146
  COMPREPLY=()
137
147
  ;;
@@ -212,6 +222,13 @@ _gflows() {
212
222
  _values "direction" ${BUMP_DIRECTIONS.map((d) => `"${d}"`).join(" ")}
213
223
  fi
214
224
  ;;
225
+ release)
226
+ if [[ "${D}words[CURRENT-1]}" == "up" ]]; then
227
+ _values "bump-type" ${BUMP_TYPES.map((t) => `"${t}"`).join(" ")}
228
+ else
229
+ _values "mode" "up" "current"
230
+ fi
231
+ ;;
215
232
  esac
216
233
  ;;
217
234
  esac
@@ -253,6 +270,8 @@ complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}"
253
270
  -a "start" -d "Create workflow branch"
254
271
  complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}" \\
255
272
  -a "finish" -d "Merge and close branch"
273
+ complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}" \\
274
+ -a "release" -d "Quick release from dev"
256
275
  complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}" \\
257
276
  -a "switch" -d "Switch branch"
258
277
  complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}" \\
@@ -315,6 +334,14 @@ complete -c gflows -f -n "__fish_seen_subcommand_from bump; and not __fish_seen_
315
334
  complete -c gflows -f -n "__fish_seen_subcommand_from bump; and __fish_seen_subcommand_from up down" \\
316
335
  -a "patch minor major"
317
336
 
337
+ # release up [patch|minor|major] | current
338
+ complete -c gflows -f -n "__fish_seen_subcommand_from release; and not __fish_seen_subcommand_from up current" \\
339
+ -a "up" -d "Bump then release"
340
+ complete -c gflows -f -n "__fish_seen_subcommand_from release; and not __fish_seen_subcommand_from up current" \\
341
+ -a "current" -d "Keep current version (no bump)"
342
+ complete -c gflows -f -n "__fish_seen_subcommand_from release; and __fish_seen_subcommand_from up" \\
343
+ -a "patch minor major"
344
+
318
345
  # Common flags
319
346
  complete -c gflows -x -n "__fish_seen_subcommand_from ${COMMANDS.join(" ")}" -l path -s C -d "Run as if in dir" -a "(__fish_complete_directories)"
320
347
  complete -c gflows -f -n "__fish_seen_subcommand_from ${COMMANDS.join(" ")}" -l help -s h -d "Show help"
@@ -37,6 +37,7 @@ import {
37
37
  tagExists,
38
38
  } from "../git.js";
39
39
  import { hint, success } from "../out.js";
40
+ import { PACKAGE_JSON } from "../packages.js";
40
41
  import {
41
42
  completeRun,
42
43
  type GflowsRunState,
@@ -44,9 +45,11 @@ import {
44
45
  suspendRun,
45
46
  writeActiveRun,
46
47
  } from "../run-state.js";
47
- import type { BranchType, MergeTarget, ParsedArgs, ResolvedConfig } from "../types.js";
48
+ import type { BranchType, MergeTarget, ParsedArgs } from "../types.js";
49
+ import { applyVersionToPackages, JSR_JSON, sortedPackageRoots } from "../version-bump.js";
48
50
 
49
- interface FinishContext {
51
+ /** Context persisted for finish / quick-release multi-step runs. */
52
+ export interface FinishContext {
50
53
  branchToFinish: string;
51
54
  type: BranchType;
52
55
  version?: string;
@@ -65,6 +68,19 @@ interface FinishContext {
65
68
  bumpOnFinish: boolean;
66
69
  }
67
70
 
71
+ /** Options for {@link runFinishPlan}. */
72
+ export interface FinishPlanOptions {
73
+ dryRun: boolean;
74
+ verbose: boolean;
75
+ quiet: boolean;
76
+ /** Plan header label (default: "gflows finish plan"). */
77
+ planLabel?: string;
78
+ /** When true, print plan and return without executing. */
79
+ preview?: boolean;
80
+ /** When false, skip printing the plan (caller already did). Default true. */
81
+ printPlan?: boolean;
82
+ }
83
+
68
84
  /**
69
85
  * Runs remaining finish steps from run state (also used by continue).
70
86
  */
@@ -375,48 +391,10 @@ export async function run(args: ParsedArgs): Promise<void> {
375
391
  });
376
392
  }
377
393
 
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;
393
- }
394
-
395
394
  if (args.bumpOnFinish && (type === "release" || type === "hotfix") && version) {
396
- await bumpAndCommit(repoRoot, version, args, config);
395
+ await bumpAndCommit(repoRoot, version, args);
397
396
  }
398
397
 
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
- ];
414
-
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
398
  const ctx: FinishContext = {
421
399
  branchToFinish,
422
400
  type,
@@ -436,56 +414,117 @@ export async function run(args: ParsedArgs): Promise<void> {
436
414
  bumpOnFinish: args.bumpOnFinish,
437
415
  };
438
416
 
417
+ await runFinishPlan(repoRoot, ctx, {
418
+ ...opts,
419
+ preview: args.preview,
420
+ planLabel: "gflows finish plan",
421
+ });
422
+ }
423
+
424
+ /**
425
+ * Prints a finish-style plan and executes it via the shared step runner.
426
+ * Used by `finish` and quick `release` from dev.
427
+ */
428
+ export async function runFinishPlan(
429
+ repoRoot: string,
430
+ ctx: FinishContext,
431
+ opts: FinishPlanOptions,
432
+ ): Promise<void> {
433
+ const tagName = ctx.version && !ctx.noTag ? normalizeTagVersion(ctx.version) : undefined;
434
+ const targetsDisplay = formatMergeTarget(ctx.mergeTarget, {
435
+ main: ctx.main,
436
+ dev: ctx.dev,
437
+ remote: ctx.remote,
438
+ prefixes: {
439
+ feature: "feature/",
440
+ bugfix: "bugfix/",
441
+ chore: "chore/",
442
+ release: "release/",
443
+ hotfix: "hotfix/",
444
+ spike: "spike/",
445
+ },
446
+ });
447
+
448
+ if (opts.printPlan !== false) {
449
+ console.error(`${opts.planLabel ?? "gflows finish plan"}:`);
450
+ console.error(` branch: ${ctx.branchToFinish}`);
451
+ console.error(` merge: → ${targetsDisplay}`);
452
+ if (tagName) console.error(` tag: ${tagName}`);
453
+ console.error(` delete: ${ctx.shouldDelete ? "yes" : "no"}`);
454
+ console.error(` push: ${ctx.doPush ? "yes" : "no"}`);
455
+ if (ctx.squash) console.error(" squash: yes");
456
+ }
457
+
458
+ if (opts.preview) {
459
+ success("gflows: preview only (no changes).");
460
+ return;
461
+ }
462
+
463
+ const steps =
464
+ ctx.mergeTarget === "dev"
465
+ ? [
466
+ { id: "merge-dev", label: `Merge into ${ctx.dev}` },
467
+ ...(ctx.shouldDelete ? [{ id: "delete", label: "Delete branch" }] : []),
468
+ ...(ctx.doPush ? [{ id: "push", label: "Push" }] : []),
469
+ ]
470
+ : [
471
+ { id: "merge-main", label: `Merge into ${ctx.main}` },
472
+ ...(tagName ? [{ id: "tag", label: `Tag ${tagName}` }] : []),
473
+ { id: "merge-main-into-dev", label: `Merge ${ctx.main} into ${ctx.dev}` },
474
+ ...(tagName ? [{ id: "changelog", label: "Update CHANGELOG" }] : []),
475
+ ...(ctx.shouldDelete ? [{ id: "delete", label: "Delete branch" }] : []),
476
+ ...(ctx.doPush ? [{ id: "push", label: "Push" }] : []),
477
+ ];
478
+
479
+ const gitOpts = { dryRun: opts.dryRun, verbose: opts.verbose, quiet: opts.quiet };
480
+ const branchSha = await resolveSha(repoRoot, ctx.branchToFinish, gitOpts);
481
+ const mainSha = await resolveSha(repoRoot, ctx.main, gitOpts);
482
+ const devSha = await resolveSha(repoRoot, ctx.dev, gitOpts);
483
+ const prevBranch = await getCurrentBranch(repoRoot, gitOpts);
484
+
439
485
  const state = startRun(repoRoot, "finish", steps, ctx as unknown as Record<string, unknown>, {
440
486
  branchSha,
441
487
  mainSha,
442
488
  devSha,
443
489
  prevBranch,
444
490
  createdTag: tagName ?? null,
445
- branchName: branchToFinish,
446
- shouldDelete,
447
- main: config.main,
448
- dev: config.dev,
491
+ branchName: ctx.branchToFinish,
492
+ shouldDelete: ctx.shouldDelete,
493
+ main: ctx.main,
494
+ dev: ctx.dev,
449
495
  });
450
496
 
451
- await executeFinishSteps(repoRoot, state, opts);
497
+ await executeFinishSteps(repoRoot, state, gitOpts);
452
498
  }
453
499
 
454
- async function bumpAndCommit(
455
- repoRoot: string,
456
- version: string,
457
- args: ParsedArgs,
458
- _config: ResolvedConfig,
459
- ): Promise<void> {
500
+ /**
501
+ * Bumps package versions (format-preserving) and commits on the current branch.
502
+ */
503
+ async function bumpAndCommit(repoRoot: string, version: string, args: ParsedArgs): Promise<void> {
504
+ if (args.dryRun) return;
460
505
  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;
506
+ const roots = sortedPackageRoots(repoRoot);
507
+ if (roots.length === 0) return;
464
508
  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");
469
- }
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");
475
- }
476
- if (!args.dryRun) {
477
- await runGit(["add", "package.json", "jsr.json"], {
509
+ applyVersionToPackages(repoRoot, roots, ver);
510
+ for (const dir of roots) {
511
+ await runGit(["add", "--", join(dir, PACKAGE_JSON)], {
478
512
  cwd: repoRoot,
479
- dryRun: args.dryRun,
513
+ dryRun: false,
480
514
  verbose: args.verbose,
481
- });
482
- await runGit(["commit", "-m", `chore: bump to ${ver}`], {
515
+ }).catch(() => undefined);
516
+ await runGit(["add", "--", join(dir, JSR_JSON)], {
483
517
  cwd: repoRoot,
484
- dryRun: args.dryRun,
518
+ dryRun: false,
485
519
  verbose: args.verbose,
486
- });
487
- if (!args.quiet) success(`gflows: bumped version to ${ver} and committed.`);
520
+ }).catch(() => undefined);
488
521
  }
522
+ await runGit(["commit", "-m", `chore: bump to ${ver}`], {
523
+ cwd: repoRoot,
524
+ dryRun: false,
525
+ verbose: args.verbose,
526
+ });
527
+ if (!args.quiet) success(`gflows: bumped version to ${ver} and committed.`);
489
528
  } catch {
490
529
  hint("finish --bump: could not bump/commit version files; continuing finish.");
491
530
  }
@@ -19,6 +19,7 @@ Commands:
19
19
  init, -I Ensure main, create dev (TTY: wizard; optional package.json script alias)
20
20
  start, -S Create workflow branch (TTY: prompts if args missing)
21
21
  finish, -F Merge and close branch (plan + delete by default)
22
+ release Quick release from dev (bump or keep version + merge main + tag)
22
23
  sync Update current branch from its base
23
24
  pr Open PR/MR via gh or glab (correct base)
24
25
  switch, -W Switch branch (picker or name)
@@ -42,7 +43,7 @@ Commands:
42
43
  Types: feature (-f), bugfix (-b), chore (-c), release (-r), hotfix (-x), spike (-e)
43
44
 
44
45
  Common flags:
45
- -p, --push Push after init/start/finish/pr
46
+ -p, --push Push after init/start/finish/release/pr
46
47
  -P, --no-push Do not push (init defaults to push; start does not)
47
48
  --main <name> Main branch (init: persist to .gflows.json)
48
49
  --dev <name> Dev branch (init: persist to .gflows.json)
@@ -61,6 +62,7 @@ Init: --script-alias <name> Add package.json script (e.g. g → "gflows")
61
62
  Start: --force Allow dirty working tree
62
63
  Finish: --no-ff Always create merge commit; -D/--delete, -N/--no-delete;
63
64
  --squash, --preview, --bump; -s/--sign, -T/--no-tag, -M/--tag-message, -m/--message
65
+ Release: up <patch|minor|major> | current (from dev only); -y -p|-P; --preview; -s/--sign
64
66
  Sync: --rebase, --force
65
67
  List: -r, --include-remote Include remote-tracking branches
66
68
  Switch: --move | --restore | --clean | --destroy | --cancel