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 +21 -0
- package/README.md +490 -0
- package/dist/cli.js +101 -0
- package/dist/git/createBranch.js +25 -0
- package/dist/git/sanitizeGitRef.js +32 -0
- package/dist/git/sanitizeGitRef.test.js +30 -0
- package/dist/git/validateBranchName.js +17 -0
- package/dist/parseArgs.js +37 -0
- package/dist/parseArgs.test.js +50 -0
- package/dist/pattern/parsePattern.js +147 -0
- package/dist/pattern/parsePattern.test.js +25 -0
- package/dist/pattern/transforms/index.js +16 -0
- package/dist/pattern/transforms/lower.js +8 -0
- package/dist/pattern/transforms/lower.test.js +10 -0
- package/dist/pattern/transforms/max.js +13 -0
- package/dist/pattern/transforms/max.test.js +13 -0
- package/dist/pattern/transforms/renderPattern.js +37 -0
- package/dist/pattern/transforms/renderPattern.test.js +46 -0
- package/dist/pattern/transforms/slugify.js +14 -0
- package/dist/pattern/transforms/slugify.test.js +14 -0
- package/dist/pattern/transforms/types.js +1 -0
- package/dist/pattern/transforms/upper.js +8 -0
- package/dist/pattern/transforms/upper.test.js +10 -0
- package/dist/pattern/types.js +1 -0
- package/dist/runtime/resolveMissingValues.js +24 -0
- package/package.json +60 -0
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,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,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
|
+
}
|