gflows 0.1.14 → 0.1.16

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/README.md CHANGED
@@ -265,7 +265,7 @@ Release and hotfix names must be a version (`vX.Y.Z` or `X.Y.Z`). Branch names u
265
265
  | `init` | `-I` | Ensure main exists; create dev from main. |
266
266
  | `start` | `-S` | Create a workflow branch (requires type + name). |
267
267
  | `finish` | `-F` | Merge branch into target(s), optional tag (release/hotfix), delete, push. |
268
- | `switch` | `-W` | Switch to a workflow branch (picker or name). |
268
+ | `switch` | `-W` | Switch to a workflow branch (picker or name); with uncommitted changes: prompt or `--move` / `--restore` / `--clean` / `--cancel`. |
269
269
  | `delete` | `-L` | Delete local workflow branch(es). Never main/dev. |
270
270
  | `list` | `-l` | List workflow branches; optional type filter and remote. |
271
271
  | `bump` | — | Bump or rollback package version (patch/minor/major). |
@@ -395,24 +395,42 @@ bun gflows finish hotfix -s -T -y
395
395
 
396
396
  ### switch
397
397
 
398
- Switch to a workflow branch. With TTY and no name, shows a picker; otherwise pass branch name.
398
+ Switch to a workflow branch. With TTY and no branch name, shows a picker; otherwise pass the branch name (e.g. `gflows switch dev` or `-B dev`).
399
+
400
+ **Uncommitted changes:** If the working tree is dirty and stdin is a TTY, you are prompted to choose:
401
+
402
+ | Option | Description |
403
+ | ------ | ----------- |
404
+ | **Move** | Move current changes to the target branch. |
405
+ | **Restore** | Save changes for this branch; restore target's saved state (if any). |
406
+ | **Clean** | Discard changes and switch clean at HEAD. |
407
+ | **Cancel** | Abort switching. |
408
+
409
+ You can skip the prompt by passing exactly one of the flags below. If the target branch has saved changes and you use **Clean**, a warning is shown (unless `-q`).
399
410
 
400
411
  **Examples:**
401
412
 
402
413
  ```bash
403
414
  bun gflows switch
404
415
  bun gflows switch feature/auth-refactor
416
+ bun gflows switch dev --restore
417
+ bun gflows switch main --clean
405
418
  bun gflows -W feature/auth-refactor
406
419
  ```
407
420
 
408
421
  **Flags:**
409
422
 
410
423
 
411
- | Flag | Short | Description |
412
- | -------------- | ----- | --------------------- |
413
- | `--path <dir>` | `-C` | Run as if in `<dir>`. |
414
- | `--verbose` | `-v` | Verbose output. |
415
- | `--quiet` | `-q` | Minimal output. |
424
+ | Flag | Short | Description |
425
+ | -------------- | ----- | --------------------------------------------------------------------------- |
426
+ | `--path <dir>` | `-C` | Run as if in `<dir>`. |
427
+ | `--branch <name>` | `-B` | Branch to switch to (alternative to positional). |
428
+ | `--move` | | Move current changes to the target branch; no prompt. |
429
+ | `--restore` | — | Save for this branch; restore target's saved state (if any); no prompt. |
430
+ | `--clean` | — | Discard changes and switch clean at HEAD; no prompt. |
431
+ | `--cancel` | — | Abort switching; no prompt. |
432
+ | `--verbose` | `-v` | Verbose output. |
433
+ | `--quiet` | `-q` | Minimal output (suppresses Clean warning about saved changes on target). |
416
434
 
417
435
 
418
436
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gflows",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "A lightweight CLI for consistent Git branching workflows (main + dev, feature/bugfix/chore/release/hotfix).",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -95,6 +95,11 @@ function buildParseArgsOptions() {
95
95
  // list (-r is context-dependent: list → include-remote; start/finish → release)
96
96
  includeRemote: { type: "boolean" as const },
97
97
  "include-remote": { type: "boolean" as const },
98
+ // switch: explicit mode (--restore, --clean, --cancel, --move)
99
+ restore: { type: "boolean" as const },
100
+ clean: { type: "boolean" as const },
101
+ cancel: { type: "boolean" as const },
102
+ move: { type: "boolean" as const },
98
103
  },
99
104
  };
100
105
  }
@@ -312,6 +317,25 @@ export function parse(argv: string[] = Bun.argv.slice(2)): ParsedArgs {
312
317
  else if (command === "completion" && name === "zsh") completionShell = "zsh";
313
318
  else if (command === "completion" && name === "fish") completionShell = "fish";
314
319
 
320
+ let switchMode: "restore" | "clean" | "cancel" | "move" | undefined;
321
+ if (command === "switch") {
322
+ const restore = v.restore === true;
323
+ const clean = v.clean === true;
324
+ const cancel = v.cancel === true;
325
+ const move = v.move === true;
326
+ const count = [restore, clean, cancel, move].filter(Boolean).length;
327
+ if (count > 1) {
328
+ console.error(
329
+ "gflows switch: only one of --restore, --clean, --cancel, or --move may be used at a time.",
330
+ );
331
+ process.exit(EXIT_USER);
332
+ }
333
+ if (restore) switchMode = "restore";
334
+ else if (clean) switchMode = "clean";
335
+ else if (cancel) switchMode = "cancel";
336
+ else if (move) switchMode = "move";
337
+ }
338
+
315
339
  return {
316
340
  command,
317
341
  cwd,
@@ -342,6 +366,7 @@ export function parse(argv: string[] = Bun.argv.slice(2)): ParsedArgs {
342
366
  tagMessage: typeof v.tagMessage === "string" ? v.tagMessage : undefined,
343
367
  message: typeof v.message === "string" ? v.message : undefined,
344
368
  includeRemote,
369
+ switchMode,
345
370
  };
346
371
  }
347
372
 
@@ -48,6 +48,10 @@ Start: --force Allow dirty working tree
48
48
  Finish: --no-ff Always create merge commit; -D/--delete, -N/--no-delete;
49
49
  -s/--sign, -T/--no-tag, -M/--tag-message, -m/--message
50
50
  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
51
55
 
52
56
  Exit codes: 0 success, 1 usage/validation, 2 Git or system error.
53
57
 
@@ -4,12 +4,30 @@
4
4
  */
5
5
 
6
6
  import { resolveConfig } from "../config.js";
7
- import { EXIT_OK, EXIT_USER } from "../constants.js";
7
+ import { EXIT_GIT, EXIT_OK, EXIT_USER } from "../constants.js";
8
8
  import { NotRepoError } from "../errors.js";
9
- import { branchList, checkout, resolveRepoRoot } from "../git.js";
9
+ import {
10
+ branchList,
11
+ checkout,
12
+ cleanUntracked,
13
+ findStashRefByBranch,
14
+ findStashRefByMessage,
15
+ getCurrentBranch,
16
+ isClean,
17
+ resolveRepoRoot,
18
+ restoreTracked,
19
+ STASH_SWITCH_MOVE_SUBSTRING,
20
+ stashDropRef,
21
+ stashPopRef,
22
+ stashPush,
23
+ stashPushMove,
24
+ } from "../git.js";
10
25
  import { hint, success } from "../out.js";
11
26
  import type { BranchType, ParsedArgs } from "../types.js";
12
27
 
28
+ /** User choice when switching with uncommitted changes. */
29
+ type SwitchWhenUncommitted = "cancel" | "restore" | "clean" | "move";
30
+
13
31
  const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
14
32
 
15
33
  /**
@@ -43,58 +61,147 @@ export async function run(args: ParsedArgs): Promise<void> {
43
61
  });
44
62
 
45
63
  const branchName = (branch?.trim() || name?.trim() || "").trim() || undefined;
64
+ const isTTY = typeof process.stdin.isTTY === "boolean" && process.stdin.isTTY;
65
+
66
+ let targetBranch: string;
46
67
 
47
68
  if (branchName) {
48
- await checkout(root, branchName, {
49
- dryRun,
50
- verbose: args.verbose,
69
+ targetBranch = branchName;
70
+ } else {
71
+ if (!isTTY) {
72
+ console.error(
73
+ "gflows switch: no branch name given and stdin is not a TTY. Pass a branch name (e.g. gflows switch feature/my-branch) or run from an interactive terminal.",
74
+ );
75
+ process.exit(EXIT_USER);
76
+ }
77
+
78
+ const allLocal = await branchList(root, { dryRun, verbose: args.verbose });
79
+ const workflowBranches = getWorkflowBranches(allLocal, config.prefixes);
80
+ const mainAndDev = [config.main, config.dev].filter((b) => allLocal.includes(b));
81
+ const choices = [...mainAndDev, ...workflowBranches];
82
+
83
+ if (choices.length === 0) {
84
+ if (!quiet) {
85
+ console.error("No branches found.");
86
+ }
87
+ process.exit(EXIT_OK);
88
+ }
89
+
90
+ const { select } = await import("@inquirer/prompts");
91
+ const chosen = await select({
92
+ message: "Switch to branch",
93
+ choices: choices.map((b) => ({ name: b, value: b })),
51
94
  });
52
- if (!quiet && !dryRun) {
53
- success(`Switched to branch '${branchName}'.`);
54
- // Hint: suggest listing branches
55
- hint("Use gflows list to see all workflow branches.");
95
+
96
+ if (typeof chosen !== "string") {
97
+ process.exit(EXIT_USER);
56
98
  }
57
- return;
99
+ targetBranch = chosen;
58
100
  }
59
101
 
60
- const isTTY = typeof process.stdin.isTTY === "boolean" && process.stdin.isTTY;
61
- if (!isTTY) {
62
- console.error(
63
- "gflows switch: no branch name given and stdin is not a TTY. Pass a branch name (e.g. gflows switch feature/my-branch) or run from an interactive terminal.",
64
- );
102
+ const gitOpts = { dryRun, verbose: args.verbose };
103
+ const treeClean = await isClean(root, gitOpts);
104
+ /** Only set when tree is dirty (or a mode flag was passed). When tree is clean and no flag, we just checkout. */
105
+ let whenUncommitted: SwitchWhenUncommitted | undefined;
106
+
107
+ if (args.switchMode !== undefined) {
108
+ whenUncommitted = args.switchMode;
109
+ } else if (!treeClean && isTTY) {
110
+ const { select: selectPrompt } = await import("@inquirer/prompts");
111
+ whenUncommitted = await selectPrompt({
112
+ message: "Working tree has uncommitted changes. What do you want to do?",
113
+ choices: [
114
+ { name: "Move — Move current changes to the target branch", value: "move" as const },
115
+ {
116
+ name: "Restore — Save changes for this branch; restore target's saved state (if any)",
117
+ value: "restore" as const,
118
+ },
119
+ { name: "Clean — Discard changes and switch clean at HEAD", value: "clean" as const },
120
+ { name: "Cancel — Abort switching", value: "cancel" as const },
121
+ ],
122
+ });
123
+ } else if (!treeClean) {
124
+ whenUncommitted = "cancel";
125
+ }
126
+ // When tree is clean and no flag: whenUncommitted stays undefined; we just checkout below.
127
+
128
+ if (whenUncommitted === "cancel") {
129
+ if (!quiet) {
130
+ console.error("Switch cancelled.");
131
+ }
65
132
  process.exit(EXIT_USER);
66
133
  }
67
134
 
68
- const allLocal = await branchList(root, { dryRun, verbose: args.verbose });
69
- const workflowBranches = getWorkflowBranches(allLocal, config.prefixes);
70
- // Include main and dev so we always show whatever branches exist
71
- const mainAndDev = [config.main, config.dev].filter((b) => allLocal.includes(b));
72
- const choices = [...mainAndDev, ...workflowBranches];
135
+ if (!treeClean && whenUncommitted === "move") {
136
+ await stashPushMove(root, gitOpts);
137
+ }
73
138
 
74
- if (choices.length === 0) {
75
- if (!quiet) {
76
- console.error("No branches found.");
139
+ if (!treeClean && whenUncommitted === "restore") {
140
+ const currentBranch = await getCurrentBranch(root, gitOpts);
141
+ if (currentBranch) {
142
+ const existingStashRef = await findStashRefByBranch(root, currentBranch, gitOpts);
143
+ if (existingStashRef) {
144
+ await stashDropRef(root, existingStashRef, gitOpts);
145
+ }
146
+ await stashPush(root, currentBranch, gitOpts);
77
147
  }
78
- process.exit(EXIT_OK);
79
148
  }
80
149
 
81
- const { select } = await import("@inquirer/prompts");
82
- const chosen = await select({
83
- message: "Switch to branch",
84
- choices: choices.map((b) => ({ name: b, value: b })),
85
- });
150
+ if (whenUncommitted === "clean") {
151
+ const targetHasSavedState = await findStashRefByBranch(root, targetBranch, gitOpts);
152
+ if (targetHasSavedState && !quiet) {
153
+ console.error(
154
+ 'Warning: This branch has saved changes. Clean will not restore them and will open the branch at its last commit. Use "Restore" if you want to reapply the saved changes.',
155
+ );
156
+ }
157
+ if (!treeClean) {
158
+ await restoreTracked(root, gitOpts);
159
+ await cleanUntracked(root, gitOpts);
160
+ }
161
+ }
86
162
 
87
- if (typeof chosen !== "string") {
88
- process.exit(EXIT_USER);
163
+ await checkout(root, targetBranch, gitOpts);
164
+
165
+ if (whenUncommitted === "move") {
166
+ const moveStashRef = await findStashRefByMessage(root, STASH_SWITCH_MOVE_SUBSTRING, gitOpts);
167
+ if (moveStashRef) {
168
+ try {
169
+ await stashPopRef(root, moveStashRef, gitOpts);
170
+ } catch (err) {
171
+ const msg = err instanceof Error ? err.message : String(err);
172
+ console.error(
173
+ "gflows switch: conflicts occurred while applying your changes on the target branch.",
174
+ );
175
+ console.error(
176
+ "The stash was not dropped. Resolve conflicts, then commit or run `git stash drop` as needed.",
177
+ );
178
+ if (args.verbose && msg) console.error(msg);
179
+ process.exit(EXIT_GIT);
180
+ }
181
+ }
182
+ }
183
+
184
+ if (whenUncommitted === "restore") {
185
+ const targetStashRef = await findStashRefByBranch(root, targetBranch, gitOpts);
186
+ if (targetStashRef) {
187
+ try {
188
+ await stashPopRef(root, targetStashRef, gitOpts);
189
+ } catch (err) {
190
+ const msg = err instanceof Error ? err.message : String(err);
191
+ console.error(
192
+ `gflows switch: conflicts occurred while restoring saved changes for '${targetBranch}'.`,
193
+ );
194
+ console.error(
195
+ "The stash was not dropped. Resolve conflicts, then commit or run `git stash drop` as needed.",
196
+ );
197
+ if (args.verbose && msg) console.error(msg);
198
+ process.exit(EXIT_GIT);
199
+ }
200
+ }
89
201
  }
90
202
 
91
- await checkout(root, chosen, {
92
- dryRun,
93
- verbose: args.verbose,
94
- });
95
203
  if (!quiet && !dryRun) {
96
- success(`Switched to branch '${chosen}'.`);
97
- // Hint: suggest listing branches
204
+ success(`Switched to branch '${targetBranch}'.`);
98
205
  hint("Use gflows list to see all workflow branches.");
99
206
  }
100
207
  }
package/src/git.ts CHANGED
@@ -314,6 +314,173 @@ export async function isClean(cwd: string, options: GitRunOptions = {}): Promise
314
314
  return result.stdout.trim() === "";
315
315
  }
316
316
 
317
+ /** Stash message prefix for per-branch switch restore. Full message: gflows-switch:<branchName>. */
318
+ export const STASH_SWITCH_PREFIX = "gflows-switch:";
319
+
320
+ /**
321
+ * Stashes uncommitted changes (including untracked) for the given branch (git stash push -u -m "gflows-switch:<branch>").
322
+ * Used for per-branch restore so the stash can be found and applied when switching back.
323
+ * Callers that want at most one stash per branch should drop an existing stash for that branch
324
+ * (findStashRefByBranch + stashDropRef) before calling this.
325
+ *
326
+ * @param cwd - Repo root.
327
+ * @param branchName - Branch name to tag the stash with.
328
+ * @param options - dryRun, verbose.
329
+ * @throws Error if stash fails.
330
+ */
331
+ export async function stashPush(
332
+ cwd: string,
333
+ branchName: string,
334
+ options: GitRunOptions = {},
335
+ ): Promise<void> {
336
+ const message = `${STASH_SWITCH_PREFIX}${branchName}`;
337
+ const result = await runGit(["stash", "push", "-u", "-m", message], { cwd, ...options });
338
+ if (result.exitCode !== 0) {
339
+ throw new Error(result.stderr.trim() || "git stash push failed.");
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Returns the stash ref (e.g. stash@{0}) for the most recent stash tagged with gflows-switch:<branchName>, or null.
345
+ * Parses `git stash list` and matches the message; stash list is newest-first.
346
+ *
347
+ * @param cwd - Repo root.
348
+ * @param branchName - Branch name (stash message is gflows-switch:<branchName>).
349
+ * @param options - dryRun, verbose.
350
+ */
351
+ export async function findStashRefByBranch(
352
+ cwd: string,
353
+ branchName: string,
354
+ options: GitRunOptions = {},
355
+ ): Promise<string | null> {
356
+ const result = await runGit(["stash", "list"], { cwd, ...options });
357
+ if (result.exitCode !== 0 || !result.stdout.trim()) return null;
358
+ const tag = `${STASH_SWITCH_PREFIX}${branchName}`;
359
+ const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
360
+ const re = new RegExp(`${escaped}(?:[\\s:]|$)`);
361
+ for (const line of result.stdout.trim().split("\n")) {
362
+ const match = line.match(/^(stash@\{\d+\})/);
363
+ const ref = match?.[1];
364
+ if (ref && re.test(line)) return ref;
365
+ }
366
+ return null;
367
+ }
368
+
369
+ /** Stash message used for "move changes to target" (one-off stash, popped after checkout). */
370
+ const STASH_SWITCH_MOVE_MESSAGE = "gflows-switch-move";
371
+
372
+ /**
373
+ * Stashes uncommitted changes (including untracked) for "move to target" flow (git stash push -u -m "<move message>").
374
+ * The stash is popped on the target branch; use findStashRefByMessage + stashPopRef after checkout.
375
+ *
376
+ * @param cwd - Repo root.
377
+ * @param options - dryRun, verbose.
378
+ * @throws Error if stash fails.
379
+ */
380
+ export async function stashPushMove(cwd: string, options: GitRunOptions = {}): Promise<void> {
381
+ const result = await runGit(["stash", "push", "-u", "-m", STASH_SWITCH_MOVE_MESSAGE], {
382
+ cwd,
383
+ ...options,
384
+ });
385
+ if (result.exitCode !== 0) {
386
+ throw new Error(result.stderr.trim() || "git stash push failed.");
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Returns the stash ref (e.g. stash@{0}) for the most recent stash whose message contains the given substring, or null.
392
+ * Used to find the "move" stash after checkout.
393
+ *
394
+ * @param cwd - Repo root.
395
+ * @param messageSubstring - Substring to search for in stash list lines.
396
+ * @param options - dryRun, verbose.
397
+ */
398
+ export async function findStashRefByMessage(
399
+ cwd: string,
400
+ messageSubstring: string,
401
+ options: GitRunOptions = {},
402
+ ): Promise<string | null> {
403
+ const result = await runGit(["stash", "list"], { cwd, ...options });
404
+ if (result.exitCode !== 0 || !result.stdout.trim()) return null;
405
+ for (const line of result.stdout.trim().split("\n")) {
406
+ if (!line.includes(messageSubstring)) continue;
407
+ const match = line.match(/^(stash@\{\d+\})/);
408
+ const ref = match?.[1];
409
+ if (ref) return ref;
410
+ }
411
+ return null;
412
+ }
413
+
414
+ /** Message substring used to find the "move" stash after checkout (internal). */
415
+ export const STASH_SWITCH_MOVE_SUBSTRING = "gflows-switch-move";
416
+
417
+ /**
418
+ * Pops a specific stash by ref (e.g. stash@{0}). Used to restore per-branch stash for target branch.
419
+ * On success the stash is removed. On conflict Git keeps the stash so the user can retry or drop it.
420
+ *
421
+ * @param cwd - Repo root.
422
+ * @param stashRef - Stash ref from findStashRefByBranch (e.g. "stash@{0}").
423
+ * @param options - dryRun, verbose.
424
+ * @throws Error if stash pop fails (e.g. merge conflicts).
425
+ */
426
+ export async function stashPopRef(
427
+ cwd: string,
428
+ stashRef: string,
429
+ options: GitRunOptions = {},
430
+ ): Promise<void> {
431
+ const result = await runGit(["stash", "pop", stashRef], { cwd, ...options });
432
+ if (result.exitCode !== 0) {
433
+ throw new Error(result.stderr.trim() || `git stash pop ${stashRef} failed.`);
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Drops a specific stash by ref (e.g. stash@{0}). Used to overwrite per-branch stash before pushing a new one.
439
+ *
440
+ * @param cwd - Repo root.
441
+ * @param stashRef - Stash ref (e.g. "stash@{0}").
442
+ * @param options - dryRun, verbose.
443
+ * @throws Error if stash drop fails.
444
+ */
445
+ export async function stashDropRef(
446
+ cwd: string,
447
+ stashRef: string,
448
+ options: GitRunOptions = {},
449
+ ): Promise<void> {
450
+ const result = await runGit(["stash", "drop", stashRef], { cwd, ...options });
451
+ if (result.exitCode !== 0) {
452
+ throw new Error(result.stderr.trim() || `git stash drop ${stashRef} failed.`);
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Discards tracked changes in the working tree (git restore .). Does not remove untracked files.
458
+ *
459
+ * @param cwd - Repo root.
460
+ * @param options - dryRun, verbose.
461
+ * @throws Error if restore fails.
462
+ */
463
+ export async function restoreTracked(cwd: string, options: GitRunOptions = {}): Promise<void> {
464
+ const result = await runGit(["restore", "."], { cwd, ...options });
465
+ if (result.exitCode !== 0) {
466
+ throw new Error(result.stderr.trim() || "git restore . failed.");
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Removes untracked files and directories (git clean -fd).
472
+ *
473
+ * @param cwd - Repo root.
474
+ * @param options - dryRun, verbose.
475
+ * @throws Error if clean fails.
476
+ */
477
+ export async function cleanUntracked(cwd: string, options: GitRunOptions = {}): Promise<void> {
478
+ const result = await runGit(["clean", "-fd"], { cwd, ...options });
479
+ if (result.exitCode !== 0) {
480
+ throw new Error(result.stderr.trim() || "git clean -fd failed.");
481
+ }
482
+ }
483
+
317
484
  /**
318
485
  * Returns the current branch name, or null if HEAD is detached.
319
486
  *
package/src/types.ts CHANGED
@@ -119,4 +119,6 @@ export interface ParsedArgs {
119
119
  message: string | undefined;
120
120
  // list
121
121
  includeRemote: boolean;
122
+ // switch: explicit mode when uncommitted (overrides prompt)
123
+ switchMode?: "restore" | "clean" | "cancel" | "move";
122
124
  }