new-branch 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Teles
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,490 @@
1
+ # new-branch CLI --- Specification
2
+
3
+ ## 1. Overview
4
+
5
+ `new-branch` is a CLI tool for generating standardized Git branch names
6
+ based on a configurable pattern and optional interactive prompts.
7
+
8
+ It can be executed in two ways:
9
+
10
+ ```bash
11
+ npx new-branch
12
+ git nb
13
+ ```
14
+
15
+ ---
16
+
17
+ ## 2. Goals
18
+
19
+ - Standardize branch naming across teams.
20
+ - Provide interactive and non-interactive modes.
21
+ - Support a composable transformation pipeline (functional style).
22
+ - Ensure generated names are always valid Git references.
23
+ - Be easily extensible via custom transforms.
24
+
25
+ ---
26
+
27
+ ## 3. Usage
28
+
29
+ ### 3.1 Basic
30
+
31
+ ```bash
32
+ npx new-branch
33
+ ```
34
+
35
+ Runs in interactive mode if required fields are missing.
36
+
37
+ ---
38
+
39
+ ### 3.2 With config file
40
+
41
+ ```bash
42
+ npx new-branch --config .newbranchrc
43
+ ```
44
+
45
+ Alias:
46
+
47
+ ```bash
48
+ npx new-branch -c .newbranchrc
49
+ ```
50
+
51
+ ---
52
+
53
+ ### 3.3 With custom pattern
54
+
55
+ ```bash
56
+ npx new-branch --pattern "{type}/{title:slugify;max:25}-{id}"
57
+ ```
58
+
59
+ Alias:
60
+
61
+ ```bash
62
+ npx new-branch -p "{type}/{title:slugify;max:25}-{id}"
63
+ ```
64
+
65
+ ---
66
+
67
+ ### 3.4 Non-interactive mode
68
+
69
+ ```bash
70
+ npx new-branch --pattern "{type}/{title:slugify;max:25}-{id}" --id STK-123 --title "My very interesting task" --type feat
71
+ ```
72
+
73
+ If all required variables are provided, no prompt is shown.
74
+
75
+ ---
76
+
77
+ ## 4. Configuration Precedence
78
+
79
+ Resolution order (highest priority first):
80
+
81
+ 1. CLI flags
82
+ 2. Environment variables
83
+ 3. Config file (.newbranchrc)
84
+ 4. Defaults
85
+ 5. Interactive prompt (only if required values are missing)
86
+
87
+ ---
88
+
89
+ ## 5. Pattern Language
90
+
91
+ ### 5.1 Syntax
92
+
93
+ Pattern example:
94
+
95
+ {type}/{title:slugify;max:25}-{id}
96
+
97
+ Structure:
98
+
99
+ {variable:transform1;transform2;transformWithArg:arg}
100
+
101
+ ### 5.2 Parsing Model
102
+
103
+ Each token is parsed into:
104
+
105
+ - variable name
106
+ - ordered list of transforms
107
+ - optional arguments per transform
108
+
109
+ Example AST representation:
110
+
111
+ ```json
112
+ {
113
+ "variable": "title",
114
+ "pipeline": [{ "fn": "slugify" }, { "fn": "max", "args": [25] }]
115
+ }
116
+ ```
117
+
118
+ ---
119
+
120
+ ## 6. Built-in Variables
121
+
122
+ Variable Description
123
+
124
+ ---
125
+
126
+ type Branch type (feat, fix, etc.)
127
+ title Human-readable task title
128
+ id Task identifier (e.g., STK-123)
129
+
130
+ ---
131
+
132
+ ## 7. Built-in Transforms
133
+
134
+ All transforms must be pure functions.
135
+
136
+ ### 7.1 String Transforms
137
+
138
+ | Transform \| Description \|
139
+
140
+ \|------------\|-------------\| slugify \| Converts to URL-safe slug \|
141
+ \| lowercase \| Converts to lowercase \| \| uppercase \| Converts to
142
+ uppercase \| \| trim \| Trims whitespace \| \| titlecase \| Capitalizes
143
+ words \|
144
+
145
+ ### 7.2 Argument-based Transforms
146
+
147
+ | Transform \| Description \| Example \|
148
+
149
+ \|------------\|-------------\|---------\| max \| Truncates string to
150
+ max length \| max:25 \| \| pad \| Pads string to length \| pad:10 \|
151
+
152
+ ### 7.3 Validation Transforms
153
+
154
+ Validation transforms do not modify value but throw errors if invalid.
155
+
156
+ | Transform \| Description \|
157
+
158
+ \|------------\|-------------\| required \| Ensures value is not empty
159
+ \| \| match \| Validates via regex \|
160
+
161
+ ---
162
+
163
+ ## 8. Functional Pipeline Execution
164
+
165
+ Each variable pipeline is executed using reduce semantics:
166
+
167
+ ```js
168
+ pipeline.reduce((acc, step) => {
169
+ return transforms[step.fn](acc, ...step.args);
170
+ }, baseValue);
171
+ ```
172
+
173
+ All transforms must be registered in a dictionary:
174
+
175
+ ```js
176
+ const transforms = {
177
+ slugify,
178
+ lowercase,
179
+ uppercase,
180
+ max,
181
+ trim,
182
+ };
183
+ ```
184
+
185
+ ---
186
+
187
+ ## 9. Git Ref Sanitization
188
+
189
+ After full pattern rendering, a final sanitization step must run:
190
+
191
+ - Remove invalid Git characters
192
+ - Prevent:
193
+ - trailing slash
194
+ - double dots
195
+ - leading dash
196
+ - spaces
197
+ - Ensure valid Git ref format
198
+
199
+ Final step example:
200
+
201
+ ```js
202
+ branchName = sanitizeGitRef(branchName);
203
+ ```
204
+
205
+ ---
206
+
207
+ ## 10. Branch Type Standardization
208
+
209
+ Supported types:
210
+
211
+ - feat
212
+ - fix
213
+ - chore
214
+ - docs
215
+ - refactor
216
+ - test
217
+ - perf
218
+ - build
219
+ - ci
220
+
221
+ Optional alias mapping:
222
+
223
+ - feature → feat
224
+ - bugfix → fix
225
+
226
+ ---
227
+
228
+ ## 11. Interactive Mode Behavior
229
+
230
+ If required values are missing:
231
+
232
+ Prompt user for: - type - id - title
233
+
234
+ Validation must occur immediately after input.
235
+
236
+ ---
237
+
238
+ ## 12. Optional Flags
239
+
240
+ Flag Description
241
+
242
+ ---
243
+
244
+ --create Creates branch using `git switch -c`
245
+ --print Prints branch name only (default behavior)
246
+
247
+ ---
248
+
249
+ ## 13. Example Outputs
250
+
251
+ Input:
252
+
253
+ type = feat
254
+ title = My very interesting task
255
+ id = STK-123
256
+
257
+ Pattern:
258
+
259
+ {type}/{title:slugify;max:25}-{id}
260
+
261
+ Output:
262
+
263
+ feat/my-very-interesting-task-STK-123
264
+
265
+ ---
266
+
267
+ ## 14. Future Extensions
268
+
269
+ - Custom transform plugins
270
+ - Jira integration (auto-fetch title from ID)
271
+ - Branch existence check
272
+ - Automatic incremental suffixing
273
+ - Conventional commits integration
274
+
275
+ ---
276
+
277
+ ## 15. Summary
278
+
279
+ `new-branch` is a composable, functional, extensible CLI tool for
280
+ standardized Git branch naming.
281
+
282
+ Core principles:
283
+
284
+ - Functional pipeline transforms
285
+ - Deterministic output
286
+ - Git-safe sanitization
287
+ - Clear precedence rules
288
+ - Interactive fallback
289
+
290
+ # new-branch
291
+
292
+ A small CLI tool to generate and optionally create standardized Git branch names
293
+ based on a composable pattern language.
294
+
295
+ ---
296
+
297
+ ## Installation
298
+
299
+ Using npx:
300
+
301
+ ```bash
302
+ npx new-branch
303
+ ```
304
+
305
+ Or install globally:
306
+
307
+ ```bash
308
+ npm install -g new-branch
309
+ ```
310
+
311
+ ---
312
+
313
+ ## Usage
314
+
315
+ ### Generate a branch name
316
+
317
+ ```bash
318
+ new-branch \
319
+ --pattern "{type}/{title:slugify}-{id}" \
320
+ --type feat \
321
+ --title "My task" \
322
+ --id STK-123
323
+ ```
324
+
325
+ Output:
326
+
327
+ ```
328
+ feat/minha-tarefa-STK-123
329
+ ```
330
+
331
+ ---
332
+
333
+ ### Create the branch automatically
334
+
335
+ ```bash
336
+ new-branch \
337
+ --pattern "{type:upper}/{title:slugify}-{id}" \
338
+ --type feat \
339
+ --title "My task" \
340
+ --id STK-123 \
341
+ --create
342
+ ```
343
+
344
+ This runs:
345
+
346
+ ```
347
+ git switch -c <generated-branch>
348
+ ```
349
+
350
+ ---
351
+
352
+ ### Interactive mode
353
+
354
+ If required variables in the pattern are missing, the CLI will prompt for them.
355
+
356
+ Example:
357
+
358
+ ```bash
359
+ new-branch \
360
+ --pattern "{type:upper}/{title:slugify}-{id}" \
361
+ --title "My task" \
362
+ --id STK-123 \
363
+ --create
364
+ ```
365
+
366
+ You will be prompted for `type`.
367
+
368
+ ---
369
+
370
+ ## CLI Options
371
+
372
+ | Option | Description |
373
+ | ------------------------- | -------------------------------------------- |
374
+ | `-p, --pattern <pattern>` | Branch name pattern |
375
+ | `--id <id>` | Task ID |
376
+ | `--title <title>` | Task title |
377
+ | `--type <type>` | Branch type |
378
+ | `--create` | Create the branch using `git switch -c` |
379
+ | `--no-prompt` | Fail instead of prompting for missing values |
380
+ | `--quiet` | Do not print any output |
381
+
382
+ ---
383
+
384
+ ## Pattern Language
385
+
386
+ Branch names are generated from a pattern string.
387
+
388
+ Example:
389
+
390
+ ```
391
+ {type}/{title:slugify;max:25}-{id}
392
+ ```
393
+
394
+ ### Syntax
395
+
396
+ ```
397
+ {variable:transform1;transform2:arg}
398
+ ```
399
+
400
+ - Variables are wrapped in `{}`
401
+ - Transforms are separated by `;`
402
+ - Transform arguments are separated by `:`
403
+
404
+ ---
405
+
406
+ ## Built-in Variables
407
+
408
+ These variables are currently supported:
409
+
410
+ - `type`
411
+ - `title`
412
+ - `id`
413
+
414
+ ---
415
+
416
+ ## Built-in Transforms
417
+
418
+ All transforms are pure functions and executed left-to-right.
419
+
420
+ ### String transforms
421
+
422
+ | Transform | Description |
423
+ | --------- | --------------------------- |
424
+ | `lower` | Lowercases the value |
425
+ | `upper` | Uppercases the value |
426
+ | `slugify` | Converts to a git-safe slug |
427
+
428
+ ### Argument-based transforms
429
+
430
+ | Transform | Description | Example |
431
+ | --------- | ------------------------------ | -------- |
432
+ | `max` | Truncates string to max length | `max:25` |
433
+
434
+ Example:
435
+
436
+ ```
437
+ {title:slugify;max:25}
438
+ ```
439
+
440
+ ---
441
+
442
+ ## Git Ref Validation
443
+
444
+ After rendering, branch names are:
445
+
446
+ 1. Lightly sanitized
447
+ 2. Validated using `git check-ref-format --branch`
448
+
449
+ If invalid, the command fails.
450
+
451
+ ---
452
+
453
+ ## Example
454
+
455
+ Input:
456
+
457
+ ```
458
+ type = feat
459
+ title = My task
460
+ id = STK-123
461
+ ```
462
+
463
+ Pattern:
464
+
465
+ ```
466
+ {type}/{title:slugify;max:25}-{id}
467
+ ```
468
+
469
+ Output:
470
+
471
+ ```
472
+ feat/my-task-STK-123
473
+ ```
474
+
475
+ ---
476
+
477
+ ## Roadmap
478
+
479
+ Planned future features:
480
+
481
+ - Config file support
482
+ - Custom transform plugins
483
+ - Additional variable providers (e.g. git username, date)
484
+ - Branch existence checks
485
+
486
+ ---
487
+
488
+ ## License
489
+
490
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "./parseArgs.js";
3
+ import { parsePattern } from "./pattern/parsePattern.js";
4
+ import { defaultTransforms } from "./pattern/transforms/index.js";
5
+ import { renderPattern } from "./pattern/transforms/renderPattern.js";
6
+ import { resolveMissingValues } from "./runtime/resolveMissingValues.js";
7
+ import { sanitizeGitRef } from "./git/sanitizeGitRef.js";
8
+ import { validateBranchName } from "./git/validateBranchName.js";
9
+ import { createBranch } from "./git/createBranch.js";
10
+ const ok = (value) => ({ ok: true, value });
11
+ const err = (error) => ({ ok: false, error });
12
+ const isOk = (r) => r.ok;
13
+ function fail(msg, e) {
14
+ console.error(`\n❌ ${msg}`);
15
+ if (e)
16
+ console.error(e);
17
+ process.exit(1);
18
+ }
19
+ function safe(fn) {
20
+ try {
21
+ return ok(fn());
22
+ }
23
+ catch (e) {
24
+ return err(e);
25
+ }
26
+ }
27
+ async function safeAsync(fn) {
28
+ try {
29
+ return ok(await fn());
30
+ }
31
+ catch (e) {
32
+ return err(e);
33
+ }
34
+ }
35
+ function requirePattern(pattern) {
36
+ if (!pattern || pattern.trim().length === 0) {
37
+ return err(new Error("Pattern is required. Use --pattern to specify it."));
38
+ }
39
+ return ok(pattern);
40
+ }
41
+ function toInitialValues(args) {
42
+ return {
43
+ id: args.options.id,
44
+ title: args.options.title,
45
+ type: args.options.type,
46
+ };
47
+ }
48
+ async function run() {
49
+ // Step 0: args/options
50
+ const args = parseArgs(process.argv);
51
+ if (args.options.help) {
52
+ return;
53
+ }
54
+ const quiet = args.options.quiet === true;
55
+ const create = args.options.create === true;
56
+ const prompt = args.options.prompt !== false;
57
+ // Pipeline: pattern -> AST -> resolve values -> render -> sanitize -> validate -> (optional) git -> output
58
+ const patternRes = requirePattern(args.options.pattern);
59
+ if (!isOk(patternRes))
60
+ fail("Invalid CLI arguments.", patternRes.error);
61
+ const astRes = safe(() => parsePattern(patternRes.value));
62
+ if (!isOk(astRes))
63
+ fail("Invalid pattern.", astRes.error);
64
+ const initialValues = toInitialValues(args);
65
+ const valuesRes = await safeAsync(() => resolveMissingValues(astRes.value, initialValues, {
66
+ prompt,
67
+ }));
68
+ if (!isOk(valuesRes))
69
+ fail("Failed to resolve required values.", valuesRes.error);
70
+ const renderedRes = safe(() => renderPattern(astRes.value, valuesRes.value, {
71
+ transforms: defaultTransforms,
72
+ strict: true,
73
+ }));
74
+ if (!isOk(renderedRes))
75
+ fail("Failed to render branch name.", renderedRes.error);
76
+ const sanitized = sanitizeGitRef(renderedRes.value);
77
+ const validateRes = await safeAsync(() => validateBranchName(sanitized));
78
+ if (!isOk(validateRes))
79
+ fail("Branch name is not valid for git.", validateRes.error);
80
+ const ctx = {
81
+ quiet,
82
+ create,
83
+ prompt,
84
+ pattern: patternRes.value,
85
+ ast: astRes.value,
86
+ values: valuesRes.value,
87
+ branchName: sanitized,
88
+ };
89
+ const createRes = create ? await safeAsync(() => createBranch(ctx.branchName)) : ok(undefined);
90
+ if (!isOk(createRes))
91
+ fail("Failed to create branch.", createRes.error);
92
+ if (!ctx.quiet) {
93
+ if (ctx.create) {
94
+ console.log(`\n✅ Branch created and switched to: ${ctx.branchName}`);
95
+ }
96
+ else {
97
+ console.log(ctx.branchName);
98
+ }
99
+ }
100
+ }
101
+ run().catch((e) => fail("Unexpected error in CLI.", e));
@@ -0,0 +1,25 @@
1
+ import { execa } from "execa";
2
+ /**
3
+ * Creates and switches to a new Git branch.
4
+ *
5
+ * Strategy:
6
+ * 1. Try `git switch -c <name>` (modern Git ≥ 2.23).
7
+ * 2. Fallback to `git checkout -b <name>` if switch is unavailable.
8
+ *
9
+ * @throws Error if branch creation fails.
10
+ */
11
+ export async function createBranch(name) {
12
+ try {
13
+ await execa("git", ["switch", "-c", name]);
14
+ return;
15
+ }
16
+ catch {
17
+ try {
18
+ await execa("git", ["checkout", "-b", name]);
19
+ return;
20
+ }
21
+ catch {
22
+ throw new Error(`Failed to create branch "${name}"`);
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Performs a lightweight sanitization before delegating
3
+ * full validation to Git.
4
+ *
5
+ * This does NOT try to fully reimplement Git rules.
6
+ * Final validation must be done via:
7
+ * git check-ref-format --branch
8
+ */
9
+ export function sanitizeGitRef(input) {
10
+ let name = input.trim();
11
+ // Replace whitespace with "-"
12
+ name = name.replace(/\s+/g, "-");
13
+ // Remove characters Git never allows in refs
14
+ // ~ ^ : ? * [ \ and ASCII control chars
15
+ name = name.replace(/[~^:?*[\]\\]/g, "");
16
+ // name = name.replace(/[\u0000-\u001F\u007F]/g, "");
17
+ // Remove occurrences of "@{"
18
+ name = name.replace(/@\{/g, "");
19
+ // Collapse multiple slashes
20
+ name = name.replace(/\/+/g, "/");
21
+ // Remove repeated dots
22
+ name = name.replace(/\.\.+/g, ".");
23
+ // Prevent leading dash or slash
24
+ name = name.replace(/^[-/]+/, "");
25
+ // Prevent trailing slash or dot
26
+ name = name.replace(/[/.]+$/, "");
27
+ // Prevent ending with ".lock"
28
+ if (name.endsWith(".lock")) {
29
+ name = name.slice(0, -5);
30
+ }
31
+ return name;
32
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { sanitizeGitRef } from "./sanitizeGitRef.js";
3
+ describe("sanitizeGitRef", () => {
4
+ it("trims input and replaces whitespace with dashes", () => {
5
+ expect(sanitizeGitRef(" feature new ")).toBe("feature-new");
6
+ });
7
+ it("removes forbidden characters and sequences", () => {
8
+ const input = "~^:??*[\\]name@{weird}";
9
+ // forbidden chars removed and @{ removed
10
+ expect(sanitizeGitRef(input)).toBe("nameweird}");
11
+ });
12
+ it("collapses multiple slashes and removes leading/trailing slashes", () => {
13
+ expect(sanitizeGitRef("///a//b/c///")).toBe("a/b/c");
14
+ });
15
+ it("collapses repeated dots and trims trailing dots", () => {
16
+ expect(sanitizeGitRef("v1..0...")).toBe("v1.0");
17
+ });
18
+ it("prevents leading dash or slash and trailing dot or slash", () => {
19
+ expect(sanitizeGitRef("-/--abc/.")).toBe("abc");
20
+ });
21
+ it("removes trailing .lock suffix", () => {
22
+ expect(sanitizeGitRef("release.lock")).toBe("release");
23
+ // multiple .lock occurrences only remove final suffix
24
+ expect(sanitizeGitRef("a.lock.lock")).toBe("a.lock");
25
+ });
26
+ it("works with an empty-ish result (keeps dot/punctuations according to sanitizer)", () => {
27
+ // Implementation collapses dots and slashes but does not remove the leading dot
28
+ expect(sanitizeGitRef(" ....///--- ")).toBe("./---");
29
+ });
30
+ });
@@ -0,0 +1,17 @@
1
+ import { execa } from "execa";
2
+ /**
3
+ * Validates a branch name by delegating to Git itself.
4
+ *
5
+ * Uses:
6
+ * git check-ref-format --branch <name>
7
+ *
8
+ * Throws if invalid.
9
+ */
10
+ export async function validateBranchName(name) {
11
+ try {
12
+ await execa("git", ["check-ref-format", "--branch", name]);
13
+ }
14
+ catch {
15
+ throw new Error(`Invalid git branch name: "${name}"`);
16
+ }
17
+ }
@@ -0,0 +1,37 @@
1
+ import { cac } from "cac";
2
+ function stripDoubleDash(argv) {
3
+ const idx = argv.indexOf("--");
4
+ if (idx === -1)
5
+ return [...argv];
6
+ // keep node + script, remove the "--" separator and retain the rest
7
+ return [...argv.slice(0, idx), ...argv.slice(idx + 1)];
8
+ }
9
+ export function parseArgs(argv = process.argv) {
10
+ const cli = cac("new-branch");
11
+ cli
12
+ .option("-p, --pattern <pattern>", "Branch pattern")
13
+ .option("--id <id>", "Task id")
14
+ .option("--title <title>", "Task title")
15
+ .option("--type <type>", "Branch type")
16
+ .option("--create", "Create branch")
17
+ .option("--no-prompt", "Fail instead of prompting for missing values")
18
+ .option("--quiet", "Suppress non-essential output")
19
+ .help();
20
+ const cleaned = stripDoubleDash(argv);
21
+ const parsed = cli.parse(cleaned);
22
+ const opts = parsed.options;
23
+ const options = {
24
+ pattern: typeof opts.pattern === "string" ? opts.pattern : undefined,
25
+ id: typeof opts.id === "string" ? opts.id : undefined,
26
+ title: typeof opts.title === "string" ? opts.title : undefined,
27
+ type: typeof opts.type === "string" ? opts.type : undefined,
28
+ create: typeof opts.create === "boolean" ? opts.create : undefined,
29
+ prompt: typeof opts.prompt === "boolean" ? opts.prompt : undefined,
30
+ quiet: typeof opts.quiet === "boolean" ? opts.quiet : undefined,
31
+ help: typeof opts.help === "boolean" ? opts.help : undefined,
32
+ };
33
+ return {
34
+ options,
35
+ args: parsed.args,
36
+ };
37
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseArgs } from "./parseArgs.js";
3
+ describe("parseArgs", () => {
4
+ it("parses long options", () => {
5
+ const argv = [
6
+ "node",
7
+ "cli",
8
+ "--pattern",
9
+ "{type}/{title}-{id}",
10
+ "--id",
11
+ "STK-123",
12
+ "--title",
13
+ "Minha tarefa",
14
+ "--type",
15
+ "feat",
16
+ "--create",
17
+ ];
18
+ const res = parseArgs(argv);
19
+ expect(res.options.pattern).toBe("{type}/{title}-{id}");
20
+ expect(res.options.id).toBe("STK-123");
21
+ expect(res.options.title).toBe("Minha tarefa");
22
+ expect(res.options.type).toBe("feat");
23
+ expect(res.options.create).toBe(true);
24
+ expect(res.args).toEqual([]);
25
+ });
26
+ it("parses short -p as pattern", () => {
27
+ const argv = ["node", "cli", "-p", "{type}/{title}-{id}"];
28
+ const res = parseArgs(argv);
29
+ expect(res.options.pattern).toBe("{type}/{title}-{id}");
30
+ });
31
+ it("strips tsx double-dash separator", () => {
32
+ const argv = [
33
+ "node",
34
+ "cli",
35
+ "--",
36
+ "--pattern",
37
+ "{type}/{title}-{id}",
38
+ "--id",
39
+ "STK-123",
40
+ ];
41
+ const res = parseArgs(argv);
42
+ expect(res.options.pattern).toBe("{type}/{title}-{id}");
43
+ expect(res.options.id).toBe("STK-123");
44
+ });
45
+ it("supports --no-prompt", () => {
46
+ const argv = ["node", "cli", "--no-prompt"];
47
+ const res = parseArgs(argv);
48
+ expect(res.options.prompt).toBe(false);
49
+ });
50
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Parses a branch name pattern string and produces an abstract syntax tree (AST)
3
+ * representation along with the list of variables used.
4
+ *
5
+ * @remarks
6
+ * ## Pattern Grammar (v1)
7
+ *
8
+ * - **Literals**: Any text outside of `{}` is treated as a literal segment.
9
+ * - **Variables**: Declared inside curly braces, e.g. `{name}`.
10
+ * - **Transforms**: Optional transforms can be applied using `:` and `;`:
11
+ * - `{name:transform}`
12
+ * - `{name:transform;transform:arg}`
13
+ * - **Transform arguments**: Separated by `:`, for example:
14
+ * - `{title:max:25}`
15
+ * - `{title:slugify;max:25}`
16
+ *
17
+ * This parser does **not** validate transform names or arguments.
18
+ * Validation should be handled in a later stage of the pipeline.
19
+ *
20
+ * @param input - The raw pattern string to parse.
21
+ *
22
+ * @returns An object containing:
23
+ * - `nodes`: An ordered list of parsed pattern nodes (literals and variables).
24
+ * - `variablesUsed`: A deduplicated list of variable names in appearance order.
25
+ *
26
+ * @throws Error
27
+ * - If a variable block is missing a closing `}`.
28
+ * - If a `{` is found inside a variable block.
29
+ * - If a variable block is empty (`{}`).
30
+ * - If a variable name is missing.
31
+ *
32
+ * @example
33
+ * Basic usage:
34
+ *
35
+ * ```ts
36
+ * parsePattern("{type}/{title}-{id}");
37
+ * ```
38
+ *
39
+ * @example
40
+ * With transforms:
41
+ *
42
+ * ```ts
43
+ * parsePattern("{type}/{title:slugify;max:25}-{id}");
44
+ * ```
45
+ *
46
+ * @example
47
+ * Returned structure:
48
+ *
49
+ * ```ts
50
+ * {
51
+ * nodes: [
52
+ * { kind: "variable", name: "type", transforms: [] },
53
+ * { kind: "literal", value: "/" },
54
+ * {
55
+ * kind: "variable",
56
+ * name: "title",
57
+ * transforms: [
58
+ * { name: "slugify", args: [] },
59
+ * { name: "max", args: ["25"] }
60
+ * ]
61
+ * },
62
+ * { kind: "literal", value: "-" },
63
+ * { kind: "variable", name: "id", transforms: [] }
64
+ * ],
65
+ * variablesUsed: ["type", "title", "id"]
66
+ * }
67
+ * ```
68
+ */
69
+ export function parsePattern(input) {
70
+ const nodes = [];
71
+ const variablesUsed = [];
72
+ let i = 0;
73
+ let literalBuffer = "";
74
+ const flushLiteral = () => {
75
+ if (literalBuffer.length > 0) {
76
+ nodes.push({ kind: "literal", value: literalBuffer });
77
+ literalBuffer = "";
78
+ }
79
+ };
80
+ while (i < input.length) {
81
+ const ch = input[i];
82
+ if (ch !== "{") {
83
+ literalBuffer += ch;
84
+ i += 1;
85
+ continue;
86
+ }
87
+ // We found "{": start of a variable block.
88
+ flushLiteral();
89
+ const end = input.indexOf("}", i + 1);
90
+ if (end === -1) {
91
+ throw new Error(`Invalid pattern: missing "}" (at index ${i})`);
92
+ }
93
+ const inside = input.slice(i + 1, end).trim();
94
+ if (inside.includes("{")) {
95
+ throw new Error(`Invalid pattern: unexpected "{" inside "{}" (at index ${i})`);
96
+ }
97
+ if (!inside) {
98
+ throw new Error(`Invalid pattern: empty "{}" (at index ${i})`);
99
+ }
100
+ const [rawName, ...rest] = inside.split(":");
101
+ const name = rawName.trim();
102
+ if (!name) {
103
+ throw new Error(`Invalid pattern: missing variable name (at index ${i})`);
104
+ }
105
+ // If there was ":" in the inside, everything after the first ":" is the transform section.
106
+ const transformSection = rest.length > 0 ? rest.join(":").trim() : "";
107
+ const transforms = transformSection
108
+ ? transformSection
109
+ .split(";")
110
+ .map((s) => s.trim())
111
+ .filter(Boolean)
112
+ .map(parseTransform)
113
+ : [];
114
+ nodes.push({ kind: "variable", name, transforms });
115
+ variablesUsed.push(name);
116
+ i = end + 1;
117
+ }
118
+ flushLiteral();
119
+ return {
120
+ nodes,
121
+ variablesUsed: uniquePreserveOrder(variablesUsed),
122
+ };
123
+ }
124
+ function parseTransform(segment) {
125
+ // segment examples:
126
+ // - "slugify"
127
+ // - "max:25"
128
+ // - "replace:_:-" (args are ["_", "-"]) (still v1, no quoting rules yet)
129
+ const parts = segment.split(":").map((p) => p.trim());
130
+ const name = parts[0];
131
+ const args = parts.slice(1);
132
+ if (!name) {
133
+ throw new Error(`Invalid transform: "${segment}"`);
134
+ }
135
+ return { name, args };
136
+ }
137
+ function uniquePreserveOrder(items) {
138
+ const seen = new Set();
139
+ const out = [];
140
+ for (const it of items) {
141
+ if (!seen.has(it)) {
142
+ seen.add(it);
143
+ out.push(it);
144
+ }
145
+ }
146
+ return out;
147
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parsePattern } from "./parsePattern.js";
3
+ describe("parsePattern", () => {
4
+ it("parses literals + variables + transforms", () => {
5
+ const res = parsePattern("{type}/{title:slugify;max:25}-{id}");
6
+ expect(res.variablesUsed).toEqual(["type", "title", "id"]);
7
+ expect(res.nodes).toEqual([
8
+ { kind: "variable", name: "type", transforms: [] },
9
+ { kind: "literal", value: "/" },
10
+ {
11
+ kind: "variable",
12
+ name: "title",
13
+ transforms: [
14
+ { name: "slugify", args: [] },
15
+ { name: "max", args: ["25"] },
16
+ ],
17
+ },
18
+ { kind: "literal", value: "-" },
19
+ { kind: "variable", name: "id", transforms: [] },
20
+ ]);
21
+ });
22
+ it("throws on missing closing brace", () => {
23
+ expect(() => parsePattern("{type/{id}")).toThrow(/missing "}"|Invalid pattern/);
24
+ });
25
+ });
@@ -0,0 +1,16 @@
1
+ import { lower } from "../../pattern/transforms/lower.js";
2
+ import { upper } from "../../pattern/transforms/upper.js";
3
+ import { max } from "../../pattern/transforms/max.js";
4
+ // import { replace } from "./replace.js";
5
+ import { slugify } from "./slugify.js";
6
+ export const allTransforms = [lower, upper, max, slugify];
7
+ export function buildRegistry(defs) {
8
+ const registry = {};
9
+ for (const d of defs) {
10
+ if (registry[d.name])
11
+ throw new Error(`Duplicate transform name: "${d.name}"`);
12
+ registry[d.name] = d.fn;
13
+ }
14
+ return registry;
15
+ }
16
+ export const defaultTransforms = buildRegistry(allTransforms);
@@ -0,0 +1,8 @@
1
+ export const lower = {
2
+ name: "lower",
3
+ fn: (value) => value.toLowerCase(),
4
+ doc: {
5
+ summary: "Lowercases the value.",
6
+ usage: ["{name:lower}"],
7
+ },
8
+ };
@@ -0,0 +1,10 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { lower } from "./lower.js";
3
+ describe("lower transform", () => {
4
+ it("converts to lowercase", () => {
5
+ expect(lower.fn("Hello WORLD", [])).toBe("hello world");
6
+ });
7
+ it("keeps non-letter characters", () => {
8
+ expect(lower.fn("123-ÁÉ!", [])).toBe("123-áé!");
9
+ });
10
+ });
@@ -0,0 +1,13 @@
1
+ export const max = {
2
+ name: "max",
3
+ fn: (value, [n]) => {
4
+ const size = Number(n);
5
+ if (!Number.isFinite(size) || size < 0)
6
+ throw new Error(`max expects a non-negative number, got "${n ?? ""}"`);
7
+ return value.slice(0, size);
8
+ },
9
+ doc: {
10
+ summary: "Truncates the value to a maximum length.",
11
+ usage: ["{name:max:25}"],
12
+ },
13
+ };
@@ -0,0 +1,13 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { max } from "./max.js";
3
+ describe("max transform", () => {
4
+ it("truncates the value to the given length", () => {
5
+ expect(max.fn("abcdef", ["3"])).toBe("abc");
6
+ expect(max.fn("short", ["10"])).toBe("short");
7
+ });
8
+ it("throws on negative or non-finite sizes", () => {
9
+ expect(() => max.fn("abc", ["-1"])).toThrow();
10
+ expect(() => max.fn("abc", ["not-a-number"])).toThrow();
11
+ expect(() => max.fn("abc", [])).toThrow();
12
+ });
13
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Renders a parsed pattern AST into its final string representation.
3
+ *
4
+ * @param parsed - The parsed pattern AST.
5
+ * @param values - A map of variable values.
6
+ * @param opts - Rendering options including transform registry.
7
+ *
8
+ * @returns The rendered string.
9
+ *
10
+ * @throws Error If a required variable is missing in strict mode.
11
+ * @throws Error If an unknown transform is encountered.
12
+ */
13
+ export function renderPattern(parsed, values, opts) {
14
+ const strict = opts.strict ?? true;
15
+ return parsed.nodes.map((node) => renderNode(node, values, opts.transforms, strict)).join("");
16
+ }
17
+ function renderNode(node, values, transforms, strict) {
18
+ if (node.kind === "literal") {
19
+ return node.value;
20
+ }
21
+ const raw = values[node.name];
22
+ if (raw == null) {
23
+ if (strict) {
24
+ throw new Error(`Missing value for "{${node.name}}"`);
25
+ }
26
+ return "";
27
+ }
28
+ let result = raw;
29
+ for (const transform of node.transforms) {
30
+ const fn = transforms[transform.name];
31
+ if (!fn) {
32
+ throw new Error(`Unknown transform "${transform.name}" on "{${node.name}}"`);
33
+ }
34
+ result = fn(result, transform.args);
35
+ }
36
+ return result;
37
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderPattern } from "./renderPattern.js";
3
+ import { parsePattern } from "../parsePattern.js";
4
+ import { lower } from "./lower.js";
5
+ import { upper } from "./upper.js";
6
+ import { max } from "./max.js";
7
+ import { slugify } from "./slugify.js";
8
+ const defaultTransforms = {
9
+ [lower.name]: lower.fn,
10
+ [upper.name]: upper.fn,
11
+ [max.name]: max.fn,
12
+ [slugify.name]: slugify.fn,
13
+ };
14
+ describe("renderPattern", () => {
15
+ it("renders literals and variables without transforms", () => {
16
+ const parsed = parsePattern("{type}/{title}-{id}");
17
+ const out = renderPattern(parsed, { type: "feat", title: "My Task", id: "123" }, { transforms: defaultTransforms });
18
+ expect(out).toBe("feat/My Task-123");
19
+ });
20
+ it("applies transforms in order (slugify then max)", () => {
21
+ const parsed = parsePattern("{title:slugify;max:5}");
22
+ const out = renderPattern(parsed, { title: "Título grande" }, { transforms: defaultTransforms });
23
+ // slugify -> "titulo-grande" then max:5 -> "titul"
24
+ expect(out).toBe("titul");
25
+ });
26
+ it("throws when an unknown transform is used", () => {
27
+ const parsed = {
28
+ nodes: [{ kind: "variable", name: "foo", transforms: [{ name: "nope", args: [] }] }],
29
+ variablesUsed: ["foo"],
30
+ };
31
+ expect(() => renderPattern(parsed, { foo: "bar" }, { transforms: defaultTransforms })).toThrow(/Unknown transform/i);
32
+ });
33
+ it("throws on missing variable in strict mode (default)", () => {
34
+ const parsed = parsePattern("{id}");
35
+ expect(() => renderPattern(parsed, {}, { transforms: defaultTransforms })).toThrow(/Missing value/);
36
+ });
37
+ it("returns empty for missing variable when strict is false", () => {
38
+ const parsed = parsePattern("pre-{id}-post");
39
+ const out = renderPattern(parsed, {}, { transforms: defaultTransforms, strict: false });
40
+ expect(out).toBe("pre--post");
41
+ });
42
+ it("propagates transform errors (invalid max arg)", () => {
43
+ const parsed = parsePattern("{title:max:-1}");
44
+ expect(() => renderPattern(parsed, { title: "abc" }, { transforms: defaultTransforms })).toThrow();
45
+ });
46
+ });
@@ -0,0 +1,14 @@
1
+ export const slugify = {
2
+ name: "slugify",
3
+ fn: (value) => value
4
+ .normalize("NFKD")
5
+ .replace(/[\u0300-\u036f]/g, "")
6
+ .toLowerCase()
7
+ .replace(/[^a-z0-9]+/g, "-")
8
+ .replace(/-+/g, "-")
9
+ .replace(/^-|-$/g, ""),
10
+ doc: {
11
+ summary: "Slugifies to a git-friendly format.",
12
+ usage: ["{title:slugify}"],
13
+ },
14
+ };
@@ -0,0 +1,14 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { slugify } from "./slugify.js";
3
+ describe("slugify transform", () => {
4
+ it("removes accents, lowercases and replaces non-alphanum with dashes", () => {
5
+ const input = "Título com Acentos & símbolos!";
6
+ const out = slugify.fn(input, []);
7
+ expect(out).toBe("titulo-com-acentos-simbolos");
8
+ });
9
+ it("collapses multiple separators into one and trims dashes", () => {
10
+ const input = " --Hello___World-- ";
11
+ const out = slugify.fn(input, []);
12
+ expect(out).toBe("hello-world");
13
+ });
14
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ export const upper = {
2
+ name: "upper",
3
+ fn: (value) => value.toUpperCase(),
4
+ doc: {
5
+ summary: "Uppercases the value.",
6
+ usage: ["{name:upper}"],
7
+ },
8
+ };
@@ -0,0 +1,10 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { upper } from "./upper.js";
3
+ describe("upper transform", () => {
4
+ it("converts to uppercase", () => {
5
+ expect(upper.fn("Hello world", [])).toBe("HELLO WORLD");
6
+ });
7
+ it("keeps non-letter characters", () => {
8
+ expect(upper.fn("123-áé!", [])).toBe("123-ÁÉ!");
9
+ });
10
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { input } from "@inquirer/prompts";
2
+ /**
3
+ * Resolves missing variable values required by the pattern.
4
+ *
5
+ * Rule (v1):
6
+ * - Any variable used in the pattern is considered required.
7
+ */
8
+ export async function resolveMissingValues(parsed, initialValues, opts) {
9
+ const requiredVars = parsed.variablesUsed;
10
+ const values = { ...initialValues };
11
+ for (const name of requiredVars) {
12
+ if (values[name])
13
+ continue;
14
+ if (!opts.prompt) {
15
+ throw new Error(`Missing required value: "${name}"`);
16
+ }
17
+ const answer = await input({
18
+ message: `Enter ${name}:`,
19
+ validate: (v) => (v.trim() ? true : `${name} cannot be empty`),
20
+ });
21
+ values[name] = answer;
22
+ }
23
+ return values;
24
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "new-branch",
3
+ "version": "0.1.0",
4
+ "description": "Generate and create standardized git branch names from a pattern.",
5
+ "keywords": [
6
+ "git",
7
+ "branch",
8
+ "cli",
9
+ "tool"
10
+ ],
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "scripts": {
16
+ "dev": "tsx src/cli.ts",
17
+ "build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
18
+ "start": "node dist/cli.js",
19
+ "typecheck": "tsc -p tsconfig.json --noEmit",
20
+ "prepack": "pnpm build && pnpm test:run",
21
+ "test": "vitest",
22
+ "test:run": "vitest run",
23
+ "format": "prettier . --write",
24
+ "format:check": "prettier . --check",
25
+ "lint": "eslint src --ext .ts"
26
+ },
27
+ "bin": {
28
+ "new-branch": "./dist/cli.js"
29
+ },
30
+ "author": "Teles <github.com/teles>",
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/teles/new-branch.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/teles/new-branch/issues"
41
+ },
42
+ "homepage": "https://github.com/teles/new-branch#readme",
43
+ "packageManager": "pnpm@10.22.0",
44
+ "devDependencies": {
45
+ "@eslint/js": "10.0.1",
46
+ "@types/node": "25.2.3",
47
+ "eslint": "10.0.0",
48
+ "prettier": "3.8.1",
49
+ "tsc-alias": "1.8.16",
50
+ "tsx": "4.21.0",
51
+ "typescript": "5.9.3",
52
+ "typescript-eslint": "8.56.0",
53
+ "vitest": "4.0.18"
54
+ },
55
+ "dependencies": {
56
+ "@inquirer/prompts": "8.2.1",
57
+ "cac": "6.7.14",
58
+ "execa": "9.6.1"
59
+ }
60
+ }