gflows 0.1.18 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +78 -0
- package/CHANGELOG.md +40 -0
- package/README.md +279 -505
- package/llms.txt +22 -0
- package/package.json +29 -23
- package/src/cli.ts +21 -367
- 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 -21
- 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 +14 -19
- 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 +62 -3
- 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
|
@@ -92,19 +92,14 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
92
92
|
dryRun,
|
|
93
93
|
verbose: args.verbose,
|
|
94
94
|
});
|
|
95
|
-
const {
|
|
96
|
-
|
|
95
|
+
const { selectPrompt } = await import("../prompts.js");
|
|
96
|
+
targetBranch = await selectPrompt({
|
|
97
97
|
message: "Switch to branch",
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
options: choices.map((b) => ({
|
|
99
|
+
label: currentBranchForPicker && b === currentBranchForPicker ? `${b} (current)` : b,
|
|
100
100
|
value: b,
|
|
101
101
|
})),
|
|
102
102
|
});
|
|
103
|
-
|
|
104
|
-
if (typeof chosen !== "string") {
|
|
105
|
-
process.exit(EXIT_USER);
|
|
106
|
-
}
|
|
107
|
-
targetBranch = chosen;
|
|
108
103
|
}
|
|
109
104
|
|
|
110
105
|
const gitOpts = { dryRun, verbose: args.verbose };
|
|
@@ -115,21 +110,21 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
115
110
|
if (args.switchMode !== undefined) {
|
|
116
111
|
whenUncommitted = args.switchMode;
|
|
117
112
|
} else if (!treeClean && isTTY) {
|
|
118
|
-
const {
|
|
119
|
-
whenUncommitted = await selectPrompt({
|
|
113
|
+
const { selectPrompt } = await import("../prompts.js");
|
|
114
|
+
whenUncommitted = await selectPrompt<"move" | "restore" | "clean" | "destroy" | "cancel">({
|
|
120
115
|
message: "Working tree has uncommitted changes. What do you want to do?",
|
|
121
|
-
|
|
122
|
-
{
|
|
116
|
+
options: [
|
|
117
|
+
{ label: "Move — Move current changes to the target branch", value: "move" },
|
|
123
118
|
{
|
|
124
|
-
|
|
125
|
-
value: "restore"
|
|
119
|
+
label: "Restore — Save changes for this branch; restore target's saved state (if any)",
|
|
120
|
+
value: "restore",
|
|
126
121
|
},
|
|
127
|
-
{
|
|
122
|
+
{ label: "Clean — Discard changes and switch clean at HEAD", value: "clean" },
|
|
128
123
|
{
|
|
129
|
-
|
|
130
|
-
value: "destroy"
|
|
124
|
+
label: "Destroy — Delete current branch and switch to the target branch",
|
|
125
|
+
value: "destroy",
|
|
131
126
|
},
|
|
132
|
-
{
|
|
127
|
+
{ label: "Cancel — Abort switching", value: "cancel" },
|
|
133
128
|
],
|
|
134
129
|
});
|
|
135
130
|
} else if (!treeClean) {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync command: update current workflow branch from its base (fetch + merge or rebase).
|
|
3
|
+
* Stashes dirty work when needed; supports continue via run-state.
|
|
4
|
+
* @module commands/sync
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getBranchTypeMeta, resolveConfig } from "../config.js";
|
|
8
|
+
import { EXIT_USER } from "../constants.js";
|
|
9
|
+
import { DirtyWorkingTreeError } from "../errors.js";
|
|
10
|
+
import { classifyBranch, getBaseBranchName } from "../flow.js";
|
|
11
|
+
import {
|
|
12
|
+
assertNotDetached,
|
|
13
|
+
fetch,
|
|
14
|
+
getCurrentBranch,
|
|
15
|
+
isClean,
|
|
16
|
+
resolveRepoRoot,
|
|
17
|
+
resolveSha,
|
|
18
|
+
runGit,
|
|
19
|
+
stashPopRef,
|
|
20
|
+
stashPushMove,
|
|
21
|
+
} from "../git.js";
|
|
22
|
+
import { hint, success } from "../out.js";
|
|
23
|
+
import {
|
|
24
|
+
completeRun,
|
|
25
|
+
type GflowsRunState,
|
|
26
|
+
startRun,
|
|
27
|
+
suspendRun,
|
|
28
|
+
writeActiveRun,
|
|
29
|
+
} from "../run-state.js";
|
|
30
|
+
import type { ParsedArgs } from "../types.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Executes remaining sync steps (used by continue).
|
|
34
|
+
*/
|
|
35
|
+
export async function executeSyncSteps(
|
|
36
|
+
repoRoot: string,
|
|
37
|
+
state: GflowsRunState,
|
|
38
|
+
opts: { dryRun: boolean; verbose: boolean; quiet: boolean },
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
let i = state.nextStep;
|
|
41
|
+
while (i < state.steps.length) {
|
|
42
|
+
const step = state.steps[i];
|
|
43
|
+
if (!step) break;
|
|
44
|
+
try {
|
|
45
|
+
const base = String(state.context.base);
|
|
46
|
+
const rebase = Boolean(state.context.rebase);
|
|
47
|
+
if (step.id === "update") {
|
|
48
|
+
if (rebase) {
|
|
49
|
+
const r = await runGit(["rebase", base], { cwd: repoRoot, ...opts });
|
|
50
|
+
if (r.exitCode !== 0) {
|
|
51
|
+
throw new Error(`Rebase onto '${base}' conflicted.`);
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
const r = await runGit(["merge", base], { cwd: repoRoot, ...opts });
|
|
55
|
+
if (r.exitCode !== 0) {
|
|
56
|
+
throw new Error(`Merge from '${base}' conflicted.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} else if (step.id === "stash-pop") {
|
|
60
|
+
const ref = typeof state.undo.stashRef === "string" ? state.undo.stashRef : null;
|
|
61
|
+
if (ref) {
|
|
62
|
+
await stashPopRef(repoRoot, ref, opts).catch(() => undefined);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
i += 1;
|
|
66
|
+
state.nextStep = i;
|
|
67
|
+
writeActiveRun(repoRoot, { ...state, status: "running" });
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
70
|
+
suspendRun(repoRoot, { ...state, nextStep: i }, msg);
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
completeRun(repoRoot, state);
|
|
75
|
+
if (!opts.quiet && !opts.dryRun) {
|
|
76
|
+
success(`gflows: synced with '${String(state.context.base)}'.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Runs sync on the current workflow branch.
|
|
82
|
+
*/
|
|
83
|
+
export async function run(args: ParsedArgs): Promise<void> {
|
|
84
|
+
const repoRoot = await resolveRepoRoot(args.cwd);
|
|
85
|
+
const config = resolveConfig(
|
|
86
|
+
repoRoot,
|
|
87
|
+
{ main: args.main, dev: args.dev, remote: args.remote },
|
|
88
|
+
{ verbose: args.verbose },
|
|
89
|
+
);
|
|
90
|
+
const opts = { dryRun: args.dryRun, verbose: args.verbose, quiet: args.quiet };
|
|
91
|
+
|
|
92
|
+
await assertNotDetached(repoRoot);
|
|
93
|
+
const current = await getCurrentBranch(repoRoot, opts);
|
|
94
|
+
if (!current) {
|
|
95
|
+
console.error("gflows sync: HEAD is detached.");
|
|
96
|
+
process.exit(EXIT_USER);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const classification = classifyBranch(current, config);
|
|
100
|
+
if (classification === "main" || classification === "dev") {
|
|
101
|
+
// Allow updating long-lived from remote tracking
|
|
102
|
+
await fetch(repoRoot, config.remote, opts);
|
|
103
|
+
const remoteRef = `${config.remote}/${current}`;
|
|
104
|
+
const r = await runGit(["merge", remoteRef], { cwd: repoRoot, ...opts });
|
|
105
|
+
if (r.exitCode !== 0) {
|
|
106
|
+
console.error(`gflows sync: could not update '${current}' from ${remoteRef}.`);
|
|
107
|
+
process.exit(2);
|
|
108
|
+
}
|
|
109
|
+
if (!args.quiet) success(`gflows: updated '${current}' from ${remoteRef}.`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (classification === null) {
|
|
113
|
+
console.error(`gflows sync: '${current}' is not a known workflow branch.`);
|
|
114
|
+
hint("Use gflows start to create a typed branch, or checkout feature/bugfix/…");
|
|
115
|
+
process.exit(EXIT_USER);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const fromMain = classification === "bugfix" && args.fromMain;
|
|
119
|
+
const base = getBaseBranchName(classification, fromMain, config);
|
|
120
|
+
// For bugfix-from-main detection on sync: if type is bugfix, prefer meta base unless fromMain
|
|
121
|
+
const meta = getBranchTypeMeta(classification);
|
|
122
|
+
const baseBranch =
|
|
123
|
+
classification === "hotfix" || (classification === "bugfix" && args.fromMain)
|
|
124
|
+
? config.main
|
|
125
|
+
: meta.base === "main"
|
|
126
|
+
? config.main
|
|
127
|
+
: config.dev;
|
|
128
|
+
|
|
129
|
+
await fetch(repoRoot, config.remote, opts);
|
|
130
|
+
|
|
131
|
+
let stashed = false;
|
|
132
|
+
const clean = await isClean(repoRoot, opts);
|
|
133
|
+
if (!clean) {
|
|
134
|
+
if (!args.force && !args.yes && !process.stdin.isTTY) {
|
|
135
|
+
throw new DirtyWorkingTreeError(
|
|
136
|
+
"Working tree dirty. Commit, stash, or pass --force to stash automatically during sync.",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
await stashPushMove(repoRoot, opts);
|
|
140
|
+
stashed = true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const beforeSha = await resolveSha(repoRoot, current, opts);
|
|
144
|
+
const steps = [
|
|
145
|
+
{ id: "update", label: args.rebase ? `Rebase onto ${baseBranch}` : `Merge ${baseBranch}` },
|
|
146
|
+
...(stashed ? [{ id: "stash-pop", label: "Restore stash" }] : []),
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
const state = startRun(
|
|
150
|
+
repoRoot,
|
|
151
|
+
"sync",
|
|
152
|
+
steps,
|
|
153
|
+
{ base: baseBranch, rebase: args.rebase, branch: current },
|
|
154
|
+
{ beforeSha, branch: current, stashRef: stashed ? "stash@{0}" : null },
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// Prefer configured base; ensure local base exists
|
|
158
|
+
void base;
|
|
159
|
+
await executeSyncSteps(repoRoot, state, opts);
|
|
160
|
+
}
|