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 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
- type Branch type (feat, fix, etc.)
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 \| 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 \|
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 \| Description \| Example \|
148
-
149
- \|------------\|-------------\|---------\| max \| Truncates string to
150
- max length \| max:25 \| \| pad \| Pads string to length \| pad:10 \|
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 \| Description \|
157
-
158
- \|------------\|-------------\| required \| Ensures value is not empty
159
- \| \| match \| Validates via regex \|
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 (args.options.help) {
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 patternRes = requirePattern(args.options.pattern);
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 the pattern.
4
+ * Resolves missing variable values required by a parsed pattern.
4
5
  *
5
- * Rule (v1):
6
- * - Any variable used in the pattern is considered required.
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
- if (values[name])
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.1.1",
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",