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 +25 -7
- package/package.json +1 -1
- package/src/cli.ts +25 -0
- package/src/commands/help.ts +4 -0
- package/src/commands/switch.ts +144 -37
- package/src/git.ts +167 -0
- package/src/types.ts +2 -0
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
|
-
| `--
|
|
415
|
-
| `--
|
|
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
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
|
|
package/src/commands/help.ts
CHANGED
|
@@ -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
|
|
package/src/commands/switch.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
hint("Use gflows list to see all workflow branches.");
|
|
95
|
+
|
|
96
|
+
if (typeof chosen !== "string") {
|
|
97
|
+
process.exit(EXIT_USER);
|
|
56
98
|
}
|
|
57
|
-
|
|
99
|
+
targetBranch = chosen;
|
|
58
100
|
}
|
|
59
101
|
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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 '${
|
|
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