new-branch 0.1.1 → 0.3.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/README.md +25 -28
- package/dist/cli.js +10 -2
- package/dist/config/loadProjectConfig.js +19 -0
- package/dist/config/loadProjectConfig.test.js +34 -0
- package/dist/pattern/transforms/camel.js +25 -0
- package/dist/pattern/transforms/camel.test.js +13 -0
- package/dist/pattern/transforms/helpers/words.js +64 -0
- package/dist/pattern/transforms/helpers/words.test.js +35 -0
- package/dist/pattern/transforms/kebab.js +19 -0
- package/dist/pattern/transforms/kebab.test.js +13 -0
- package/dist/pattern/transforms/snake.js +19 -0
- package/dist/pattern/transforms/snake.test.js +13 -0
- package/dist/pattern/transforms/title.js +19 -0
- package/dist/pattern/transforms/title.test.js +13 -0
- package/dist/pattern/transforms/words.js +27 -0
- package/dist/pattern/transforms/words.test.js +16 -0
- package/dist/runtime/enums.js +11 -0
- package/dist/runtime/resolveMissingValues.js +39 -5
- package/dist/runtime/resolveMissingValues.test.js +64 -0
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -119,13 +119,11 @@ Example AST representation:
|
|
|
119
119
|
|
|
120
120
|
## 6. Built-in Variables
|
|
121
121
|
|
|
122
|
-
Variable Description
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
title Human-readable task title
|
|
128
|
-
id Task identifier (e.g., STK-123)
|
|
122
|
+
| Variable | Description |
|
|
123
|
+
| -------- | ------------------------------- |
|
|
124
|
+
| type | Branch type (feat, fix, etc.) |
|
|
125
|
+
| title | Human-readable task title |
|
|
126
|
+
| id | Task identifier (e.g., STK-123) |
|
|
129
127
|
|
|
130
128
|
---
|
|
131
129
|
|
|
@@ -135,28 +133,29 @@ All transforms must be pure functions.
|
|
|
135
133
|
|
|
136
134
|
### 7.1 String Transforms
|
|
137
135
|
|
|
138
|
-
| Transform
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
uppercase
|
|
143
|
-
|
|
136
|
+
| Transform | Description |
|
|
137
|
+
| --------- | ------------------------- |
|
|
138
|
+
| slugify | Converts to URL-safe slug |
|
|
139
|
+
| lowercase | Converts to lowercase |
|
|
140
|
+
| uppercase | Converts to uppercase |
|
|
141
|
+
| trim | Trims whitespace |
|
|
142
|
+
| titlecase | Capitalizes words |
|
|
144
143
|
|
|
145
144
|
### 7.2 Argument-based Transforms
|
|
146
145
|
|
|
147
|
-
| Transform
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
| Transform | Description | Example |
|
|
147
|
+
| --------- | ------------------------------ | ------- |
|
|
148
|
+
| max | Truncates string to max length | max:25 |
|
|
149
|
+
| pad | Pads string to length | pad:10 |
|
|
151
150
|
|
|
152
151
|
### 7.3 Validation Transforms
|
|
153
152
|
|
|
154
|
-
Validation transforms do not modify value but throw errors if invalid.
|
|
153
|
+
Validation transforms do not modify a value but throw errors if invalid.
|
|
155
154
|
|
|
156
|
-
| Transform
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
155
|
+
| Transform | Description |
|
|
156
|
+
| --------- | -------------------------- |
|
|
157
|
+
| required | Ensures value is not empty |
|
|
158
|
+
| match | Validates value via regex |
|
|
160
159
|
|
|
161
160
|
---
|
|
162
161
|
|
|
@@ -237,12 +236,10 @@ Validation must occur immediately after input.
|
|
|
237
236
|
|
|
238
237
|
## 12. Optional Flags
|
|
239
238
|
|
|
240
|
-
Flag Description
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
--create Creates branch using `git switch -c`
|
|
245
|
-
--print Prints branch name only (default behavior)
|
|
239
|
+
| Flag | Description |
|
|
240
|
+
| ---------- | ------------------------------------------ |
|
|
241
|
+
| `--create` | Creates branch using `git switch -c` |
|
|
242
|
+
| `--print` | Prints branch name only (default behavior) |
|
|
246
243
|
|
|
247
244
|
---
|
|
248
245
|
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { parsePattern } from "./pattern/parsePattern.js";
|
|
|
4
4
|
import { defaultTransforms } from "./pattern/transforms/index.js";
|
|
5
5
|
import { renderPattern } from "./pattern/transforms/renderPattern.js";
|
|
6
6
|
import { resolveMissingValues } from "./runtime/resolveMissingValues.js";
|
|
7
|
+
import { loadProjectConfig } from "./config/loadProjectConfig.js";
|
|
7
8
|
import { sanitizeGitRef } from "./git/sanitizeGitRef.js";
|
|
8
9
|
import { validateBranchName } from "./git/validateBranchName.js";
|
|
9
10
|
import { createBranch } from "./git/createBranch.js";
|
|
@@ -47,15 +48,22 @@ function toInitialValues(args) {
|
|
|
47
48
|
}
|
|
48
49
|
async function run() {
|
|
49
50
|
// Step 0: args/options
|
|
51
|
+
// Note: CAC prints help, but depending on our parseArgs wrapper we might not
|
|
52
|
+
// expose `help` in `args.options`. We still want to exit early and never
|
|
53
|
+
// require a pattern when the user just asked for help.
|
|
54
|
+
const argv = process.argv.slice(2);
|
|
55
|
+
const wantsHelp = argv.includes("--help") || argv.includes("-h");
|
|
50
56
|
const args = parseArgs(process.argv);
|
|
51
|
-
if (
|
|
57
|
+
if (wantsHelp) {
|
|
52
58
|
return;
|
|
53
59
|
}
|
|
54
60
|
const quiet = args.options.quiet === true;
|
|
55
61
|
const create = args.options.create === true;
|
|
56
62
|
const prompt = args.options.prompt !== false;
|
|
57
63
|
// Pipeline: pattern -> AST -> resolve values -> render -> sanitize -> validate -> (optional) git -> output
|
|
58
|
-
const
|
|
64
|
+
const projectConfig = await loadProjectConfig();
|
|
65
|
+
const resolvedPattern = args.options.pattern ?? projectConfig.pattern;
|
|
66
|
+
const patternRes = requirePattern(resolvedPattern);
|
|
59
67
|
if (!isOk(patternRes))
|
|
60
68
|
fail("Invalid CLI arguments.", patternRes.error);
|
|
61
69
|
const astRes = safe(() => parsePattern(patternRes.value));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export async function loadProjectConfig() {
|
|
4
|
+
try {
|
|
5
|
+
const path = join(process.cwd(), "package.json");
|
|
6
|
+
const raw = await readFile(path, "utf8");
|
|
7
|
+
const pkg = JSON.parse(raw);
|
|
8
|
+
const config = pkg["new-branch"];
|
|
9
|
+
if (!config || typeof config !== "object")
|
|
10
|
+
return {};
|
|
11
|
+
const cfg = config;
|
|
12
|
+
return {
|
|
13
|
+
pattern: typeof cfg.pattern === "string" ? cfg.pattern : undefined,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
vi.mock("node:fs/promises", () => ({
|
|
3
|
+
readFile: vi.fn(),
|
|
4
|
+
}));
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
describe("loadProjectConfig", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.resetAllMocks();
|
|
9
|
+
});
|
|
10
|
+
it("returns pattern when package.json contains new-branch.pattern as string", async () => {
|
|
11
|
+
readFile.mockResolvedValueOnce(JSON.stringify({ "new-branch": { pattern: "{type}/{title}-{id}" } }));
|
|
12
|
+
const { loadProjectConfig } = await import("./loadProjectConfig.js");
|
|
13
|
+
const cfg = await loadProjectConfig();
|
|
14
|
+
expect(cfg).toEqual({ pattern: "{type}/{title}-{id}" });
|
|
15
|
+
});
|
|
16
|
+
it("returns empty object when package.json has no new-branch key", async () => {
|
|
17
|
+
readFile.mockResolvedValueOnce(JSON.stringify({ name: "pkg" }));
|
|
18
|
+
const { loadProjectConfig } = await import("./loadProjectConfig.js");
|
|
19
|
+
const cfg = await loadProjectConfig();
|
|
20
|
+
expect(cfg).toEqual({});
|
|
21
|
+
});
|
|
22
|
+
it("ignores non-string pattern values", async () => {
|
|
23
|
+
readFile.mockResolvedValueOnce(JSON.stringify({ "new-branch": { pattern: 123 } }));
|
|
24
|
+
const { loadProjectConfig } = await import("./loadProjectConfig.js");
|
|
25
|
+
const cfg = await loadProjectConfig();
|
|
26
|
+
expect(cfg).toEqual({});
|
|
27
|
+
});
|
|
28
|
+
it("returns empty object if reading package.json throws", async () => {
|
|
29
|
+
readFile.mockRejectedValueOnce(new Error("enoent"));
|
|
30
|
+
const { loadProjectConfig } = await import("./loadProjectConfig.js");
|
|
31
|
+
const cfg = await loadProjectConfig();
|
|
32
|
+
expect(cfg).toEqual({});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { splitWords, upperFirst } from "./helpers/words.js";
|
|
2
|
+
/**
|
|
3
|
+
* Transform: camel
|
|
4
|
+
*
|
|
5
|
+
* Converts an input string into camelCase. Uses `splitWords` to extract word
|
|
6
|
+
* boundaries and lower-cases the words before joining. The first word is kept
|
|
7
|
+
* in lower-case; subsequent words are capitalized using `upperFirst`.
|
|
8
|
+
*
|
|
9
|
+
* Examples:
|
|
10
|
+
* - "My Task" -> "myTask"
|
|
11
|
+
* - "HTTP Server" -> "httpServer"
|
|
12
|
+
*/
|
|
13
|
+
export const camel = {
|
|
14
|
+
name: "camel",
|
|
15
|
+
fn: (value) => {
|
|
16
|
+
const words = splitWords(value).map((w) => w.toLowerCase());
|
|
17
|
+
if (!words.length)
|
|
18
|
+
return "";
|
|
19
|
+
return words[0] + words.slice(1).map(upperFirst).join("");
|
|
20
|
+
},
|
|
21
|
+
doc: {
|
|
22
|
+
summary: "Converts value to camelCase.",
|
|
23
|
+
usage: ["{title:camel}"],
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { camel } from "./camel.js";
|
|
3
|
+
describe("camel transform", () => {
|
|
4
|
+
it("converts spaced text to camelCase", () => {
|
|
5
|
+
expect(camel.fn("My Task", [])).toBe("myTask");
|
|
6
|
+
});
|
|
7
|
+
it("handles punctuation and multiple separators", () => {
|
|
8
|
+
expect(camel.fn("hello-world_test", [])).toBe("helloWorldTest");
|
|
9
|
+
});
|
|
10
|
+
it("returns empty string for empty input", () => {
|
|
11
|
+
expect(camel.fn("", [])).toBe("");
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits an input string into word-like segments.
|
|
3
|
+
*
|
|
4
|
+
* The function attempts to be Unicode-aware and supports the following
|
|
5
|
+
* heuristics for identifying boundaries:
|
|
6
|
+
* - Splits on any run of non-letter/number characters (spaces, punctuation).
|
|
7
|
+
* - Inserts boundaries for camelCase (e.g. `myTask` -> `my Task`).
|
|
8
|
+
* - Inserts a boundary between an ALL-CAPS acronym and a following
|
|
9
|
+
* capitalized word (e.g. `HTTPServer` -> `HTTP Server`).
|
|
10
|
+
*
|
|
11
|
+
* Returned words are trimmed and empty segments are discarded.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* splitWords("myTask") // => ["my", "Task"]
|
|
15
|
+
* splitWords("HTTPServer") // => ["HTTP", "Server"]
|
|
16
|
+
* splitWords("Título grande") // => ["Título", "grande"]
|
|
17
|
+
*
|
|
18
|
+
* @param input - The string to split into words.
|
|
19
|
+
* @returns An array of word segments (possibly empty).
|
|
20
|
+
*/
|
|
21
|
+
export function splitWords(input) {
|
|
22
|
+
const cleaned = input.trim();
|
|
23
|
+
if (!cleaned)
|
|
24
|
+
return [];
|
|
25
|
+
const withBoundaries = cleaned
|
|
26
|
+
// camelCase boundary: myTask -> my Task
|
|
27
|
+
.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, "$1 $2")
|
|
28
|
+
// ALLCAPS followed by lowercase: HTTPServer -> HTTP Server
|
|
29
|
+
.replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu, "$1 $2");
|
|
30
|
+
return withBoundaries
|
|
31
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
32
|
+
.map((w) => w.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Upper-cases the first character of the provided string.
|
|
37
|
+
*
|
|
38
|
+
* Does not modify the remainder of the string.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* upperFirst("hello") // => "Hello"
|
|
42
|
+
* upperFirst("") // => ""
|
|
43
|
+
*
|
|
44
|
+
* @param s - Input string.
|
|
45
|
+
* @returns String with the first character upper-cased (if present).
|
|
46
|
+
*/
|
|
47
|
+
export function upperFirst(s) {
|
|
48
|
+
return s.length ? s[0].toUpperCase() + s.slice(1) : s;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Lower-cases the first character of the provided string.
|
|
52
|
+
*
|
|
53
|
+
* Does not modify the remainder of the string.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* lowerFirst("Hello") // => "hello"
|
|
57
|
+
* lowerFirst("") // => ""
|
|
58
|
+
*
|
|
59
|
+
* @param s - Input string.
|
|
60
|
+
* @returns String with the first character lower-cased (if present).
|
|
61
|
+
*/
|
|
62
|
+
export function lowerFirst(s) {
|
|
63
|
+
return s.length ? s[0].toLowerCase() + s.slice(1) : s;
|
|
64
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { splitWords, upperFirst, lowerFirst } from "./words.js";
|
|
3
|
+
describe("splitWords", () => {
|
|
4
|
+
it("returns empty array for empty or whitespace-only input", () => {
|
|
5
|
+
expect(splitWords("")).toEqual([]);
|
|
6
|
+
expect(splitWords(" ")).toEqual([]);
|
|
7
|
+
});
|
|
8
|
+
it("splits on separators and trims words", () => {
|
|
9
|
+
expect(splitWords("hello world-test")).toEqual(["hello", "world", "test"]);
|
|
10
|
+
expect(splitWords(" leading and trailing ")).toEqual(["leading", "and", "trailing"]);
|
|
11
|
+
});
|
|
12
|
+
it("handles camelCase boundaries", () => {
|
|
13
|
+
expect(splitWords("myTask")).toEqual(["my", "Task"]);
|
|
14
|
+
expect(splitWords("version2Beta")).toEqual(["version2", "Beta"]);
|
|
15
|
+
});
|
|
16
|
+
it("handles ALLCAPS followed by capitalized word", () => {
|
|
17
|
+
expect(splitWords("HTTPServer")).toEqual(["HTTP", "Server"]);
|
|
18
|
+
expect(splitWords("XMLHttpRequest")).toEqual(["XML", "Http", "Request"]);
|
|
19
|
+
});
|
|
20
|
+
it("keeps Unicode letters and accents", () => {
|
|
21
|
+
expect(splitWords("Título grande")).toEqual(["Título", "grande"]);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe("upperFirst / lowerFirst", () => {
|
|
25
|
+
it("upperFirst capitalizes only the first character", () => {
|
|
26
|
+
expect(upperFirst("hello")).toBe("Hello");
|
|
27
|
+
expect(upperFirst("")).toBe("");
|
|
28
|
+
expect(upperFirst("éclair")).toBe("Éclair");
|
|
29
|
+
});
|
|
30
|
+
it("lowerFirst lowercases only the first character", () => {
|
|
31
|
+
expect(lowerFirst("Hello")).toBe("hello");
|
|
32
|
+
expect(lowerFirst("")).toBe("");
|
|
33
|
+
expect(lowerFirst("Éclair")).toBe("éclair");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { splitWords } from "./helpers/words.js";
|
|
2
|
+
/**
|
|
3
|
+
* Transform: kebab
|
|
4
|
+
*
|
|
5
|
+
* Converts an input string into kebab-case (lowercased words joined with
|
|
6
|
+
* hyphens). Uses `splitWords` to determine word boundaries.
|
|
7
|
+
*
|
|
8
|
+
* Example: "My Task" -> "my-task"
|
|
9
|
+
*/
|
|
10
|
+
export const kebab = {
|
|
11
|
+
name: "kebab",
|
|
12
|
+
fn: (value) => splitWords(value)
|
|
13
|
+
.map((w) => w.toLowerCase())
|
|
14
|
+
.join("-"),
|
|
15
|
+
doc: {
|
|
16
|
+
summary: "Converts value to kebab-case.",
|
|
17
|
+
usage: ["{title:kebab}"],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { kebab } from "./kebab.js";
|
|
3
|
+
describe("kebab transform", () => {
|
|
4
|
+
it("converts spaced text to kebab-case", () => {
|
|
5
|
+
expect(kebab.fn("My Task", [])).toBe("my-task");
|
|
6
|
+
});
|
|
7
|
+
it("handles camelCase and punctuation", () => {
|
|
8
|
+
expect(kebab.fn("myTaskHTTP Server", [])).toBe("my-task-http-server");
|
|
9
|
+
});
|
|
10
|
+
it("returns empty string for empty input", () => {
|
|
11
|
+
expect(kebab.fn("", [])).toBe("");
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { splitWords } from "./helpers/words.js";
|
|
2
|
+
/**
|
|
3
|
+
* Transform: snake
|
|
4
|
+
*
|
|
5
|
+
* Converts an input string into snake_case (lowercased words joined with
|
|
6
|
+
* underscores). Uses `splitWords` to determine boundaries.
|
|
7
|
+
*
|
|
8
|
+
* Example: "My Task" -> "my_task"
|
|
9
|
+
*/
|
|
10
|
+
export const snake = {
|
|
11
|
+
name: "snake",
|
|
12
|
+
fn: (value) => splitWords(value)
|
|
13
|
+
.map((w) => w.toLowerCase())
|
|
14
|
+
.join("_"),
|
|
15
|
+
doc: {
|
|
16
|
+
summary: "Converts value to snake_case.",
|
|
17
|
+
usage: ["{title:snake}"],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { snake } from "./snake.js";
|
|
3
|
+
describe("snake transform", () => {
|
|
4
|
+
it("converts spaced text to snake_case", () => {
|
|
5
|
+
expect(snake.fn("My Task", [])).toBe("my_task");
|
|
6
|
+
});
|
|
7
|
+
it("handles camelCase and punctuation", () => {
|
|
8
|
+
expect(snake.fn("myTaskHTTP Server", [])).toBe("my_task_http_server");
|
|
9
|
+
});
|
|
10
|
+
it("returns empty string for empty input", () => {
|
|
11
|
+
expect(snake.fn("", [])).toBe("");
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { splitWords, upperFirst } from "./helpers/words.js";
|
|
2
|
+
/**
|
|
3
|
+
* Transform: title
|
|
4
|
+
*
|
|
5
|
+
* Converts an input string into Title Case where each word's first
|
|
6
|
+
* character is upper-cased and the remainder lower-cased. Uses `splitWords`.
|
|
7
|
+
*
|
|
8
|
+
* Example: "hello WORLD" -> "Hello World"
|
|
9
|
+
*/
|
|
10
|
+
export const title = {
|
|
11
|
+
name: "title",
|
|
12
|
+
fn: (value) => splitWords(value)
|
|
13
|
+
.map((w) => upperFirst(w.toLowerCase()))
|
|
14
|
+
.join(" "),
|
|
15
|
+
doc: {
|
|
16
|
+
summary: "Converts value to Title Case.",
|
|
17
|
+
usage: ["{title:title}"],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { title } from "./title.js";
|
|
3
|
+
describe("title transform", () => {
|
|
4
|
+
it("converts text to Title Case", () => {
|
|
5
|
+
expect(title.fn("hello WORLD", [])).toBe("Hello World");
|
|
6
|
+
});
|
|
7
|
+
it("handles punctuation and multiple separators", () => {
|
|
8
|
+
expect(title.fn("my-task_HTTP server", [])).toBe("My Task Http Server");
|
|
9
|
+
});
|
|
10
|
+
it("returns empty string for empty input", () => {
|
|
11
|
+
expect(title.fn("", [])).toBe("");
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { splitWords } from "./helpers/words.js";
|
|
2
|
+
/**
|
|
3
|
+
* Transform: words
|
|
4
|
+
*
|
|
5
|
+
* Limits the input to at most `n` words. The transform expects a single
|
|
6
|
+
* numeric argument that indicates the maximum number of words to keep. The
|
|
7
|
+
* returned value is the first `n` words joined by a single space.
|
|
8
|
+
*
|
|
9
|
+
* Examples:
|
|
10
|
+
* - `{title:words:2}` applied to "My big title" -> "My big"
|
|
11
|
+
*
|
|
12
|
+
* @throws If the provided argument is missing or not a non-negative number.
|
|
13
|
+
*/
|
|
14
|
+
export const words = {
|
|
15
|
+
name: "words",
|
|
16
|
+
fn: (value, [n]) => {
|
|
17
|
+
const count = Number(n);
|
|
18
|
+
if (!Number.isFinite(count) || count < 0) {
|
|
19
|
+
throw new Error(`words expects a non-negative number, got "${n ?? ""}"`);
|
|
20
|
+
}
|
|
21
|
+
return splitWords(value).slice(0, count).join(" ");
|
|
22
|
+
},
|
|
23
|
+
doc: {
|
|
24
|
+
summary: "Limits value to a maximum number of words.",
|
|
25
|
+
usage: ["{title:words:3}"],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { words } from "./words.js";
|
|
3
|
+
describe("words transform", () => {
|
|
4
|
+
it("limits the number of words when a positive number is provided", () => {
|
|
5
|
+
expect(words.fn("one two three four", ["2"]).trim()).toBe("one two");
|
|
6
|
+
expect(words.fn("hello world", ["5"]).trim()).toBe("hello world");
|
|
7
|
+
});
|
|
8
|
+
it("returns empty string when 0 is provided", () => {
|
|
9
|
+
expect(words.fn("some text here", ["0"]).trim()).toBe("");
|
|
10
|
+
});
|
|
11
|
+
it("throws when argument is missing or invalid", () => {
|
|
12
|
+
expect(() => words.fn("a b c", [])).toThrow();
|
|
13
|
+
expect(() => words.fn("a b c", ["-1"])).toThrow();
|
|
14
|
+
expect(() => words.fn("a b c", ["not-a-number"])).toThrow();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const TYPE_CHOICES = [
|
|
2
|
+
{ name: "Feature", value: "feat" },
|
|
3
|
+
{ name: "Fix", value: "fix" },
|
|
4
|
+
{ name: "Documentation", value: "docs" },
|
|
5
|
+
{ name: "Chore", value: "chore" },
|
|
6
|
+
{ name: "Refactor", value: "refactor" },
|
|
7
|
+
{ name: "Test", value: "test" },
|
|
8
|
+
{ name: "Performance", value: "perf" },
|
|
9
|
+
{ name: "Build", value: "build" },
|
|
10
|
+
{ name: "CI", value: "ci" },
|
|
11
|
+
];
|
|
@@ -1,19 +1,53 @@
|
|
|
1
|
-
import { input } from "@inquirer/prompts";
|
|
1
|
+
import { input, select } from "@inquirer/prompts";
|
|
2
|
+
import { TYPE_CHOICES } from "../runtime/enums.js";
|
|
2
3
|
/**
|
|
3
|
-
* Resolves missing variable values required by
|
|
4
|
+
* Resolves missing variable values required by a parsed pattern.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* The function inspects `parsed.variablesUsed` and ensures each required
|
|
7
|
+
* variable has a non-empty value in the returned object. If a variable is
|
|
8
|
+
* missing or contains only whitespace and `opts.prompt` is `true`, the
|
|
9
|
+
* function will prompt the user for the value. The special variable name
|
|
10
|
+
* `type` is resolved with a select prompt pre-populated with
|
|
11
|
+
* {@link TYPE_CHOICES}.
|
|
12
|
+
*
|
|
13
|
+
* Behavior summary:
|
|
14
|
+
* - Any variable present in `parsed.variablesUsed` is considered required.
|
|
15
|
+
* - If a value exists and is non-empty (after trimming) it is preserved.
|
|
16
|
+
* - If a value is missing or blank and `opts.prompt` is `false`, an error is
|
|
17
|
+
* thrown listing the missing variable.
|
|
18
|
+
* - If `opts.prompt` is `true`, the function will:
|
|
19
|
+
* - use a select prompt for the variable named `type` (choices from
|
|
20
|
+
* {@link TYPE_CHOICES});
|
|
21
|
+
* - use a text input prompt for any other variable.
|
|
22
|
+
*
|
|
23
|
+
* @param parsed - Parsed pattern containing `variablesUsed`.
|
|
24
|
+
* @param initialValues - Existing values that may satisfy requirements.
|
|
25
|
+
* @param opts - Options controlling prompting behavior.
|
|
26
|
+
* @returns A promise resolving to a `RenderValues` object containing all
|
|
27
|
+
* required variables (original values preserved when present).
|
|
28
|
+
* @throws When a required variable is missing and `opts.prompt` is false.
|
|
29
|
+
* @example
|
|
30
|
+
* const parsed = parsePattern('{type}/{title}');
|
|
31
|
+
* await resolveMissingValues(parsed, { title: 'Hello' }, { prompt: true });
|
|
7
32
|
*/
|
|
8
33
|
export async function resolveMissingValues(parsed, initialValues, opts) {
|
|
9
34
|
const requiredVars = parsed.variablesUsed;
|
|
10
35
|
const values = { ...initialValues };
|
|
11
36
|
for (const name of requiredVars) {
|
|
12
|
-
|
|
37
|
+
const current = values[name];
|
|
38
|
+
if (current && current.trim() !== "")
|
|
13
39
|
continue;
|
|
14
40
|
if (!opts.prompt) {
|
|
15
41
|
throw new Error(`Missing required value: "${name}"`);
|
|
16
42
|
}
|
|
43
|
+
if (name === "type") {
|
|
44
|
+
const selected = await select({
|
|
45
|
+
message: "Select branch type:",
|
|
46
|
+
choices: TYPE_CHOICES,
|
|
47
|
+
});
|
|
48
|
+
values[name] = selected;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
17
51
|
const answer = await input({
|
|
18
52
|
message: `Enter ${name}:`,
|
|
19
53
|
validate: (v) => (v.trim() ? true : `${name} cannot be empty`),
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { resolveMissingValues } from "./resolveMissingValues.js";
|
|
3
|
+
import { parsePattern } from "../pattern/parsePattern.js";
|
|
4
|
+
// Mock prompts module so tests can control interactive behavior
|
|
5
|
+
vi.mock("@inquirer/prompts", () => {
|
|
6
|
+
return {
|
|
7
|
+
input: vi.fn(),
|
|
8
|
+
select: vi.fn(),
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
// Provide a lightweight mock for the runtime enums so module resolution
|
|
12
|
+
// does not fail in the test environment (we only need the shape used
|
|
13
|
+
// by the code under test).
|
|
14
|
+
vi.mock("@/runtime/enums.js", () => ({
|
|
15
|
+
TYPE_CHOICES: [
|
|
16
|
+
{ name: "Feature", value: "feat" },
|
|
17
|
+
{ name: "Fix", value: "fix" },
|
|
18
|
+
],
|
|
19
|
+
}));
|
|
20
|
+
import { input, select } from "@inquirer/prompts";
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
// resetAllMocks clears implementations and mock queues (e.g. mockResolvedValueOnce)
|
|
23
|
+
// which prevents cross-test leakage of one-off mock return values.
|
|
24
|
+
vi.resetAllMocks();
|
|
25
|
+
});
|
|
26
|
+
describe("resolveMissingValues", () => {
|
|
27
|
+
it("returns values unchanged when all required vars are present", async () => {
|
|
28
|
+
const parsed = parsePattern("{type}/{title}-{id}");
|
|
29
|
+
const initial = { type: "feat", title: "My Task", id: "123" };
|
|
30
|
+
const out = await resolveMissingValues(parsed, initial, { prompt: true });
|
|
31
|
+
expect(out).toEqual(initial);
|
|
32
|
+
expect(input).not.toHaveBeenCalled();
|
|
33
|
+
expect(select).not.toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
it("prompts for a missing non-type variable using input", async () => {
|
|
36
|
+
const parsed = parsePattern("{type}/{title}");
|
|
37
|
+
// input should be used for `title`
|
|
38
|
+
input.mockResolvedValueOnce("Provided Title");
|
|
39
|
+
// Type is present so select should not be called
|
|
40
|
+
const out = await resolveMissingValues(parsed, { type: "fix" }, { prompt: true });
|
|
41
|
+
expect(input).toHaveBeenCalled();
|
|
42
|
+
expect(select).not.toHaveBeenCalled();
|
|
43
|
+
expect(out.title).toBe("Provided Title");
|
|
44
|
+
});
|
|
45
|
+
it("uses select for the `type` variable", async () => {
|
|
46
|
+
const parsed = parsePattern("{type}/{title}");
|
|
47
|
+
select.mockResolvedValueOnce("feat");
|
|
48
|
+
input.mockResolvedValueOnce("Some title");
|
|
49
|
+
const out = await resolveMissingValues(parsed, { title: "Some title" }, { prompt: true });
|
|
50
|
+
expect(select).toHaveBeenCalled();
|
|
51
|
+
expect(out.type).toBe("feat");
|
|
52
|
+
});
|
|
53
|
+
it("throws when prompt is false and a required variable is missing", async () => {
|
|
54
|
+
const parsed = parsePattern("{id}");
|
|
55
|
+
await expect(resolveMissingValues(parsed, {}, { prompt: false })).rejects.toThrow(/Missing required value/i);
|
|
56
|
+
});
|
|
57
|
+
it("treats whitespace-only values as missing and prompts", async () => {
|
|
58
|
+
const parsed = parsePattern("{title}");
|
|
59
|
+
input.mockResolvedValueOnce("Trimmed");
|
|
60
|
+
const out = await resolveMissingValues(parsed, { title: " " }, { prompt: true });
|
|
61
|
+
expect(input).toHaveBeenCalled();
|
|
62
|
+
expect(out.title).toBe("Trimmed");
|
|
63
|
+
});
|
|
64
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "new-branch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Generate and create standardized git branch names from a pattern.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"git",
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
"start": "node dist/cli.js",
|
|
19
19
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
20
20
|
"prepack": "pnpm build && pnpm test:run",
|
|
21
|
-
"test": "vitest",
|
|
22
|
-
"test:run": "vitest run",
|
|
21
|
+
"test": "vitest --dir src --exclude **/dist/**",
|
|
22
|
+
"test:run": "vitest run --dir src --exclude **/dist/**",
|
|
23
|
+
"test:coverage": "vitest run --coverage --dir src --exclude **/dist/**",
|
|
23
24
|
"format": "prettier . --write",
|
|
24
25
|
"format:check": "prettier . --check",
|
|
25
26
|
"lint": "eslint src --ext .ts"
|
|
@@ -40,11 +41,15 @@
|
|
|
40
41
|
"bugs": {
|
|
41
42
|
"url": "https://github.com/teles/new-branch/issues"
|
|
42
43
|
},
|
|
44
|
+
"new-branch": {
|
|
45
|
+
"pattern": "{type}/{title:lower}-{id}"
|
|
46
|
+
},
|
|
43
47
|
"homepage": "https://github.com/teles/new-branch#readme",
|
|
44
48
|
"packageManager": "pnpm@10.22.0",
|
|
45
49
|
"devDependencies": {
|
|
46
50
|
"@eslint/js": "10.0.1",
|
|
47
51
|
"@types/node": "25.2.3",
|
|
52
|
+
"@vitest/coverage-v8": "4.0.18",
|
|
48
53
|
"eslint": "10.0.0",
|
|
49
54
|
"prettier": "3.8.1",
|
|
50
55
|
"tsc-alias": "1.8.16",
|