gflows 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +549 -0
- package/package.json +38 -0
- package/src/cli.ts +358 -0
- package/src/commands/bump.ts +213 -0
- package/src/commands/completion.ts +353 -0
- package/src/commands/delete.ts +133 -0
- package/src/commands/finish.ts +275 -0
- package/src/commands/help.ts +53 -0
- package/src/commands/init.ts +70 -0
- package/src/commands/list.ts +89 -0
- package/src/commands/start.ts +141 -0
- package/src/commands/status.ts +137 -0
- package/src/commands/switch.ts +102 -0
- package/src/commands/version.ts +27 -0
- package/src/config.ts +229 -0
- package/src/constants.ts +38 -0
- package/src/errors.ts +96 -0
- package/src/git.ts +481 -0
- package/src/index.ts +84 -0
- package/src/types.ts +124 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finish command: merge workflow branch into target(s), optional tag, delete branch, and push.
|
|
3
|
+
* Resolves branch (current, -B <name>, or picker when -B with no value and TTY).
|
|
4
|
+
* Uses normal merge with optional --no-ff; on conflict exits with clear message. Release/hotfix: merge to main then dev, create tag.
|
|
5
|
+
* @module commands/finish
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BranchType, ParsedArgs } from "../types.js";
|
|
9
|
+
import { EXIT_USER, VERSION_REGEX } from "../constants.js";
|
|
10
|
+
import {
|
|
11
|
+
getBranchTypeMeta,
|
|
12
|
+
getPrefixForType,
|
|
13
|
+
resolveConfig,
|
|
14
|
+
} from "../config.js";
|
|
15
|
+
import { BranchNotFoundError } from "../errors.js";
|
|
16
|
+
import {
|
|
17
|
+
assertNoRebaseOrMerge,
|
|
18
|
+
assertNotDetached,
|
|
19
|
+
branchList,
|
|
20
|
+
checkout,
|
|
21
|
+
deleteBranch,
|
|
22
|
+
getCurrentBranch,
|
|
23
|
+
merge,
|
|
24
|
+
push,
|
|
25
|
+
resolveRepoRoot,
|
|
26
|
+
tag,
|
|
27
|
+
tagExists,
|
|
28
|
+
} from "../git.js";
|
|
29
|
+
|
|
30
|
+
/** Normalizes version to vX.Y.Z for tag name. */
|
|
31
|
+
function normalizeTagVersion(version: string): string {
|
|
32
|
+
const v = version.trim();
|
|
33
|
+
return v.startsWith("v") ? v : `v${v}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns workflow branches (local) that match any configured prefix.
|
|
38
|
+
*/
|
|
39
|
+
async function getWorkflowBranches(
|
|
40
|
+
cwd: string,
|
|
41
|
+
prefixes: Record<BranchType, string>
|
|
42
|
+
): Promise<string[]> {
|
|
43
|
+
const all = await branchList(cwd, { dryRun: false, verbose: false });
|
|
44
|
+
const workflow: string[] = [];
|
|
45
|
+
for (const b of all) {
|
|
46
|
+
for (const prefix of Object.values(prefixes)) {
|
|
47
|
+
if (b.startsWith(prefix)) {
|
|
48
|
+
workflow.push(b);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return workflow.sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Infers branch type and optional version from branch name using config prefixes.
|
|
58
|
+
*/
|
|
59
|
+
function parseBranchTypeAndVersion(
|
|
60
|
+
branchName: string,
|
|
61
|
+
prefixes: Record<BranchType, string>
|
|
62
|
+
): { type: BranchType; version?: string } | null {
|
|
63
|
+
for (const type of [
|
|
64
|
+
"release",
|
|
65
|
+
"hotfix",
|
|
66
|
+
"feature",
|
|
67
|
+
"bugfix",
|
|
68
|
+
"chore",
|
|
69
|
+
"spike",
|
|
70
|
+
] as BranchType[]) {
|
|
71
|
+
const prefix = prefixes[type];
|
|
72
|
+
if (prefix && branchName.startsWith(prefix)) {
|
|
73
|
+
const suffix = branchName.slice(prefix.length);
|
|
74
|
+
if (type === "release" || type === "hotfix") {
|
|
75
|
+
const ver = suffix.trim();
|
|
76
|
+
return VERSION_REGEX.test(ver) ? { type, version: ver } : { type };
|
|
77
|
+
}
|
|
78
|
+
return { type };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Runs the finish command: resolve branch, run pre-checks, merge to target(s), optional tag/delete/push.
|
|
86
|
+
* Pre-checks: repo, not detached HEAD, no rebase/merge, branch is not main/dev; for release/hotfix tag must not exist.
|
|
87
|
+
*
|
|
88
|
+
* @param args - Parsed CLI args (cwd, type, branch, push, noPush, remote, dryRun, verbose, quiet, yes, noFf, deleteAfterFinish, noDeleteAfterFinish, signTag, noTag, tagMessage).
|
|
89
|
+
*/
|
|
90
|
+
export async function run(args: ParsedArgs): Promise<void> {
|
|
91
|
+
const repoRoot = await resolveRepoRoot(args.cwd);
|
|
92
|
+
const config = resolveConfig(
|
|
93
|
+
repoRoot,
|
|
94
|
+
{ remote: args.remote },
|
|
95
|
+
{ verbose: args.verbose }
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const opts: { dryRun: boolean; verbose: boolean } = {
|
|
99
|
+
dryRun: args.dryRun,
|
|
100
|
+
verbose: args.verbose,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const isTTY = Boolean(process.stdin.isTTY);
|
|
104
|
+
|
|
105
|
+
// Resolve branch to finish (and guard main/dev early so we can error before picker)
|
|
106
|
+
let branchToFinish: string;
|
|
107
|
+
const explicitBranch =
|
|
108
|
+
typeof args.branch === "string" && args.branch.trim() !== ""
|
|
109
|
+
? args.branch.trim()
|
|
110
|
+
: undefined;
|
|
111
|
+
|
|
112
|
+
if (explicitBranch) {
|
|
113
|
+
branchToFinish = explicitBranch;
|
|
114
|
+
} else if (isTTY) {
|
|
115
|
+
const workflow = await getWorkflowBranches(repoRoot, config.prefixes);
|
|
116
|
+
if (workflow.length === 0) {
|
|
117
|
+
console.error("gflows finish: no workflow branches found.");
|
|
118
|
+
process.exit(EXIT_USER);
|
|
119
|
+
}
|
|
120
|
+
const { select } = await import("@inquirer/prompts");
|
|
121
|
+
branchToFinish = await select({
|
|
122
|
+
message: "Branch to finish",
|
|
123
|
+
choices: workflow.map((b) => ({ name: b, value: b })),
|
|
124
|
+
});
|
|
125
|
+
} else {
|
|
126
|
+
const current = await getCurrentBranch(repoRoot, opts);
|
|
127
|
+
if (!current) {
|
|
128
|
+
console.error(
|
|
129
|
+
"gflows finish: HEAD is detached. Checkout a branch or specify one with -B <name>."
|
|
130
|
+
);
|
|
131
|
+
process.exit(EXIT_USER);
|
|
132
|
+
}
|
|
133
|
+
branchToFinish = current;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Refuse finish on main or dev
|
|
137
|
+
if (branchToFinish === config.main || branchToFinish === config.dev) {
|
|
138
|
+
console.error(
|
|
139
|
+
`gflows finish: cannot finish the long-lived branch '${branchToFinish}'. Finish a workflow branch (feature, bugfix, etc.) instead.`
|
|
140
|
+
);
|
|
141
|
+
process.exit(2);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Infer type from branch name (or use args.type if provided and consistent)
|
|
145
|
+
const parsed = parseBranchTypeAndVersion(branchToFinish, config.prefixes);
|
|
146
|
+
const type: BranchType | undefined = args.type ?? parsed?.type ?? undefined;
|
|
147
|
+
if (!type) {
|
|
148
|
+
console.error(
|
|
149
|
+
`gflows finish: cannot determine branch type for '${branchToFinish}'. Specify type (e.g. gflows finish feature) or use a branch name with a known prefix.`
|
|
150
|
+
);
|
|
151
|
+
process.exit(EXIT_USER);
|
|
152
|
+
}
|
|
153
|
+
if (parsed && parsed.type !== type) {
|
|
154
|
+
console.error(
|
|
155
|
+
`gflows finish: branch '${branchToFinish}' matches type '${parsed.type}', but '${type}' was specified.`
|
|
156
|
+
);
|
|
157
|
+
process.exit(EXIT_USER);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const meta = getBranchTypeMeta(type);
|
|
161
|
+
const version = parsed?.version;
|
|
162
|
+
|
|
163
|
+
// Pre-checks: not detached, no rebase/merge
|
|
164
|
+
await assertNotDetached(repoRoot);
|
|
165
|
+
assertNoRebaseOrMerge(repoRoot);
|
|
166
|
+
|
|
167
|
+
// For release/hotfix: tag must not already exist
|
|
168
|
+
if (meta.tagOnFinish && version) {
|
|
169
|
+
const tagName = normalizeTagVersion(version);
|
|
170
|
+
const exists = await tagExists(repoRoot, tagName, opts);
|
|
171
|
+
if (exists) {
|
|
172
|
+
console.error(`gflows finish: tag '${tagName}' already exists.`);
|
|
173
|
+
process.exit(2);
|
|
174
|
+
}
|
|
175
|
+
} else if (meta.tagOnFinish && !version) {
|
|
176
|
+
console.error(
|
|
177
|
+
`gflows finish: release/hotfix branch '${branchToFinish}' has no valid version segment. Use format release/vX.Y.Z or hotfix/vX.Y.Z.`
|
|
178
|
+
);
|
|
179
|
+
process.exit(EXIT_USER);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Ensure the branch we're finishing exists (e.g. if -B was used)
|
|
183
|
+
const branches = await branchList(repoRoot, { ...opts, dryRun: false });
|
|
184
|
+
if (!branches.includes(branchToFinish)) {
|
|
185
|
+
throw new BranchNotFoundError(
|
|
186
|
+
`Branch '${branchToFinish}' not found. Specify an existing local branch with -B <name>.`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const noFf = args.noFf;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
if (meta.mergeTarget === "dev") {
|
|
194
|
+
await checkout(repoRoot, config.dev, opts);
|
|
195
|
+
await merge(repoRoot, branchToFinish, { ...opts, noFf });
|
|
196
|
+
} else {
|
|
197
|
+
// main-then-dev: merge into main first, then merge main into dev
|
|
198
|
+
await checkout(repoRoot, config.main, opts);
|
|
199
|
+
await merge(repoRoot, branchToFinish, { ...opts, noFf });
|
|
200
|
+
|
|
201
|
+
if (meta.tagOnFinish && version && !args.noTag) {
|
|
202
|
+
const tagName = normalizeTagVersion(version);
|
|
203
|
+
await tag(repoRoot, tagName, {
|
|
204
|
+
...opts,
|
|
205
|
+
sign: args.signTag,
|
|
206
|
+
tagMessage: args.tagMessage,
|
|
207
|
+
});
|
|
208
|
+
if (!args.quiet && !args.dryRun) {
|
|
209
|
+
console.error(`gflows: created tag '${tagName}'.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await checkout(repoRoot, config.dev, opts);
|
|
214
|
+
await merge(repoRoot, config.main, { ...opts, noFf });
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Optional: delete the finished branch
|
|
221
|
+
let shouldDelete = args.deleteAfterFinish;
|
|
222
|
+
if (!args.deleteAfterFinish && !args.noDeleteAfterFinish) {
|
|
223
|
+
if (args.yes) {
|
|
224
|
+
shouldDelete = false;
|
|
225
|
+
} else if (isTTY) {
|
|
226
|
+
const { confirm } = await import("@inquirer/prompts");
|
|
227
|
+
shouldDelete = await confirm({
|
|
228
|
+
message: "Delete branch after finish?",
|
|
229
|
+
default: false,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (args.noDeleteAfterFinish) {
|
|
234
|
+
shouldDelete = false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (shouldDelete && !opts.dryRun) {
|
|
238
|
+
await deleteBranch(repoRoot, branchToFinish, opts);
|
|
239
|
+
if (!args.quiet) {
|
|
240
|
+
console.error(`gflows: deleted branch '${branchToFinish}'.`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const doPush = args.push && !args.noPush;
|
|
245
|
+
const didCreateTag = !!(
|
|
246
|
+
meta.mergeTarget === "main-then-dev" && meta.tagOnFinish && version && !args.noTag
|
|
247
|
+
);
|
|
248
|
+
if (doPush) {
|
|
249
|
+
const remote = args.remote ?? config.remote;
|
|
250
|
+
const refsToPush: string[] = [config.dev];
|
|
251
|
+
if (meta.mergeTarget === "main-then-dev") {
|
|
252
|
+
refsToPush.push(config.main);
|
|
253
|
+
}
|
|
254
|
+
const pushCode = await push(
|
|
255
|
+
repoRoot,
|
|
256
|
+
remote,
|
|
257
|
+
refsToPush,
|
|
258
|
+
didCreateTag,
|
|
259
|
+
opts
|
|
260
|
+
);
|
|
261
|
+
if (pushCode !== 0) {
|
|
262
|
+
console.error(
|
|
263
|
+
"gflows: merge and tag succeeded locally, but push failed. Retry with `git push` or `gflows finish ... --push`."
|
|
264
|
+
);
|
|
265
|
+
process.exit(2);
|
|
266
|
+
}
|
|
267
|
+
if (!args.quiet && !args.dryRun) {
|
|
268
|
+
console.error(`gflows: pushed to ${remote}.`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!args.quiet && !args.dryRun) {
|
|
273
|
+
console.error(`gflows: finished '${branchToFinish}' into ${meta.mergeTarget}.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help command: print quick reference from the spec and list commands/flags.
|
|
3
|
+
* @module commands/help
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ParsedArgs } from "../types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Runs the help command: prints usage, commands, types, flags, and exit codes to stdout.
|
|
10
|
+
* @param _args - Parsed CLI args (unused; kept for command signature consistency).
|
|
11
|
+
*/
|
|
12
|
+
export async function run(_args: ParsedArgs): Promise<void> {
|
|
13
|
+
const out = `
|
|
14
|
+
gflows — Modern Git branching workflow CLI
|
|
15
|
+
|
|
16
|
+
Usage: gflows <command> [type] [name] [flags]
|
|
17
|
+
|
|
18
|
+
Commands:
|
|
19
|
+
init, -I Ensure main, create dev
|
|
20
|
+
start, -S Create workflow branch
|
|
21
|
+
finish, -F Merge and close branch
|
|
22
|
+
switch, -W Switch branch (picker or name)
|
|
23
|
+
delete, -L Delete local branch(es)
|
|
24
|
+
list, -l List branches by type
|
|
25
|
+
bump Bump or rollback package version
|
|
26
|
+
completion Print shell completion script
|
|
27
|
+
status, -t Show current branch flow info
|
|
28
|
+
help, -h Show this usage
|
|
29
|
+
version, -V Show version
|
|
30
|
+
|
|
31
|
+
Types: feature (-f), bugfix (-b), chore (-c), release (-r), hotfix (-x), spike (-e)
|
|
32
|
+
|
|
33
|
+
Common flags:
|
|
34
|
+
-p, --push Push after init/start/finish
|
|
35
|
+
-P, --no-push Do not push
|
|
36
|
+
-R, --remote <name> Remote name for push
|
|
37
|
+
-o, --from <branch> Base branch override (e.g. -o main for bugfix)
|
|
38
|
+
-B, --branch <name> Branch name (finish: branch to finish)
|
|
39
|
+
-y, --yes Skip confirmations
|
|
40
|
+
-d, --dry-run Log actions only, no writes
|
|
41
|
+
-v, --verbose Verbose output
|
|
42
|
+
-q, --quiet Minimal output
|
|
43
|
+
-C, --path <dir> Run as if in <dir>
|
|
44
|
+
|
|
45
|
+
Start: --force Allow dirty working tree
|
|
46
|
+
Finish: --no-ff Always create merge commit; -D/--delete, -N/--no-delete;
|
|
47
|
+
-s/--sign, -T/--no-tag, -M/--tag-message, -m/--message
|
|
48
|
+
List: -r, --include-remote Include remote-tracking branches
|
|
49
|
+
|
|
50
|
+
Exit codes: 0 success, 1 usage/validation, 2 Git or system error.
|
|
51
|
+
`;
|
|
52
|
+
console.log(out.trim());
|
|
53
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Init command: ensure main exists, create dev from main, optional push and dry-run.
|
|
3
|
+
* @module commands/init
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolveConfig } from "../config.js";
|
|
7
|
+
import { BranchNotFoundError, NotRepoError } from "../errors.js";
|
|
8
|
+
import {
|
|
9
|
+
branchList,
|
|
10
|
+
push,
|
|
11
|
+
resolveRepoRoot,
|
|
12
|
+
revParse,
|
|
13
|
+
runGit,
|
|
14
|
+
} from "../git.js";
|
|
15
|
+
import type { ParsedArgs } from "../types.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Runs the init command: ensure main exists, create dev from main if missing, optional push.
|
|
19
|
+
* Pre-check: cwd (or -C) must be a git repo; main branch must exist (exit 2 otherwise).
|
|
20
|
+
* Skips creating dev if it already exists. Supports --dry-run and --push.
|
|
21
|
+
*
|
|
22
|
+
* @param args - Parsed CLI args (cwd, dryRun, push, noPush, remote, verbose, quiet).
|
|
23
|
+
*/
|
|
24
|
+
export async function run(args: ParsedArgs): Promise<void> {
|
|
25
|
+
const repoRoot = await resolveRepoRoot(args.cwd);
|
|
26
|
+
const config = resolveConfig(repoRoot, {
|
|
27
|
+
remote: args.remote,
|
|
28
|
+
}, { verbose: args.verbose });
|
|
29
|
+
|
|
30
|
+
const opts = {
|
|
31
|
+
dryRun: args.dryRun,
|
|
32
|
+
verbose: args.verbose,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Ensure main branch exists
|
|
36
|
+
try {
|
|
37
|
+
await revParse(repoRoot, config.main, [], { dryRun: false, verbose: opts.verbose });
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (err instanceof NotRepoError) throw err;
|
|
40
|
+
if (err instanceof BranchNotFoundError) {
|
|
41
|
+
throw new BranchNotFoundError(
|
|
42
|
+
`Main branch '${config.main}' does not exist. Create an initial commit and the main branch first.`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const branches = await branchList(repoRoot, { ...opts, dryRun: false });
|
|
49
|
+
const devExists = branches.includes(config.dev);
|
|
50
|
+
|
|
51
|
+
if (!devExists) {
|
|
52
|
+
await runGit(["branch", config.dev, config.main], { cwd: repoRoot, ...opts });
|
|
53
|
+
if (!args.quiet && !args.dryRun) {
|
|
54
|
+
console.error(`gflows: created branch '${config.dev}' from '${config.main}'.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const doPush = args.push && !args.noPush;
|
|
59
|
+
if (doPush) {
|
|
60
|
+
const pushCode = await push(repoRoot, config.remote, [config.dev], false, opts);
|
|
61
|
+
if (pushCode !== 0) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Push failed. Local branch '${config.dev}' was created. Retry with \`git push ${config.remote} ${config.dev}\` or \`gflows init --push\`.`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (!args.quiet && !args.dryRun) {
|
|
67
|
+
console.error(`gflows: pushed '${config.dev}' to '${config.remote}'.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List command: list workflow branches using resolved config prefixes;
|
|
3
|
+
* optional type filter and -r/--include-remote for remote-tracking branches.
|
|
4
|
+
* Script-friendly: one branch per line to stdout.
|
|
5
|
+
* @module commands/list
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BranchType } from "../types.js";
|
|
9
|
+
import type { ParsedArgs } from "../types.js";
|
|
10
|
+
import type { ResolvedConfig } from "../types.js";
|
|
11
|
+
import { resolveConfig } from "../config.js";
|
|
12
|
+
import { NotRepoError } from "../errors.js";
|
|
13
|
+
import { branchList, fetch, resolveRepoRoot } from "../git.js";
|
|
14
|
+
|
|
15
|
+
const BRANCH_TYPES: BranchType[] = [
|
|
16
|
+
"feature",
|
|
17
|
+
"bugfix",
|
|
18
|
+
"chore",
|
|
19
|
+
"release",
|
|
20
|
+
"hotfix",
|
|
21
|
+
"spike",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns branch names that match any workflow prefix. If typeFilter is set,
|
|
26
|
+
* only branches with that type's prefix are included. Excludes main and dev.
|
|
27
|
+
*/
|
|
28
|
+
function filterWorkflowBranches(
|
|
29
|
+
allBranches: string[],
|
|
30
|
+
config: ResolvedConfig,
|
|
31
|
+
typeFilter: BranchType | undefined
|
|
32
|
+
): string[] {
|
|
33
|
+
const { main, dev, prefixes } = config;
|
|
34
|
+
const prefixesToMatch =
|
|
35
|
+
typeFilter !== undefined
|
|
36
|
+
? [prefixes[typeFilter]]
|
|
37
|
+
: BRANCH_TYPES.map((t) => prefixes[t]);
|
|
38
|
+
|
|
39
|
+
return allBranches.filter((b) => {
|
|
40
|
+
if (b === main || b === dev) return false;
|
|
41
|
+
return prefixesToMatch.some((p) => p && b.startsWith(p));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Runs the list command.
|
|
47
|
+
* Lists workflow branches (matching config prefixes), optionally filtered by type
|
|
48
|
+
* and optionally including remote-tracking branches (-r/--include-remote).
|
|
49
|
+
* When includeRemote is true, fetches from the configured remote first so refs are up to date.
|
|
50
|
+
* Output: one branch per line to stdout (script-friendly).
|
|
51
|
+
*/
|
|
52
|
+
export async function run(args: ParsedArgs): Promise<void> {
|
|
53
|
+
const { cwd, type: typeFilter, includeRemote, dryRun, verbose, quiet } = args;
|
|
54
|
+
|
|
55
|
+
const root = await resolveRepoRoot(cwd).catch((err: unknown) => {
|
|
56
|
+
if (err instanceof NotRepoError) throw err;
|
|
57
|
+
throw err;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const config = resolveConfig(root, undefined, { verbose: !!verbose });
|
|
61
|
+
|
|
62
|
+
if (includeRemote && !dryRun) {
|
|
63
|
+
await fetch(root, config.remote, {
|
|
64
|
+
verbose: !!verbose,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const allBranches = await branchList(root, {
|
|
69
|
+
includeRemote: includeRemote ?? false,
|
|
70
|
+
dryRun: !!dryRun,
|
|
71
|
+
verbose: !!verbose,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const workflowBranches = filterWorkflowBranches(
|
|
75
|
+
allBranches,
|
|
76
|
+
config,
|
|
77
|
+
typeFilter
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const sorted = [...workflowBranches].sort();
|
|
81
|
+
|
|
82
|
+
for (const b of sorted) {
|
|
83
|
+
console.log(b);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!quiet && sorted.length === 0) {
|
|
87
|
+
console.error("No workflow branches found.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start command: create a workflow branch from the appropriate base.
|
|
3
|
+
* Ensures clean tree (or --force), base exists (local or after fetch), creates type/name branch, optional push.
|
|
4
|
+
* @module commands/start
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { BranchType, ParsedArgs } from "../types.js";
|
|
8
|
+
import { EXIT_USER, VERSION_REGEX } from "../constants.js";
|
|
9
|
+
import { getPrefixForType, resolveConfig } from "../config.js";
|
|
10
|
+
import { BranchNotFoundError, DirtyWorkingTreeError, InvalidVersionError } from "../errors.js";
|
|
11
|
+
import {
|
|
12
|
+
assertNoRebaseOrMerge,
|
|
13
|
+
assertNotDetached,
|
|
14
|
+
branchList,
|
|
15
|
+
fetch,
|
|
16
|
+
isClean,
|
|
17
|
+
push,
|
|
18
|
+
resolveRepoRoot,
|
|
19
|
+
revParse,
|
|
20
|
+
runGit,
|
|
21
|
+
validateBranchName,
|
|
22
|
+
} from "../git.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns the base branch name for the given type and fromMain flag (main vs dev).
|
|
26
|
+
*/
|
|
27
|
+
function getBaseBranch(
|
|
28
|
+
type: BranchType,
|
|
29
|
+
fromMain: boolean,
|
|
30
|
+
main: string,
|
|
31
|
+
dev: string
|
|
32
|
+
): string {
|
|
33
|
+
if (type === "hotfix") return main;
|
|
34
|
+
if (type === "bugfix" && fromMain) return main;
|
|
35
|
+
return dev;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Runs the start command: validate pre-conditions, ensure base exists, create branch, optional push.
|
|
40
|
+
* Pre-checks: repo is git, not detached HEAD, no rebase/merge in progress, working tree clean (or --force), base exists.
|
|
41
|
+
* Requires type and name (exit 1 if missing). For release/hotfix, name must match vX.Y.Z or X.Y.Z.
|
|
42
|
+
*
|
|
43
|
+
* @param args - Parsed CLI args (cwd, type, name, push, noPush, remote, dryRun, verbose, quiet, force, fromMain).
|
|
44
|
+
*/
|
|
45
|
+
export async function run(args: ParsedArgs): Promise<void> {
|
|
46
|
+
if (!args.type || args.name === undefined || args.name === "") {
|
|
47
|
+
console.error("gflows start: requires type and name (e.g. gflows start feature my-feat). Use 'gflows help' for usage.");
|
|
48
|
+
process.exit(EXIT_USER);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const type = args.type;
|
|
52
|
+
const name = args.name.trim();
|
|
53
|
+
const repoRoot = await resolveRepoRoot(args.cwd);
|
|
54
|
+
const config = resolveConfig(
|
|
55
|
+
repoRoot,
|
|
56
|
+
{ remote: args.remote },
|
|
57
|
+
{ verbose: args.verbose }
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const opts = {
|
|
61
|
+
dryRun: args.dryRun,
|
|
62
|
+
verbose: args.verbose,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Pre-checks: not detached HEAD, no rebase/merge in progress
|
|
66
|
+
await assertNotDetached(repoRoot);
|
|
67
|
+
assertNoRebaseOrMerge(repoRoot);
|
|
68
|
+
|
|
69
|
+
// Working tree clean or --force
|
|
70
|
+
if (!args.force) {
|
|
71
|
+
const clean = await isClean(repoRoot, { dryRun: false, verbose: opts.verbose });
|
|
72
|
+
if (!clean) {
|
|
73
|
+
throw new DirtyWorkingTreeError();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Validate name: version for release/hotfix, branch name for others
|
|
78
|
+
if (type === "release" || type === "hotfix") {
|
|
79
|
+
if (!VERSION_REGEX.test(name)) {
|
|
80
|
+
throw new InvalidVersionError(
|
|
81
|
+
`Invalid version '${name}'. Use format vX.Y.Z or X.Y.Z (e.g. v1.2.0).`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
validateBranchName(name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const base = getBaseBranch(type, args.fromMain, config.main, config.dev);
|
|
89
|
+
|
|
90
|
+
// Ensure base exists (local or create from remote after fetch)
|
|
91
|
+
let baseExists = false;
|
|
92
|
+
try {
|
|
93
|
+
await revParse(repoRoot, base, [], { dryRun: false, verbose: opts.verbose });
|
|
94
|
+
baseExists = true;
|
|
95
|
+
} catch {
|
|
96
|
+
// Fetch and try remote ref
|
|
97
|
+
await fetch(repoRoot, config.remote, opts);
|
|
98
|
+
const remoteRef = `${config.remote}/${base}`;
|
|
99
|
+
try {
|
|
100
|
+
await revParse(repoRoot, remoteRef, [], { dryRun: false, verbose: opts.verbose });
|
|
101
|
+
if (!opts.dryRun) {
|
|
102
|
+
await runGit(["branch", base, remoteRef], { cwd: repoRoot, ...opts, dryRun: false });
|
|
103
|
+
}
|
|
104
|
+
baseExists = true;
|
|
105
|
+
} catch {
|
|
106
|
+
throw new BranchNotFoundError(
|
|
107
|
+
`Base branch '${base}' not found locally or on ${config.remote}. Create it or push it first.`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const prefix = getPrefixForType(config, type);
|
|
113
|
+
const fullBranchName = `${prefix}${name}`;
|
|
114
|
+
|
|
115
|
+
const branches = await branchList(repoRoot, { ...opts, dryRun: false });
|
|
116
|
+
if (branches.includes(fullBranchName)) {
|
|
117
|
+
throw new BranchNotFoundError(
|
|
118
|
+
`Branch '${fullBranchName}' already exists. Use a different name or switch to it.`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await runGit(["checkout", "-b", fullBranchName, base], { cwd: repoRoot, ...opts });
|
|
123
|
+
|
|
124
|
+
if (!args.quiet && !args.dryRun) {
|
|
125
|
+
console.error(`gflows: created and checked out branch '${fullBranchName}' from '${base}'.`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const doPush = args.push && !args.noPush;
|
|
129
|
+
if (doPush) {
|
|
130
|
+
const remote = args.remote ?? config.remote;
|
|
131
|
+
const pushCode = await push(repoRoot, remote, [fullBranchName], false, opts);
|
|
132
|
+
if (pushCode !== 0) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Push failed. Local branch '${fullBranchName}' was created. Retry with \`git push ${remote} ${fullBranchName}\` or \`gflows start ... --push\`.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (!args.quiet && !args.dryRun) {
|
|
138
|
+
console.error(`gflows: pushed '${fullBranchName}' to '${remote}'.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|