gflows 0.1.17 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +78 -0
- package/CHANGELOG.md +40 -0
- package/README.md +279 -502
- package/llms.txt +22 -0
- package/package.json +30 -23
- package/src/cli.ts +21 -364
- package/src/commands/abort.ts +39 -0
- package/src/commands/bump.ts +10 -10
- package/src/commands/completion.ts +14 -4
- package/src/commands/config.ts +123 -0
- package/src/commands/continue.ts +40 -0
- package/src/commands/delete.ts +4 -4
- package/src/commands/doctor.ts +143 -0
- package/src/commands/finish.ts +363 -137
- package/src/commands/help.ts +29 -20
- package/src/commands/init.ts +99 -18
- package/src/commands/list.ts +6 -1
- package/src/commands/mcp.ts +238 -0
- package/src/commands/pr.ts +120 -0
- package/src/commands/schema.ts +98 -0
- package/src/commands/start.ts +33 -31
- package/src/commands/status.ts +70 -51
- package/src/commands/switch.ts +50 -20
- package/src/commands/sync.ts +160 -0
- package/src/commands/undo.ts +78 -0
- package/src/commands/version.ts +3 -15
- package/src/commands/viz.ts +18 -0
- package/src/dispatch.ts +21 -0
- package/src/errors.ts +55 -12
- package/src/flow.ts +157 -0
- package/src/git.ts +135 -8
- package/src/index.ts +24 -1
- package/src/interactive.ts +209 -0
- package/src/out.ts +11 -4
- package/src/package-scripts.ts +73 -0
- package/src/parse.ts +430 -0
- package/src/prompts.ts +89 -0
- package/src/recommend.ts +132 -0
- package/src/run-state.ts +165 -0
- package/src/tui/HubHome.tsx +343 -0
- package/src/tui/HubShell.tsx +186 -0
- package/src/tui/flows.tsx +140 -0
- package/src/tui/hub.ts +89 -0
- package/src/tui/prompts.tsx +207 -0
- package/src/types.ts +63 -4
- package/src/ui.ts +132 -0
- package/src/version.ts +24 -0
- package/src/viz.ts +294 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Print a machine-readable catalog of gflows commands, flags, types, and exit codes.
|
|
3
|
+
* @module commands/schema
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ParsedArgs } from "../types.js";
|
|
7
|
+
import { ALL_COMMANDS, BRANCH_TYPE_SHORTS } from "../types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Emits JSON schema for agents and tooling.
|
|
11
|
+
*/
|
|
12
|
+
export async function run(_args: ParsedArgs): Promise<void> {
|
|
13
|
+
const schema = {
|
|
14
|
+
name: "gflows",
|
|
15
|
+
version: "1.0.0",
|
|
16
|
+
description: "Git branching workflow CLI (main + dev + typed short-lived branches)",
|
|
17
|
+
exitCodes: {
|
|
18
|
+
"0": "success",
|
|
19
|
+
"1": "usage/validation",
|
|
20
|
+
"2": "git/system",
|
|
21
|
+
},
|
|
22
|
+
branchTypes: Object.entries(BRANCH_TYPE_SHORTS).map(([type, short]) => ({
|
|
23
|
+
type,
|
|
24
|
+
short,
|
|
25
|
+
})),
|
|
26
|
+
commands: ALL_COMMANDS.map((command) => ({
|
|
27
|
+
command,
|
|
28
|
+
...commandMeta(command),
|
|
29
|
+
})),
|
|
30
|
+
flags: {
|
|
31
|
+
common: [
|
|
32
|
+
"-C/--path",
|
|
33
|
+
"-p/--push",
|
|
34
|
+
"-P/--no-push",
|
|
35
|
+
"-y/--yes",
|
|
36
|
+
"-d/--dry-run",
|
|
37
|
+
"-v/--verbose",
|
|
38
|
+
"-q/--quiet",
|
|
39
|
+
"--force",
|
|
40
|
+
"--json",
|
|
41
|
+
"--main",
|
|
42
|
+
"--dev",
|
|
43
|
+
"-R/--remote",
|
|
44
|
+
],
|
|
45
|
+
finish: [
|
|
46
|
+
"-B/--branch",
|
|
47
|
+
"-D/--delete",
|
|
48
|
+
"-N/--no-delete",
|
|
49
|
+
"--no-ff",
|
|
50
|
+
"--squash",
|
|
51
|
+
"--preview",
|
|
52
|
+
"--bump",
|
|
53
|
+
"-s/--sign",
|
|
54
|
+
"-T/--no-tag",
|
|
55
|
+
"-M/--tag-message",
|
|
56
|
+
"-m/--message",
|
|
57
|
+
],
|
|
58
|
+
start: ["-o/--from", "--force", "-p/--push"],
|
|
59
|
+
sync: ["--rebase", "--force"],
|
|
60
|
+
list: ["-r/--include-remote", "--json"],
|
|
61
|
+
status: ["--json"],
|
|
62
|
+
},
|
|
63
|
+
agentNotes: [
|
|
64
|
+
"Always pass explicit flags in non-TTY (never assume interactive hub).",
|
|
65
|
+
"Prefer -y to accept finish plan (delete defaults on); pass -P or -p for push.",
|
|
66
|
+
"On conflict: gflows continue | gflows abort | gflows undo.",
|
|
67
|
+
"Use gflows schema and AGENTS.md as source of truth.",
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
console.log(JSON.stringify(schema, null, 2));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function commandMeta(command: string): { summary: string; interactiveOk: boolean } {
|
|
74
|
+
const map: Record<string, { summary: string; interactiveOk: boolean }> = {
|
|
75
|
+
init: { summary: "Ensure main; create dev", interactiveOk: true },
|
|
76
|
+
start: { summary: "Create workflow branch", interactiveOk: true },
|
|
77
|
+
finish: { summary: "Merge and close workflow branch", interactiveOk: true },
|
|
78
|
+
switch: { summary: "Switch workflow branch", interactiveOk: true },
|
|
79
|
+
delete: { summary: "Delete local workflow branches", interactiveOk: true },
|
|
80
|
+
list: { summary: "List workflow branches", interactiveOk: false },
|
|
81
|
+
bump: { summary: "Bump package version", interactiveOk: true },
|
|
82
|
+
sync: { summary: "Update branch from base", interactiveOk: true },
|
|
83
|
+
pr: { summary: "Open PR/MR via gh/glab", interactiveOk: true },
|
|
84
|
+
viz: { summary: "Visual branch map and flow diagram", interactiveOk: true },
|
|
85
|
+
doctor: { summary: "Repo health checks", interactiveOk: false },
|
|
86
|
+
config: { summary: "Get/set .gflows.json", interactiveOk: true },
|
|
87
|
+
schema: { summary: "Machine-readable command catalog", interactiveOk: false },
|
|
88
|
+
continue: { summary: "Resume suspended operation", interactiveOk: false },
|
|
89
|
+
undo: { summary: "Undo last completed operation", interactiveOk: false },
|
|
90
|
+
abort: { summary: "Abort suspended operation", interactiveOk: false },
|
|
91
|
+
completion: { summary: "Shell completion script", interactiveOk: false },
|
|
92
|
+
status: { summary: "Current branch flow info", interactiveOk: false },
|
|
93
|
+
help: { summary: "Usage", interactiveOk: false },
|
|
94
|
+
version: { summary: "CLI version", interactiveOk: false },
|
|
95
|
+
mcp: { summary: "Start MCP server for agents", interactiveOk: false },
|
|
96
|
+
};
|
|
97
|
+
return map[command] ?? { summary: command, interactiveOk: false };
|
|
98
|
+
}
|
package/src/commands/start.ts
CHANGED
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import { getPrefixForType, resolveConfig } from "../config.js";
|
|
8
8
|
import { EXIT_USER, VERSION_REGEX } from "../constants.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
BranchExistsError,
|
|
11
|
+
BranchNotFoundError,
|
|
12
|
+
DirtyWorkingTreeError,
|
|
13
|
+
InvalidVersionError,
|
|
14
|
+
} from "../errors.js";
|
|
15
|
+
import { getBaseBranchName } from "../flow.js";
|
|
10
16
|
import {
|
|
11
17
|
assertNoRebaseOrMerge,
|
|
12
18
|
assertNotDetached,
|
|
@@ -19,35 +25,30 @@ import {
|
|
|
19
25
|
runGit,
|
|
20
26
|
validateBranchName,
|
|
21
27
|
} from "../git.js";
|
|
28
|
+
import { promptStartArgs } from "../interactive.js";
|
|
22
29
|
import { hint, success } from "../out.js";
|
|
23
|
-
import type {
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Returns the base branch name for the given type and fromMain flag (main vs dev).
|
|
27
|
-
*/
|
|
28
|
-
function getBaseBranch(type: BranchType, fromMain: boolean, main: string, dev: string): string {
|
|
29
|
-
if (type === "hotfix") return main;
|
|
30
|
-
if (type === "bugfix" && fromMain) return main;
|
|
31
|
-
return dev;
|
|
32
|
-
}
|
|
30
|
+
import type { ParsedArgs } from "../types.js";
|
|
33
31
|
|
|
34
32
|
/**
|
|
35
33
|
* Runs the start command: validate pre-conditions, ensure base exists, create branch, optional push.
|
|
36
|
-
* Pre-checks: repo is git, not detached HEAD, no rebase/merge in progress, working tree clean (or --force), base exists.
|
|
37
|
-
* Requires type and name (exit 1 if missing). For release/hotfix, name must match vX.Y.Z or X.Y.Z.
|
|
38
|
-
*
|
|
39
|
-
* @param args - Parsed CLI args (cwd, type, name, push, noPush, remote, dryRun, verbose, quiet, force, fromMain).
|
|
40
34
|
*/
|
|
41
35
|
export async function run(args: ParsedArgs): Promise<void> {
|
|
42
|
-
|
|
36
|
+
let type = args.type;
|
|
37
|
+
let name = args.name?.trim();
|
|
38
|
+
|
|
39
|
+
if ((!type || !name) && process.stdin.isTTY) {
|
|
40
|
+
const prompted = await promptStartArgs();
|
|
41
|
+
type = type ?? prompted.type;
|
|
42
|
+
name = name || prompted.name;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!type || name === undefined || name === "") {
|
|
43
46
|
console.error(
|
|
44
47
|
"gflows start: requires type and name (e.g. gflows start feature my-feat). Use 'gflows help' for usage.",
|
|
45
48
|
);
|
|
46
49
|
process.exit(EXIT_USER);
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
const type = args.type;
|
|
50
|
-
const name = args.name.trim();
|
|
51
52
|
const repoRoot = await resolveRepoRoot(args.cwd);
|
|
52
53
|
const config = resolveConfig(
|
|
53
54
|
repoRoot,
|
|
@@ -60,11 +61,9 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
60
61
|
verbose: args.verbose,
|
|
61
62
|
};
|
|
62
63
|
|
|
63
|
-
// Pre-checks: not detached HEAD, no rebase/merge in progress
|
|
64
64
|
await assertNotDetached(repoRoot);
|
|
65
65
|
assertNoRebaseOrMerge(repoRoot);
|
|
66
66
|
|
|
67
|
-
// Working tree clean or --force
|
|
68
67
|
if (!args.force) {
|
|
69
68
|
const clean = await isClean(repoRoot, { dryRun: false, verbose: opts.verbose });
|
|
70
69
|
if (!clean) {
|
|
@@ -72,7 +71,6 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
72
71
|
}
|
|
73
72
|
}
|
|
74
73
|
|
|
75
|
-
// Validate name: version for release/hotfix, branch name for others
|
|
76
74
|
if (type === "release" || type === "hotfix") {
|
|
77
75
|
if (!VERSION_REGEX.test(name)) {
|
|
78
76
|
throw new InvalidVersionError(
|
|
@@ -83,15 +81,18 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
83
81
|
validateBranchName(name);
|
|
84
82
|
}
|
|
85
83
|
|
|
86
|
-
const
|
|
84
|
+
const fromMain =
|
|
85
|
+
args.fromMain ||
|
|
86
|
+
(typeof args.from === "string" && (args.from === config.main || args.from === "main"));
|
|
87
|
+
|
|
88
|
+
const base =
|
|
89
|
+
typeof args.from === "string" && args.from !== "main" && args.from !== config.main
|
|
90
|
+
? args.from
|
|
91
|
+
: getBaseBranchName(type, fromMain, config);
|
|
87
92
|
|
|
88
|
-
// Ensure base exists (local or create from remote after fetch)
|
|
89
|
-
let _baseExists = false;
|
|
90
93
|
try {
|
|
91
94
|
await revParse(repoRoot, base, [], { dryRun: false, verbose: opts.verbose });
|
|
92
|
-
_baseExists = true;
|
|
93
95
|
} catch {
|
|
94
|
-
// Fetch and try remote ref
|
|
95
96
|
await fetch(repoRoot, config.remote, opts);
|
|
96
97
|
const remoteRef = `${config.remote}/${base}`;
|
|
97
98
|
try {
|
|
@@ -99,7 +100,6 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
99
100
|
if (!opts.dryRun) {
|
|
100
101
|
await runGit(["branch", base, remoteRef], { cwd: repoRoot, ...opts, dryRun: false });
|
|
101
102
|
}
|
|
102
|
-
_baseExists = true;
|
|
103
103
|
} catch {
|
|
104
104
|
throw new BranchNotFoundError(
|
|
105
105
|
`Base branch '${base}' not found locally or on ${config.remote}. Create it or push it first.`,
|
|
@@ -112,8 +112,9 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
112
112
|
|
|
113
113
|
const branches = await branchList(repoRoot, { ...opts, dryRun: false });
|
|
114
114
|
if (branches.includes(fullBranchName)) {
|
|
115
|
-
throw new
|
|
116
|
-
`Branch '${fullBranchName}' already exists
|
|
115
|
+
throw new BranchExistsError(
|
|
116
|
+
`Branch '${fullBranchName}' already exists.`,
|
|
117
|
+
`Use a different name, or: gflows switch ${fullBranchName}`,
|
|
117
118
|
);
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -138,7 +139,8 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
if (!args.quiet && !args.dryRun) {
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
hint(
|
|
143
|
+
`Commit your work, then gflows sync or gflows finish ${type}. Interactive: just run gflows`,
|
|
144
|
+
);
|
|
143
145
|
}
|
|
144
146
|
}
|
package/src/commands/status.ts
CHANGED
|
@@ -1,59 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Status command: show current branch flow info.
|
|
3
|
-
* Classifies branch (feature/bugfix/chore/release/hotfix/spike or unknown),
|
|
4
|
-
* shows base and merge target(s), and optionally ahead/behind vs base.
|
|
5
|
-
* No write operations.
|
|
2
|
+
* Status command: show current branch flow info and recovery hints.
|
|
6
3
|
* @module commands/status
|
|
7
4
|
*/
|
|
8
5
|
|
|
9
|
-
import {
|
|
6
|
+
import { resolveConfig } from "../config.js";
|
|
10
7
|
import { NotRepoError } from "../errors.js";
|
|
8
|
+
import {
|
|
9
|
+
classifyBranch,
|
|
10
|
+
formatMergeTarget,
|
|
11
|
+
getBaseBranchName,
|
|
12
|
+
resolveMergeTarget,
|
|
13
|
+
} from "../flow.js";
|
|
11
14
|
import { getAheadBehind, getCurrentBranch, resolveRepoRoot } from "../git.js";
|
|
12
15
|
import { hint } from "../out.js";
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Classifies a branch name into a workflow type, "main", "dev", or null (unknown).
|
|
19
|
-
* Uses resolved config for main/dev names and prefixes.
|
|
20
|
-
*/
|
|
21
|
-
function classifyBranch(
|
|
22
|
-
branchName: string,
|
|
23
|
-
config: ResolvedConfig,
|
|
24
|
-
): BranchType | "main" | "dev" | null {
|
|
25
|
-
if (branchName === config.main) return "main";
|
|
26
|
-
if (branchName === config.dev) return "dev";
|
|
27
|
-
const { prefixes } = config;
|
|
28
|
-
for (const type of BRANCH_TYPES) {
|
|
29
|
-
const prefix = prefixes[type];
|
|
30
|
-
if (prefix && branchName.startsWith(prefix)) {
|
|
31
|
-
return type;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Formats merge target for display (actual branch names).
|
|
39
|
-
*/
|
|
40
|
-
function formatMergeTarget(
|
|
41
|
-
mergeTarget: "main" | "dev" | "main-then-dev",
|
|
42
|
-
config: ResolvedConfig,
|
|
43
|
-
): string {
|
|
44
|
-
if (mergeTarget === "main-then-dev") {
|
|
45
|
-
return `${config.main}, then ${config.dev}`;
|
|
46
|
-
}
|
|
47
|
-
return mergeTarget === "main" ? config.main : config.dev;
|
|
48
|
-
}
|
|
16
|
+
import { readActiveRun } from "../run-state.js";
|
|
17
|
+
import type { ParsedArgs } from "../types.js";
|
|
49
18
|
|
|
50
19
|
/**
|
|
51
20
|
* Runs the status command.
|
|
52
|
-
* Shows current branch, classification, base, merge target(s), and ahead/behind vs base.
|
|
53
|
-
* Output goes to stdout for scripts. On detached HEAD or non-repo, reports clearly and exits.
|
|
54
21
|
*/
|
|
55
22
|
export async function run(args: ParsedArgs): Promise<void> {
|
|
56
|
-
const { cwd, dryRun, verbose, quiet } = args;
|
|
23
|
+
const { cwd, dryRun, verbose, quiet, json } = args;
|
|
57
24
|
|
|
58
25
|
const root = await resolveRepoRoot(cwd).catch((err: unknown) => {
|
|
59
26
|
if (err instanceof NotRepoError) throw err;
|
|
@@ -70,22 +37,62 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
70
37
|
verbose: !!verbose,
|
|
71
38
|
});
|
|
72
39
|
|
|
40
|
+
const active = readActiveRun(root);
|
|
41
|
+
|
|
42
|
+
if (json) {
|
|
43
|
+
const payload: Record<string, unknown> = {
|
|
44
|
+
branch: current,
|
|
45
|
+
suspended: active
|
|
46
|
+
? { command: active.command, status: active.status, nextStep: active.nextStep }
|
|
47
|
+
: null,
|
|
48
|
+
};
|
|
49
|
+
if (current) {
|
|
50
|
+
const classification = classifyBranch(current, config);
|
|
51
|
+
payload.type = classification;
|
|
52
|
+
if (classification && classification !== "main" && classification !== "dev") {
|
|
53
|
+
const mergeTarget = await resolveMergeTarget(root, current, classification, config, {
|
|
54
|
+
dryRun: !!dryRun,
|
|
55
|
+
verbose: !!verbose,
|
|
56
|
+
});
|
|
57
|
+
const base = getBaseBranchName(classification, false, config);
|
|
58
|
+
const ab = await getAheadBehind(root, base, current, {
|
|
59
|
+
dryRun: !!dryRun,
|
|
60
|
+
verbose: !!verbose,
|
|
61
|
+
});
|
|
62
|
+
payload.base = base;
|
|
63
|
+
payload.mergeTarget = mergeTarget;
|
|
64
|
+
payload.mergeTargetDisplay = formatMergeTarget(mergeTarget, config);
|
|
65
|
+
payload.ahead = ab.ahead;
|
|
66
|
+
payload.behind = ab.behind;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (active && !quiet) {
|
|
74
|
+
console.log(`Suspended: ${active.command} (${active.status})`);
|
|
75
|
+
hint("Run gflows continue after resolving conflicts, or gflows abort / undo.");
|
|
76
|
+
}
|
|
77
|
+
|
|
73
78
|
if (current === null) {
|
|
74
79
|
if (!quiet) {
|
|
75
80
|
console.log("HEAD is detached.");
|
|
81
|
+
hint("Try: git checkout dev");
|
|
76
82
|
}
|
|
77
83
|
return;
|
|
78
84
|
}
|
|
79
85
|
|
|
80
|
-
const classification = classifyBranch(current, config);
|
|
81
|
-
|
|
82
86
|
if (!quiet) {
|
|
83
87
|
console.log(`Branch: ${current}`);
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
const classification = classifyBranch(current, config);
|
|
91
|
+
|
|
86
92
|
if (classification === "main") {
|
|
87
93
|
if (!quiet) {
|
|
88
94
|
console.log("Type: long-lived (main)");
|
|
95
|
+
hint("Run gflows start feature <name> or gflows start hotfix vX.Y.Z");
|
|
89
96
|
}
|
|
90
97
|
return;
|
|
91
98
|
}
|
|
@@ -93,6 +100,7 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
93
100
|
if (classification === "dev") {
|
|
94
101
|
if (!quiet) {
|
|
95
102
|
console.log("Type: long-lived (dev)");
|
|
103
|
+
hint("Run gflows start feature <name> to begin work.");
|
|
96
104
|
}
|
|
97
105
|
return;
|
|
98
106
|
}
|
|
@@ -100,13 +108,21 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
100
108
|
if (classification === null) {
|
|
101
109
|
if (!quiet) {
|
|
102
110
|
console.log("Type: unknown");
|
|
111
|
+
hint("Use a typed prefix (feature/, bugfix/, …) or gflows start …");
|
|
103
112
|
}
|
|
104
113
|
return;
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
const mergeTarget = await resolveMergeTarget(root, current, classification, config, {
|
|
117
|
+
dryRun: !!dryRun,
|
|
118
|
+
verbose: !!verbose,
|
|
119
|
+
});
|
|
120
|
+
const baseBranch = getBaseBranchName(
|
|
121
|
+
classification,
|
|
122
|
+
mergeTarget === "main-then-dev" && classification === "bugfix",
|
|
123
|
+
config,
|
|
124
|
+
);
|
|
125
|
+
const mergeTargetDisplay = formatMergeTarget(mergeTarget, config);
|
|
110
126
|
|
|
111
127
|
if (!quiet) {
|
|
112
128
|
console.log(`Type: ${classification}`);
|
|
@@ -121,7 +137,10 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
121
137
|
|
|
122
138
|
if (!quiet) {
|
|
123
139
|
console.log(`Ahead/behind: ${ahead} ahead, ${behind} behind`);
|
|
124
|
-
|
|
125
|
-
|
|
140
|
+
if (ahead === 0) {
|
|
141
|
+
hint("No commits to finish yet — commit changes first.");
|
|
142
|
+
} else {
|
|
143
|
+
hint(`Run gflows finish ${classification} to merge into ${mergeTargetDisplay}.`);
|
|
144
|
+
}
|
|
126
145
|
}
|
|
127
146
|
}
|
package/src/commands/switch.ts
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
import { resolveConfig } from "../config.js";
|
|
7
7
|
import { EXIT_GIT, EXIT_OK, EXIT_USER } from "../constants.js";
|
|
8
|
-
import { NotRepoError } from "../errors.js";
|
|
8
|
+
import { CannotDeleteMainOrDevError, NotRepoError } from "../errors.js";
|
|
9
9
|
import {
|
|
10
10
|
branchList,
|
|
11
11
|
checkout,
|
|
12
12
|
cleanUntracked,
|
|
13
|
+
deleteBranch,
|
|
13
14
|
findStashRefByBranch,
|
|
14
15
|
findStashRefByMessage,
|
|
15
16
|
getCurrentBranch,
|
|
@@ -26,7 +27,7 @@ import { hint, success } from "../out.js";
|
|
|
26
27
|
import type { BranchType, ParsedArgs } from "../types.js";
|
|
27
28
|
|
|
28
29
|
/** User choice when switching with uncommitted changes. */
|
|
29
|
-
type SwitchWhenUncommitted = "cancel" | "restore" | "clean" | "move";
|
|
30
|
+
type SwitchWhenUncommitted = "cancel" | "restore" | "clean" | "move" | "destroy";
|
|
30
31
|
|
|
31
32
|
const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
|
|
32
33
|
|
|
@@ -91,19 +92,14 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
91
92
|
dryRun,
|
|
92
93
|
verbose: args.verbose,
|
|
93
94
|
});
|
|
94
|
-
const {
|
|
95
|
-
|
|
95
|
+
const { selectPrompt } = await import("../prompts.js");
|
|
96
|
+
targetBranch = await selectPrompt({
|
|
96
97
|
message: "Switch to branch",
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
options: choices.map((b) => ({
|
|
99
|
+
label: currentBranchForPicker && b === currentBranchForPicker ? `${b} (current)` : b,
|
|
99
100
|
value: b,
|
|
100
101
|
})),
|
|
101
102
|
});
|
|
102
|
-
|
|
103
|
-
if (typeof chosen !== "string") {
|
|
104
|
-
process.exit(EXIT_USER);
|
|
105
|
-
}
|
|
106
|
-
targetBranch = chosen;
|
|
107
103
|
}
|
|
108
104
|
|
|
109
105
|
const gitOpts = { dryRun, verbose: args.verbose };
|
|
@@ -114,17 +110,21 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
114
110
|
if (args.switchMode !== undefined) {
|
|
115
111
|
whenUncommitted = args.switchMode;
|
|
116
112
|
} else if (!treeClean && isTTY) {
|
|
117
|
-
const {
|
|
118
|
-
whenUncommitted = await selectPrompt({
|
|
113
|
+
const { selectPrompt } = await import("../prompts.js");
|
|
114
|
+
whenUncommitted = await selectPrompt<"move" | "restore" | "clean" | "destroy" | "cancel">({
|
|
119
115
|
message: "Working tree has uncommitted changes. What do you want to do?",
|
|
120
|
-
|
|
121
|
-
{
|
|
116
|
+
options: [
|
|
117
|
+
{ label: "Move — Move current changes to the target branch", value: "move" },
|
|
122
118
|
{
|
|
123
|
-
|
|
124
|
-
value: "restore"
|
|
119
|
+
label: "Restore — Save changes for this branch; restore target's saved state (if any)",
|
|
120
|
+
value: "restore",
|
|
125
121
|
},
|
|
126
|
-
{
|
|
127
|
-
{
|
|
122
|
+
{ label: "Clean — Discard changes and switch clean at HEAD", value: "clean" },
|
|
123
|
+
{
|
|
124
|
+
label: "Destroy — Delete current branch and switch to the target branch",
|
|
125
|
+
value: "destroy",
|
|
126
|
+
},
|
|
127
|
+
{ label: "Cancel — Abort switching", value: "cancel" },
|
|
128
128
|
],
|
|
129
129
|
});
|
|
130
130
|
} else if (!treeClean) {
|
|
@@ -139,6 +139,31 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
139
139
|
process.exit(EXIT_USER);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
let branchToDestroy: string | undefined;
|
|
143
|
+
if (whenUncommitted === "destroy") {
|
|
144
|
+
const currentBranch = await getCurrentBranch(root, gitOpts);
|
|
145
|
+
if (!currentBranch) {
|
|
146
|
+
console.error("gflows switch: cannot destroy when HEAD is detached.");
|
|
147
|
+
process.exit(EXIT_USER);
|
|
148
|
+
}
|
|
149
|
+
if (currentBranch === config.main || currentBranch === config.dev) {
|
|
150
|
+
throw new CannotDeleteMainOrDevError(
|
|
151
|
+
`Cannot destroy the long-lived branch '${currentBranch}'.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (currentBranch === targetBranch) {
|
|
155
|
+
console.error(
|
|
156
|
+
"gflows switch: cannot destroy current branch when switching to the same branch.",
|
|
157
|
+
);
|
|
158
|
+
process.exit(EXIT_USER);
|
|
159
|
+
}
|
|
160
|
+
branchToDestroy = currentBranch;
|
|
161
|
+
if (!treeClean) {
|
|
162
|
+
await restoreTracked(root, gitOpts);
|
|
163
|
+
await cleanUntracked(root, gitOpts);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
142
167
|
if (!treeClean && whenUncommitted === "move") {
|
|
143
168
|
await stashPushMove(root, gitOpts);
|
|
144
169
|
}
|
|
@@ -210,7 +235,12 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
210
235
|
await tryRestoreTargetStash();
|
|
211
236
|
}
|
|
212
237
|
|
|
213
|
-
if (
|
|
238
|
+
if (branchToDestroy) {
|
|
239
|
+
await deleteBranch(root, branchToDestroy, gitOpts);
|
|
240
|
+
if (!quiet && !dryRun) {
|
|
241
|
+
success(`Deleted branch '${branchToDestroy}' and switched to '${targetBranch}'.`);
|
|
242
|
+
}
|
|
243
|
+
} else if (!quiet && !dryRun) {
|
|
214
244
|
success(`Switched to branch '${targetBranch}'.`);
|
|
215
245
|
hint("Use gflows list to see all workflow branches.");
|
|
216
246
|
}
|