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
package/src/git.ts
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git layer for gflows: safe wrappers around `git` via Bun.spawn.
|
|
3
|
+
* All operations use resolved repo root (cwd). Supports dry-run (log only) and verbose (echo commands).
|
|
4
|
+
* Throws typed errors from ./errors.js. Helpers for detached HEAD and rebase/merge-in-progress guards.
|
|
5
|
+
* @module git
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { INVALID_BRANCH_CHARS } from "./constants.js";
|
|
11
|
+
import {
|
|
12
|
+
BranchNotFoundError,
|
|
13
|
+
DetachedHeadError,
|
|
14
|
+
InvalidBranchNameError,
|
|
15
|
+
MergeConflictError,
|
|
16
|
+
NotRepoError,
|
|
17
|
+
RebaseMergeInProgressError,
|
|
18
|
+
} from "./errors.js";
|
|
19
|
+
|
|
20
|
+
/** Options for git operations: repo path, dry-run (log only), and verbose (echo commands). */
|
|
21
|
+
export interface GitOptions {
|
|
22
|
+
/** Resolved repo root (absolute path). */
|
|
23
|
+
cwd: string;
|
|
24
|
+
/** If true, do not run git; only log the command that would run. */
|
|
25
|
+
dryRun?: boolean;
|
|
26
|
+
/** If true, echo each git command to stderr. */
|
|
27
|
+
verbose?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Options for git helpers that take cwd as first param (no cwd in options). */
|
|
31
|
+
export type GitRunOptions = Pick<GitOptions, "dryRun" | "verbose">;
|
|
32
|
+
|
|
33
|
+
/** Result of running a git command (stdout, stderr, exit code). */
|
|
34
|
+
export interface GitRunResult {
|
|
35
|
+
stdout: string;
|
|
36
|
+
stderr: string;
|
|
37
|
+
exitCode: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Runs a git command via Bun.spawn. When dryRun is true, logs the command and returns success without running.
|
|
42
|
+
* When verbose is true, echoes the command to stderr before running.
|
|
43
|
+
*
|
|
44
|
+
* @param args - Git arguments (e.g. ["checkout", "main"]).
|
|
45
|
+
* @param options - cwd, dryRun, verbose.
|
|
46
|
+
* @returns Promise with stdout, stderr, and exitCode.
|
|
47
|
+
*/
|
|
48
|
+
export async function runGit(
|
|
49
|
+
args: string[],
|
|
50
|
+
options: GitOptions
|
|
51
|
+
): Promise<GitRunResult> {
|
|
52
|
+
const { cwd, dryRun = false, verbose = false } = options;
|
|
53
|
+
const cmd = ["git", ...args].join(" ");
|
|
54
|
+
|
|
55
|
+
if (dryRun) {
|
|
56
|
+
console.error(`gflows (dry-run): ${cmd}`);
|
|
57
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (verbose) {
|
|
61
|
+
console.error(`gflows: ${cmd}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
65
|
+
cwd,
|
|
66
|
+
stdout: "pipe",
|
|
67
|
+
stderr: "pipe",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const [stdout, stderr] = await Promise.all([
|
|
71
|
+
new Response(proc.stdout).text(),
|
|
72
|
+
new Response(proc.stderr).text(),
|
|
73
|
+
]);
|
|
74
|
+
const exitCode = await proc.exited;
|
|
75
|
+
|
|
76
|
+
return { stdout, stderr, exitCode };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolves the Git repository root for the given directory. If cwd is not inside a repo, throws NotRepoError.
|
|
81
|
+
*
|
|
82
|
+
* @param cwd - Directory to resolve from (e.g. process.cwd() or resolved -C path).
|
|
83
|
+
* @returns Absolute path to the repository root.
|
|
84
|
+
* @throws NotRepoError when cwd is not a Git repository.
|
|
85
|
+
*/
|
|
86
|
+
export async function resolveRepoRoot(cwd: string): Promise<string> {
|
|
87
|
+
const dir = resolve(cwd);
|
|
88
|
+
const result = await runGit(["rev-parse", "--show-toplevel"], {
|
|
89
|
+
cwd: dir,
|
|
90
|
+
dryRun: false,
|
|
91
|
+
verbose: false,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (result.exitCode !== 0) {
|
|
95
|
+
throw new NotRepoError("Not a Git repository.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const root = result.stdout.trim();
|
|
99
|
+
if (!root) {
|
|
100
|
+
throw new NotRepoError("Not a Git repository.");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return root;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ensures the given path is an existing directory and contains .git (or is the root reported by rev-parse).
|
|
108
|
+
* Use after resolveRepoRoot to validate, or use resolveRepoRoot which already validates.
|
|
109
|
+
* For pre-check "is cwd a git repo?", use resolveRepoRoot and catch NotRepoError.
|
|
110
|
+
*
|
|
111
|
+
* @param dir - Absolute path to directory.
|
|
112
|
+
* @throws NotRepoError if dir is not a directory or not a Git repo.
|
|
113
|
+
*/
|
|
114
|
+
export function ensureGitRepo(dir: string): void {
|
|
115
|
+
const resolved = resolve(dir);
|
|
116
|
+
const gitDir = `${resolved}/.git`;
|
|
117
|
+
if (!existsSync(resolved) || !existsSync(gitDir)) {
|
|
118
|
+
throw new NotRepoError("Not a Git repository.");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Runs git rev-parse with the given ref and optional extra args.
|
|
124
|
+
*
|
|
125
|
+
* @param cwd - Repo root.
|
|
126
|
+
* @param ref - Ref to parse (e.g. "HEAD", "main", "origin/dev").
|
|
127
|
+
* @param extraArgs - Extra args (e.g. ["--abbrev-ref"]).
|
|
128
|
+
* @param options - dryRun, verbose.
|
|
129
|
+
* @returns Trimmed stdout.
|
|
130
|
+
* @throws BranchNotFoundError when ref does not exist (exit code 128 or similar).
|
|
131
|
+
*/
|
|
132
|
+
export async function revParse(
|
|
133
|
+
cwd: string,
|
|
134
|
+
ref: string,
|
|
135
|
+
extraArgs: string[] = [],
|
|
136
|
+
options: Pick<GitOptions, "dryRun" | "verbose"> = {}
|
|
137
|
+
): Promise<string> {
|
|
138
|
+
const result = await runGit(["rev-parse", ...extraArgs, ref], { cwd, ...options });
|
|
139
|
+
if (result.exitCode !== 0) {
|
|
140
|
+
throw new BranchNotFoundError(`Ref '${ref}' not found.`);
|
|
141
|
+
}
|
|
142
|
+
return result.stdout.trim();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Lists local branch names. With includeRemote, fetches and includes remote-tracking branches.
|
|
147
|
+
*
|
|
148
|
+
* @param cwd - Repo root.
|
|
149
|
+
* @param options - dryRun, verbose; includeRemote to add -r and optionally fetch.
|
|
150
|
+
* @returns Branch names (local only, or with remotes if includeRemote).
|
|
151
|
+
*/
|
|
152
|
+
export async function branchList(
|
|
153
|
+
cwd: string,
|
|
154
|
+
options: GitRunOptions & { includeRemote?: boolean } = {}
|
|
155
|
+
): Promise<string[]> {
|
|
156
|
+
const { includeRemote = false, ...opts } = options;
|
|
157
|
+
const args = includeRemote
|
|
158
|
+
? ["branch", "-a", "--format=%(refname:short)"]
|
|
159
|
+
: ["branch", "--list", "--format=%(refname:short)"];
|
|
160
|
+
|
|
161
|
+
const result = await runGit(args, { cwd, ...opts });
|
|
162
|
+
if (result.exitCode !== 0) {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const lines = result.stdout.trim() ? result.stdout.trim().split(/\n/) : [];
|
|
167
|
+
const names = lines
|
|
168
|
+
.map((line) => line.replace(/^remotes\/[^/]+\//, "").trim())
|
|
169
|
+
.filter((name) => name && !name.startsWith("* "));
|
|
170
|
+
const dedup = [...new Set(names)];
|
|
171
|
+
return dedup.sort();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Checks out the given branch.
|
|
176
|
+
*
|
|
177
|
+
* @param cwd - Repo root.
|
|
178
|
+
* @param branch - Branch name to checkout.
|
|
179
|
+
* @param options - dryRun, verbose.
|
|
180
|
+
* @throws BranchNotFoundError if branch does not exist.
|
|
181
|
+
*/
|
|
182
|
+
export async function checkout(
|
|
183
|
+
cwd: string,
|
|
184
|
+
branch: string,
|
|
185
|
+
options: GitRunOptions = {}
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
const result = await runGit(["checkout", branch], { cwd, ...options });
|
|
188
|
+
if (result.exitCode !== 0) {
|
|
189
|
+
throw new BranchNotFoundError(`Branch '${branch}' not found or checkout failed.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Merges the given ref into the current branch. Uses --no-ff when requested.
|
|
195
|
+
*
|
|
196
|
+
* @param cwd - Repo root.
|
|
197
|
+
* @param ref - Ref to merge (branch or commit).
|
|
198
|
+
* @param options - dryRun, verbose, noFf.
|
|
199
|
+
* @throws MergeConflictError when merge has conflicts (exit code 1 and conflict hint in stderr).
|
|
200
|
+
*/
|
|
201
|
+
export async function merge(
|
|
202
|
+
cwd: string,
|
|
203
|
+
ref: string,
|
|
204
|
+
options: GitRunOptions & { noFf?: boolean } = {}
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
const { noFf = false, ...opts } = options;
|
|
207
|
+
const args = noFf ? ["merge", "--no-ff", ref] : ["merge", ref];
|
|
208
|
+
const result = await runGit(args, { cwd, ...opts });
|
|
209
|
+
|
|
210
|
+
if (result.exitCode !== 0) {
|
|
211
|
+
const hint =
|
|
212
|
+
"Resolve conflicts in the working tree, then run `git add` and `git merge --continue`, or `git merge --abort` to cancel. Re-run `gflows finish` after resolving if needed.";
|
|
213
|
+
throw new MergeConflictError(`Merge conflict while merging ${ref}. ${hint}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Pushes refs and optionally tags to the remote.
|
|
219
|
+
*
|
|
220
|
+
* @param cwd - Repo root.
|
|
221
|
+
* @param remote - Remote name (e.g. "origin").
|
|
222
|
+
* @param refs - Refs to push (e.g. ["main", "dev"] or []);
|
|
223
|
+
* @param pushTags - If true, also push tags (e.g. --follow-tags or separate push --tags).
|
|
224
|
+
* @param options - dryRun, verbose.
|
|
225
|
+
* @returns Exit code from git push (0 = success).
|
|
226
|
+
*/
|
|
227
|
+
export async function push(
|
|
228
|
+
cwd: string,
|
|
229
|
+
remote: string,
|
|
230
|
+
refs: string[],
|
|
231
|
+
pushTags: boolean,
|
|
232
|
+
options: GitRunOptions = {}
|
|
233
|
+
): Promise<number> {
|
|
234
|
+
const pushArgs = ["push", remote, ...refs];
|
|
235
|
+
const result = await runGit(pushArgs, { cwd, ...options });
|
|
236
|
+
if (result.exitCode !== 0) {
|
|
237
|
+
return result.exitCode;
|
|
238
|
+
}
|
|
239
|
+
if (pushTags) {
|
|
240
|
+
const tagResult = await runGit(["push", remote, "--tags"], { cwd, ...options });
|
|
241
|
+
return tagResult.exitCode;
|
|
242
|
+
}
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Creates a tag at the current HEAD. Optionally signed and with a message.
|
|
248
|
+
*
|
|
249
|
+
* @param cwd - Repo root.
|
|
250
|
+
* @param name - Tag name (e.g. "v1.2.3").
|
|
251
|
+
* @param options - dryRun, verbose, sign, tagMessage.
|
|
252
|
+
* @throws Error when tag already exists or creation fails.
|
|
253
|
+
*/
|
|
254
|
+
export async function tag(
|
|
255
|
+
cwd: string,
|
|
256
|
+
name: string,
|
|
257
|
+
options: GitRunOptions & { sign?: boolean; tagMessage?: string } = {}
|
|
258
|
+
): Promise<void> {
|
|
259
|
+
const { sign = false, tagMessage, ...opts } = options;
|
|
260
|
+
const args = ["tag", name];
|
|
261
|
+
if (sign) args.push("-s");
|
|
262
|
+
if (tagMessage) args.push("-m", tagMessage);
|
|
263
|
+
|
|
264
|
+
const result = await runGit(args, { cwd, ...opts });
|
|
265
|
+
if (result.exitCode !== 0) {
|
|
266
|
+
if (result.stderr.includes("already exists")) {
|
|
267
|
+
throw new Error(`Tag ${name} already exists.`);
|
|
268
|
+
}
|
|
269
|
+
throw new Error(result.stderr.trim() || `Failed to create tag ${name}.`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Returns true if a tag with the given name exists.
|
|
275
|
+
*
|
|
276
|
+
* @param cwd - Repo root.
|
|
277
|
+
* @param name - Tag name.
|
|
278
|
+
* @param options - dryRun, verbose (verbose has no effect for this read-only call).
|
|
279
|
+
*/
|
|
280
|
+
export async function tagExists(
|
|
281
|
+
cwd: string,
|
|
282
|
+
name: string,
|
|
283
|
+
options: GitRunOptions = {}
|
|
284
|
+
): Promise<boolean> {
|
|
285
|
+
const result = await runGit(["tag", "-l", name], { cwd, ...options });
|
|
286
|
+
return result.exitCode === 0 && result.stdout.trim() === name;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Deletes a local branch. Does not delete main/dev; callers must guard.
|
|
291
|
+
*
|
|
292
|
+
* @param cwd - Repo root.
|
|
293
|
+
* @param branch - Branch name to delete.
|
|
294
|
+
* @param options - dryRun, verbose.
|
|
295
|
+
* @throws BranchNotFoundError if branch does not exist or delete fails.
|
|
296
|
+
*/
|
|
297
|
+
export async function deleteBranch(
|
|
298
|
+
cwd: string,
|
|
299
|
+
branch: string,
|
|
300
|
+
options: GitRunOptions = {}
|
|
301
|
+
): Promise<void> {
|
|
302
|
+
const result = await runGit(["branch", "-d", branch], { cwd, ...options });
|
|
303
|
+
if (result.exitCode !== 0) {
|
|
304
|
+
throw new BranchNotFoundError(
|
|
305
|
+
result.stderr.trim() || `Could not delete branch '${branch}'.`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Returns true if the working tree is clean (no uncommitted changes).
|
|
312
|
+
*
|
|
313
|
+
* @param cwd - Repo root.
|
|
314
|
+
* @param options - dryRun, verbose (verbose has no effect for this read-only call).
|
|
315
|
+
*/
|
|
316
|
+
export async function isClean(
|
|
317
|
+
cwd: string,
|
|
318
|
+
options: GitRunOptions = {}
|
|
319
|
+
): Promise<boolean> {
|
|
320
|
+
const result = await runGit(["status", "--porcelain"], { cwd, ...options });
|
|
321
|
+
if (result.exitCode !== 0) return false;
|
|
322
|
+
return result.stdout.trim() === "";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Returns the current branch name, or null if HEAD is detached.
|
|
327
|
+
*
|
|
328
|
+
* @param cwd - Repo root.
|
|
329
|
+
* @param options - dryRun, verbose.
|
|
330
|
+
*/
|
|
331
|
+
export async function getCurrentBranch(
|
|
332
|
+
cwd: string,
|
|
333
|
+
options: GitRunOptions = {}
|
|
334
|
+
): Promise<string | null> {
|
|
335
|
+
const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
336
|
+
cwd,
|
|
337
|
+
...options,
|
|
338
|
+
});
|
|
339
|
+
if (result.exitCode !== 0) return null;
|
|
340
|
+
const name = result.stdout.trim();
|
|
341
|
+
if (name === "HEAD" || !name) return null;
|
|
342
|
+
return name;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Returns true if HEAD is detached (not on a branch).
|
|
347
|
+
*
|
|
348
|
+
* @param cwd - Repo root.
|
|
349
|
+
*/
|
|
350
|
+
export async function isDetachedHead(cwd: string): Promise<boolean> {
|
|
351
|
+
const branch = await getCurrentBranch(cwd);
|
|
352
|
+
return branch === null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Returns true if a rebase or merge is in progress (.git/rebase-merge, .git/rebase-apply, or .git/MERGE_HEAD).
|
|
357
|
+
*
|
|
358
|
+
* @param cwd - Repo root (must be repo root so .git is directly under it).
|
|
359
|
+
*/
|
|
360
|
+
export function isRebaseOrMergeInProgress(cwd: string): boolean {
|
|
361
|
+
const root = resolve(cwd);
|
|
362
|
+
return (
|
|
363
|
+
existsSync(`${root}/.git/rebase-merge`) ||
|
|
364
|
+
existsSync(`${root}/.git/rebase-apply`) ||
|
|
365
|
+
existsSync(`${root}/.git/MERGE_HEAD`)
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Asserts that HEAD is not detached; throws DetachedHeadError if it is.
|
|
371
|
+
*
|
|
372
|
+
* @param cwd - Repo root.
|
|
373
|
+
* @throws DetachedHeadError when HEAD is detached.
|
|
374
|
+
*/
|
|
375
|
+
export async function assertNotDetached(cwd: string): Promise<void> {
|
|
376
|
+
if (await isDetachedHead(cwd)) {
|
|
377
|
+
throw new DetachedHeadError();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Asserts that no rebase or merge is in progress; throws RebaseMergeInProgressError if one is.
|
|
383
|
+
*
|
|
384
|
+
* @param cwd - Repo root.
|
|
385
|
+
* @throws RebaseMergeInProgressError when rebase/merge is in progress.
|
|
386
|
+
*/
|
|
387
|
+
export function assertNoRebaseOrMerge(cwd: string): void {
|
|
388
|
+
if (isRebaseOrMergeInProgress(cwd)) {
|
|
389
|
+
throw new RebaseMergeInProgressError();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Validates a branch name: non-empty, no whitespace-only, no invalid ref characters.
|
|
395
|
+
* Use for feature/bugfix/chore/spike names; for release/hotfix validate version separately.
|
|
396
|
+
*
|
|
397
|
+
* @param name - Branch name segment (e.g. "my-feat").
|
|
398
|
+
* @throws InvalidBranchNameError when name is empty, whitespace, or contains invalid characters.
|
|
399
|
+
*/
|
|
400
|
+
export function validateBranchName(name: string): void {
|
|
401
|
+
const trimmed = name.trim();
|
|
402
|
+
if (trimmed.length === 0) {
|
|
403
|
+
throw new InvalidBranchNameError("Branch name cannot be empty or whitespace.");
|
|
404
|
+
}
|
|
405
|
+
if (INVALID_BRANCH_CHARS.test(name)) {
|
|
406
|
+
throw new InvalidBranchNameError(
|
|
407
|
+
"Branch name contains invalid characters (e.g. .., ~, ^, ?, *, [, ], :, \\, space)."
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Fetches from the given remote so refs are up to date (e.g. before checking if base exists).
|
|
414
|
+
*
|
|
415
|
+
* @param cwd - Repo root.
|
|
416
|
+
* @param remote - Remote name (e.g. "origin").
|
|
417
|
+
* @param options - dryRun, verbose.
|
|
418
|
+
* @returns Exit code from git fetch.
|
|
419
|
+
*/
|
|
420
|
+
export async function fetch(
|
|
421
|
+
cwd: string,
|
|
422
|
+
remote: string,
|
|
423
|
+
options: GitRunOptions = {}
|
|
424
|
+
): Promise<number> {
|
|
425
|
+
const result = await runGit(["fetch", remote], { cwd, ...options });
|
|
426
|
+
return result.exitCode;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Returns the default remote name from config; the git layer does not read config.
|
|
431
|
+
* Callers should use resolveConfig().remote. This helper exists for documentation;
|
|
432
|
+
* for "remote name" the CLI uses config. For listing remote refs, use branchList with includeRemote
|
|
433
|
+
* or run git ls-remote. Exposed for completeness: getRemoteRef checks if a ref exists on remote.
|
|
434
|
+
*
|
|
435
|
+
* @param cwd - Repo root.
|
|
436
|
+
* @param remote - Remote name.
|
|
437
|
+
* @param ref - Branch name on remote (e.g. "main").
|
|
438
|
+
* @param options - dryRun, verbose.
|
|
439
|
+
* @returns True if remote has that ref.
|
|
440
|
+
*/
|
|
441
|
+
export async function hasRemoteRef(
|
|
442
|
+
cwd: string,
|
|
443
|
+
remote: string,
|
|
444
|
+
ref: string,
|
|
445
|
+
options: GitRunOptions = {}
|
|
446
|
+
): Promise<boolean> {
|
|
447
|
+
const result = await runGit(["ls-remote", "--exit-code", remote, ref], {
|
|
448
|
+
cwd,
|
|
449
|
+
...options,
|
|
450
|
+
});
|
|
451
|
+
return result.exitCode === 0 && result.stdout.trim().length > 0;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Returns how many commits headRef is ahead of and behind baseRef (symmetric difference).
|
|
456
|
+
* Uses `git rev-list --left-right --count base...head`; first number is behind, second is ahead.
|
|
457
|
+
*
|
|
458
|
+
* @param cwd - Repo root.
|
|
459
|
+
* @param baseRef - Base ref (e.g. "dev" or "main").
|
|
460
|
+
* @param headRef - Head ref (e.g. current branch).
|
|
461
|
+
* @param options - dryRun, verbose.
|
|
462
|
+
* @returns { ahead, behind } counts; both 0 if refs are the same or on error.
|
|
463
|
+
*/
|
|
464
|
+
export async function getAheadBehind(
|
|
465
|
+
cwd: string,
|
|
466
|
+
baseRef: string,
|
|
467
|
+
headRef: string,
|
|
468
|
+
options: GitRunOptions = {}
|
|
469
|
+
): Promise<{ ahead: number; behind: number }> {
|
|
470
|
+
const result = await runGit(
|
|
471
|
+
["rev-list", "--left-right", "--count", `${baseRef}...${headRef}`],
|
|
472
|
+
{ cwd, ...options }
|
|
473
|
+
);
|
|
474
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
475
|
+
return { ahead: 0, behind: 0 };
|
|
476
|
+
}
|
|
477
|
+
const parts = result.stdout.trim().split(/\s+/);
|
|
478
|
+
const behind = Math.max(0, parseInt(parts[0] ?? "0", 10) || 0);
|
|
479
|
+
const ahead = Math.max(0, parseInt(parts[1] ?? "0", 10) || 0);
|
|
480
|
+
return { ahead, behind };
|
|
481
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library entry for gflows. Re-exports types, config, git helpers, and errors
|
|
3
|
+
* for programmatic use. The CLI is invoked via the `gflows` binary (see package.json bin).
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
BranchType,
|
|
9
|
+
BranchTypeBase,
|
|
10
|
+
BranchPrefixes,
|
|
11
|
+
BumpDirection,
|
|
12
|
+
BumpType,
|
|
13
|
+
Command,
|
|
14
|
+
GflowsConfigFile,
|
|
15
|
+
MergeTarget,
|
|
16
|
+
ParsedArgs,
|
|
17
|
+
ResolvedConfig,
|
|
18
|
+
BranchTypeMeta,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
export { BRANCH_TYPE_SHORTS } from "./types.js";
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
readConfigFile,
|
|
24
|
+
resolveConfig,
|
|
25
|
+
getPrefixForType,
|
|
26
|
+
getBranchTypeMeta,
|
|
27
|
+
getEnvConfigOverrides,
|
|
28
|
+
} from "./config.js";
|
|
29
|
+
export type {
|
|
30
|
+
ConfigCliOverrides,
|
|
31
|
+
ReadConfigResult,
|
|
32
|
+
ResolveConfigOptions,
|
|
33
|
+
} from "./config.js";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
runGit,
|
|
37
|
+
resolveRepoRoot,
|
|
38
|
+
ensureGitRepo,
|
|
39
|
+
revParse,
|
|
40
|
+
branchList,
|
|
41
|
+
checkout,
|
|
42
|
+
merge,
|
|
43
|
+
push,
|
|
44
|
+
tag,
|
|
45
|
+
tagExists,
|
|
46
|
+
deleteBranch,
|
|
47
|
+
isClean,
|
|
48
|
+
getCurrentBranch,
|
|
49
|
+
isDetachedHead,
|
|
50
|
+
isRebaseOrMergeInProgress,
|
|
51
|
+
assertNotDetached,
|
|
52
|
+
assertNoRebaseOrMerge,
|
|
53
|
+
validateBranchName,
|
|
54
|
+
fetch,
|
|
55
|
+
hasRemoteRef,
|
|
56
|
+
getAheadBehind,
|
|
57
|
+
} from "./git.js";
|
|
58
|
+
export type { GitOptions, GitRunOptions, GitRunResult } from "./git.js";
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
GflowsError,
|
|
62
|
+
NotRepoError,
|
|
63
|
+
BranchNotFoundError,
|
|
64
|
+
DirtyWorkingTreeError,
|
|
65
|
+
DetachedHeadError,
|
|
66
|
+
RebaseMergeInProgressError,
|
|
67
|
+
MergeConflictError,
|
|
68
|
+
InvalidVersionError,
|
|
69
|
+
InvalidBranchNameError,
|
|
70
|
+
CannotDeleteMainOrDevError,
|
|
71
|
+
exitCodeForError,
|
|
72
|
+
} from "./errors.js";
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
EXIT_OK,
|
|
76
|
+
EXIT_USER,
|
|
77
|
+
EXIT_GIT,
|
|
78
|
+
DEFAULT_MAIN,
|
|
79
|
+
DEFAULT_DEV,
|
|
80
|
+
DEFAULT_REMOTE,
|
|
81
|
+
DEFAULT_PREFIXES,
|
|
82
|
+
VERSION_REGEX,
|
|
83
|
+
INVALID_BRANCH_CHARS,
|
|
84
|
+
} from "./constants.js";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the gflows CLI: branch types, parsed arguments, and config.
|
|
3
|
+
* @module types
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Supported workflow branch types (core + optional spike). */
|
|
7
|
+
export type BranchType =
|
|
8
|
+
| "feature"
|
|
9
|
+
| "bugfix"
|
|
10
|
+
| "chore"
|
|
11
|
+
| "release"
|
|
12
|
+
| "hotfix"
|
|
13
|
+
| "spike";
|
|
14
|
+
|
|
15
|
+
/** Short flag for each branch type when used as CLI type selector. */
|
|
16
|
+
export const BRANCH_TYPE_SHORTS: Record<BranchType, string> = {
|
|
17
|
+
feature: "f",
|
|
18
|
+
bugfix: "b",
|
|
19
|
+
chore: "c",
|
|
20
|
+
release: "r",
|
|
21
|
+
hotfix: "x",
|
|
22
|
+
spike: "e",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
/** Default base branch for each type (without -o main override). */
|
|
26
|
+
export type BranchTypeBase = "main" | "dev";
|
|
27
|
+
|
|
28
|
+
/** Merge target(s) for each branch type on finish. */
|
|
29
|
+
export type MergeTarget = "main" | "dev" | "main-then-dev";
|
|
30
|
+
|
|
31
|
+
/** CLI command names. */
|
|
32
|
+
export type Command =
|
|
33
|
+
| "init"
|
|
34
|
+
| "start"
|
|
35
|
+
| "finish"
|
|
36
|
+
| "switch"
|
|
37
|
+
| "delete"
|
|
38
|
+
| "list"
|
|
39
|
+
| "bump"
|
|
40
|
+
| "completion"
|
|
41
|
+
| "status"
|
|
42
|
+
| "help"
|
|
43
|
+
| "version";
|
|
44
|
+
|
|
45
|
+
/** Branch prefix overrides per type (e.g. "feature" -> "feature/"). */
|
|
46
|
+
export interface BranchPrefixes {
|
|
47
|
+
feature?: string;
|
|
48
|
+
bugfix?: string;
|
|
49
|
+
chore?: string;
|
|
50
|
+
release?: string;
|
|
51
|
+
hotfix?: string;
|
|
52
|
+
spike?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Repo config file shape (.gflows.json or package.json "gflows" key). */
|
|
56
|
+
export interface GflowsConfigFile {
|
|
57
|
+
main?: string;
|
|
58
|
+
dev?: string;
|
|
59
|
+
remote?: string;
|
|
60
|
+
prefixes?: BranchPrefixes;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolved config used by commands (all required, with defaults applied). */
|
|
64
|
+
export interface ResolvedConfig {
|
|
65
|
+
main: string;
|
|
66
|
+
dev: string;
|
|
67
|
+
remote: string;
|
|
68
|
+
prefixes: Required<BranchPrefixes>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Metadata for a branch type: base, merge target(s), whether to tag on finish. */
|
|
72
|
+
export interface BranchTypeMeta {
|
|
73
|
+
base: BranchTypeBase;
|
|
74
|
+
mergeTarget: MergeTarget;
|
|
75
|
+
tagOnFinish: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Bump direction for version command. */
|
|
79
|
+
export type BumpDirection = "up" | "down";
|
|
80
|
+
|
|
81
|
+
/** Bump type (semver segment). */
|
|
82
|
+
export type BumpType = "patch" | "minor" | "major";
|
|
83
|
+
|
|
84
|
+
/** Parsed CLI arguments after resolving command, type, name, and flags. */
|
|
85
|
+
export interface ParsedArgs {
|
|
86
|
+
command: Command;
|
|
87
|
+
/** Resolved repo path (absolute); from -C/--path or cwd. */
|
|
88
|
+
cwd: string;
|
|
89
|
+
/** Branch type (for start, finish, list). */
|
|
90
|
+
type?: BranchType;
|
|
91
|
+
/** Branch or version name (for start: branch name or e.g. v1.2.0 for release/hotfix). */
|
|
92
|
+
name?: string;
|
|
93
|
+
/** Completion shell (bash | zsh | fish). */
|
|
94
|
+
completionShell?: "bash" | "zsh" | "fish";
|
|
95
|
+
/** For delete: branch name(s) as positionals. */
|
|
96
|
+
branchNames?: string[];
|
|
97
|
+
/** Bump direction (up | down). */
|
|
98
|
+
bumpDirection?: BumpDirection;
|
|
99
|
+
/** Bump type (patch | minor | major). */
|
|
100
|
+
bumpType?: BumpType;
|
|
101
|
+
// Common flags
|
|
102
|
+
push: boolean;
|
|
103
|
+
noPush: boolean;
|
|
104
|
+
remote: string | undefined;
|
|
105
|
+
branch: string | undefined;
|
|
106
|
+
yes: boolean;
|
|
107
|
+
dryRun: boolean;
|
|
108
|
+
verbose: boolean;
|
|
109
|
+
quiet: boolean;
|
|
110
|
+
force: boolean;
|
|
111
|
+
path: string | undefined;
|
|
112
|
+
// start
|
|
113
|
+
fromMain: boolean;
|
|
114
|
+
// finish
|
|
115
|
+
noFf: boolean;
|
|
116
|
+
deleteAfterFinish: boolean;
|
|
117
|
+
noDeleteAfterFinish: boolean;
|
|
118
|
+
signTag: boolean;
|
|
119
|
+
noTag: boolean;
|
|
120
|
+
tagMessage: string | undefined;
|
|
121
|
+
message: string | undefined;
|
|
122
|
+
// list
|
|
123
|
+
includeRemote: boolean;
|
|
124
|
+
}
|