new-branch 0.5.0 → 0.6.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
@@ -177,31 +177,63 @@ Disable prompts with:
177
177
 
178
178
  ---
179
179
 
180
- ## Project Configuration
180
+ ## Project Configuration and precedence
181
181
 
182
- You can define a default pattern in `package.json`:
182
+ Configuration for `new-branch` may come from several places. The CLI resolves the first _non-empty_ configuration it finds according to the following precedence (highest → lowest):
183
+
184
+ 1. CLI flags (explicit `--pattern`, `--type`, etc.)
185
+ 2. `.newbranchrc.json` (a repository-local JSON config file)
186
+ 3. `package.json` under the `new-branch` key
187
+ 4. Git config (`new-branch.pattern`) — local then global
188
+ 5. Interactive prompt (only if enabled and a value is still missing)
189
+
190
+ This means that if a higher-precedence source provides a non-empty value, lower-precedence sources are not consulted or merged.
191
+
192
+ Examples
193
+
194
+ 1. `.newbranchrc.json` (preferred when present and non-empty):
195
+
196
+ ```json
197
+ {
198
+ "pattern": "{type}/{title:slugify}-{id}",
199
+ "types": [
200
+ { "value": "feat", "label": "Feature" },
201
+ { "value": "fix", "label": "Fix" }
202
+ ],
203
+ "defaultType": "feat"
204
+ }
205
+ ```
206
+
207
+ 2. `package.json` fallback:
183
208
 
184
209
  ```json
185
210
  {
186
211
  "new-branch": {
187
- "pattern": "{type}/{title:slugify}-{id}"
212
+ "pattern": "{type}/{title:slugify}-{id}",
213
+ "defaultType": "fix"
188
214
  }
189
215
  }
190
216
  ```
191
217
 
192
- ### Git Configuration
193
-
194
- You can also define a default pattern using Git config:
218
+ 3. Git config fallback (local takes precedence over global):
195
219
 
196
220
  ```bash
197
221
  git config --local new-branch.pattern "{type}/{title:slugify}-{id}"
222
+ git config --global new-branch.pattern "{type}/{title:slugify}-{id}"
198
223
  ```
199
224
 
200
- Or globally:
225
+ Notes about `type` and `defaultType`
201
226
 
202
- ```bash
203
- git config --global new-branch.pattern "{type}/{title:slugify}-{id}"
204
- ```
227
+ - Order for resolving the branch `type` follows the SPEC behavior we implemented:
228
+ 1. CLI `--type` (explicit flag) overrides everything.
229
+ 2. `defaultType` from the selected configuration source is used next (if present).
230
+ 3. If the project config declares exactly one `type` in `types[]`, that single type is used as a convenience.
231
+ 4. If the type is still not resolved and interactive prompting is allowed, the CLI will prompt for it.
232
+ 5. If the type is still missing and `--no-prompt` (or `prompt: false`) is in effect, the CLI will fail with a helpful error.
233
+
234
+ - Validation: when a configuration source provides both `types[]` and `defaultType`, the `defaultType` must match one of the declared `types[].value`. If it does not, configuration validation will surface an error.
235
+
236
+ - Interactive prompts: when `types[]` are present in the chosen project config, those entries are exposed as choices to the interactive `type` select prompt so users see and can pick project-defined types.
205
237
 
206
238
  To remove the pattern from Git config:
207
239
 
@@ -213,13 +245,6 @@ git config --unset --local new-branch.pattern
213
245
  git config --unset --global new-branch.pattern
214
246
  ```
215
247
 
216
- When using Git config, the resolution order becomes:
217
-
218
- 1. CLI flags
219
- 2. `package.json` configuration
220
- 3. Git config (`new-branch.pattern`)
221
- 4. Interactive prompt (if enabled)
222
-
223
248
  ---
224
249
 
225
250
  ## Git Safety
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ 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
7
  import { getBuiltinValues } from "./runtime/builtins.js";
8
- import { loadProjectConfig } from "./config/loadProjectConfig.js";
8
+ import { loadConfig } from "./config/loadConfig.js";
9
9
  import { getGitConfig } from "./git/gitConfig.js";
10
10
  import { extractGitBuiltinKeysFromPattern, getGitBuiltins, patternNeedsGitBuiltins, } from "./git/gitBuiltins.js";
11
11
  import { sanitizeGitRef } from "./git/sanitizeGitRef.js";
@@ -50,13 +50,27 @@ function toInitialValues(args) {
50
50
  };
51
51
  }
52
52
  export async function run() {
53
- // Step 0: args/options
54
- // Note: CAC prints help, but depending on our parseArgs wrapper we might not
55
- // expose `help` in `args.options`. We still want to exit early and never
56
- // require a pattern when the user just asked for help.
57
- const argv = process.argv.slice(2);
53
+ // Normalize argv so it works consistently across:
54
+ // - node dist/cli.js --id 123
55
+ // - pnpm dev -- --id 123
56
+ // - tsx src/cli.ts --id 123
57
+ //
58
+ // Notes:
59
+ // - `pnpm` may inject a standalone "--" before the script flags.
60
+ // - `tsx` puts the script path (e.g. `src/cli.ts`) as the first item in `process.argv.slice(2)`.
61
+ // That script path is not a flag, so we strip leading non-flag arguments.
62
+ let argv = process.argv.slice(2);
63
+ // Strip leading positional entries like `src/cli.ts` (common when running via `tsx`).
64
+ while (argv.length > 0 && argv[0] !== "--" && !argv[0].startsWith("-")) {
65
+ argv = argv.slice(1);
66
+ }
67
+ // Strip standalone "--" injected by pnpm.
68
+ if (argv[0] === "--") {
69
+ argv = argv.slice(1);
70
+ }
58
71
  const wantsHelp = argv.includes("--help") || argv.includes("-h");
59
- const args = parseArgs(process.argv);
72
+ // Important: parseArgs should receive the reconstructed argv
73
+ const args = parseArgs(["node", "cli", ...argv]);
60
74
  if (wantsHelp) {
61
75
  return;
62
76
  }
@@ -64,7 +78,7 @@ export async function run() {
64
78
  const create = args.options.create === true;
65
79
  const prompt = args.options.prompt !== false;
66
80
  // Pipeline: pattern -> AST -> resolve values -> render -> sanitize -> validate -> (optional) git -> output
67
- const projectConfig = await loadProjectConfig();
81
+ const projectConfig = await loadConfig();
68
82
  // Git config (respects local -> global precedence automatically)
69
83
  let gitPattern;
70
84
  if (!args.options.pattern && !projectConfig.pattern) {
@@ -89,13 +103,28 @@ export async function run() {
89
103
  // GitBuiltins is compatible with RenderValues (string | undefined)
90
104
  gitValues = gitRes.value;
91
105
  }
106
+ // Resolve `type` from CLI or config, honoring precedence:
107
+ // 1. CLI --type overrides everything
108
+ // 2. projectConfig.defaultType (if present)
109
+ // 3. if only one type is declared, use that as a convenience
110
+ // 4. otherwise leave undefined so resolveMissingValues will prompt (if prompt===true)
111
+ let resolvedType = args.options.type ?? projectConfig.defaultType;
112
+ if (!resolvedType && projectConfig.types?.length === 1) {
113
+ resolvedType = projectConfig.types[0].value;
114
+ }
92
115
  const initialValues = {
93
116
  ...builtinValues,
94
117
  ...gitValues,
95
118
  ...toInitialValues(args),
119
+ type: resolvedType,
96
120
  };
97
121
  const valuesRes = await safeAsync(() => resolveMissingValues(astRes.value, initialValues, {
98
122
  prompt,
123
+ // If project config defines `types`, expose them as choices for the
124
+ // interactive `type` select so the user sees and can choose project values.
125
+ typeChoices: projectConfig.types
126
+ ? projectConfig.types.map((t) => ({ name: t.label, value: t.value }))
127
+ : undefined,
99
128
  }));
100
129
  if (!isOk(valuesRes))
101
130
  fail("Failed to resolve required values.", valuesRes.error);
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @fileoverview
3
+ * Aggregates configuration sources without merging.
4
+ *
5
+ * Precedence:
6
+ * 1) .new-branchrc.json
7
+ * 2) package.json
8
+ * 3) git config
9
+ */
10
+ import { rcLoader } from "./sources/rc.loader.js";
11
+ import { packageJsonLoader } from "./sources/packageJson.loader.js";
12
+ import { gitLoader } from "./sources/git.loader.js";
13
+ /**
14
+ * Loads the first configuration found.
15
+ * No merging is performed.
16
+ */
17
+ export async function loadConfig() {
18
+ // Load rc loader first and prefer a non-empty config. Avoid calling the
19
+ // git loader unless necessary because it depends on external git state
20
+ // and may import modules that are platform-sensitive.
21
+ const rcRes = await rcLoader.load();
22
+ if (rcRes.found && rcRes.config && Object.keys(rcRes.config).length > 0)
23
+ return rcRes.config;
24
+ const pkgRes = await packageJsonLoader.load();
25
+ if (pkgRes.found && pkgRes.config && Object.keys(pkgRes.config).length > 0)
26
+ return pkgRes.config;
27
+ const gitRes = await gitLoader.load();
28
+ if (gitRes.found)
29
+ return gitRes.config ?? {};
30
+ return {};
31
+ }
@@ -0,0 +1,39 @@
1
+ import { getGitConfig } from "../../git/gitConfig.js";
2
+ import { validateProjectConfigSource, validateProjectConfigFinal } from "../validate.js";
3
+ function parseGitTypes(raw) {
4
+ return raw
5
+ .split(",")
6
+ .map((s) => s.trim())
7
+ .filter(Boolean)
8
+ .map((entry) => {
9
+ const idx = entry.indexOf(":");
10
+ if (idx === -1) {
11
+ return { value: entry, label: entry };
12
+ }
13
+ return {
14
+ value: entry.slice(0, idx).trim(),
15
+ label: entry.slice(idx + 1).trim(),
16
+ };
17
+ });
18
+ }
19
+ export const gitLoader = {
20
+ source: "git",
21
+ async load() {
22
+ const pattern = await getGitConfig("new-branch.pattern");
23
+ const defaultType = await getGitConfig("new-branch.defaultType");
24
+ const typesRaw = await getGitConfig("new-branch.types");
25
+ if (!pattern && !defaultType && !typesRaw) {
26
+ return { found: false, source: "git", config: undefined };
27
+ }
28
+ const cfg = {};
29
+ if (pattern)
30
+ cfg.pattern = pattern;
31
+ if (defaultType)
32
+ cfg.defaultType = defaultType;
33
+ if (typesRaw)
34
+ cfg.types = parseGitTypes(typesRaw);
35
+ const sourceValidated = validateProjectConfigSource(cfg, "git config");
36
+ const finalValidated = validateProjectConfigFinal(sourceValidated, "git config");
37
+ return { found: true, source: "git", config: finalValidated };
38
+ },
39
+ };
@@ -0,0 +1,37 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { validateProjectConfigSource, validateProjectConfigFinal } from "../validate.js";
4
+ function isNodeFsError(e) {
5
+ return typeof e === "object" && e !== null && "code" in e;
6
+ }
7
+ export const packageJsonLoader = {
8
+ source: "package.json",
9
+ async load() {
10
+ try {
11
+ const path = join(process.cwd(), "package.json");
12
+ const raw = await readFile(path, "utf8");
13
+ const parsed = JSON.parse(raw);
14
+ if (typeof parsed !== "object" || parsed === null) {
15
+ return { found: false, source: "package.json", config: undefined };
16
+ }
17
+ const pkg = parsed;
18
+ const block = pkg["new-branch"];
19
+ if (!block) {
20
+ return { found: false, source: "package.json", config: undefined };
21
+ }
22
+ const sourceValidated = validateProjectConfigSource(block, "package.json");
23
+ const finalValidated = validateProjectConfigFinal(sourceValidated, "package.json");
24
+ return {
25
+ found: true,
26
+ source: "package.json",
27
+ config: finalValidated,
28
+ };
29
+ }
30
+ catch (e) {
31
+ if (isNodeFsError(e) && e.code === "ENOENT") {
32
+ return { found: false, source: "package.json", config: undefined };
33
+ }
34
+ throw e;
35
+ }
36
+ },
37
+ };
@@ -0,0 +1,26 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { validateProjectConfigSource, validateProjectConfigFinal } from "../validate.js";
4
+ export const RC_FILENAME = ".newbranchrc.json";
5
+ function isNodeFsError(e) {
6
+ return typeof e === "object" && e !== null && "code" in e;
7
+ }
8
+ export const rcLoader = {
9
+ source: "rc",
10
+ async load() {
11
+ try {
12
+ const path = join(process.cwd(), RC_FILENAME);
13
+ const raw = await readFile(path, "utf8");
14
+ const parsed = JSON.parse(raw);
15
+ const sourceValidated = validateProjectConfigSource(parsed, RC_FILENAME);
16
+ const finalValidated = validateProjectConfigFinal(sourceValidated, RC_FILENAME);
17
+ return { found: true, source: "rc", config: finalValidated };
18
+ }
19
+ catch (e) {
20
+ if (isNodeFsError(e) && e.code === "ENOENT") {
21
+ return { found: false, source: "rc", config: undefined };
22
+ }
23
+ throw e;
24
+ }
25
+ },
26
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @fileoverview
3
+ * Core types for `new-branch` configuration system.
4
+ *
5
+ * Naming consistency:
6
+ * Always use `new-branch` (never `newBranch` or `newbranch`).
7
+ */
8
+ export {};
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @fileoverview
3
+ * Validation + normalization logic for `new-branch` configuration.
4
+ *
5
+ * Strategy:
6
+ * 1) validateProjectConfigSource → structural validation per source
7
+ * 2) validateProjectConfigFinal → cross-field business rules
8
+ */
9
+ /**
10
+ * Throws a standardized configuration error.
11
+ */
12
+ function invariant(condition, source, message) {
13
+ if (!condition) {
14
+ throw new Error(`Invalid new-branch config from ${source}: ${message}`);
15
+ }
16
+ }
17
+ function isObject(v) {
18
+ return typeof v === "object" && v !== null;
19
+ }
20
+ function isString(v) {
21
+ return typeof v === "string";
22
+ }
23
+ function trimOrUndefined(v) {
24
+ const t = v.trim();
25
+ return t.length > 0 ? t : undefined;
26
+ }
27
+ function normalizeBranchType(raw, source) {
28
+ invariant(isObject(raw), source, "types[] must be an object");
29
+ const obj = raw;
30
+ const value = trimOrUndefined(String(obj.value ?? ""));
31
+ const label = trimOrUndefined(String(obj.label ?? ""));
32
+ invariant(value, source, "types[].value cannot be empty");
33
+ invariant(label, source, "types[].label cannot be empty");
34
+ return { value, label };
35
+ }
36
+ /**
37
+ * Structural validation for a single source.
38
+ */
39
+ export function validateProjectConfigSource(raw, source) {
40
+ invariant(isObject(raw), source, "config must be an object");
41
+ const obj = raw;
42
+ const cfg = {};
43
+ if ("pattern" in obj) {
44
+ invariant(isString(obj.pattern), source, "pattern must be a string");
45
+ cfg.pattern = trimOrUndefined(obj.pattern);
46
+ }
47
+ if ("defaultType" in obj) {
48
+ invariant(isString(obj.defaultType), source, "defaultType must be a string");
49
+ cfg.defaultType = trimOrUndefined(obj.defaultType);
50
+ }
51
+ if ("types" in obj) {
52
+ const typesVal = obj.types;
53
+ invariant(Array.isArray(typesVal), source, "types must be an array");
54
+ cfg.types = typesVal.map((t) => normalizeBranchType(t, source));
55
+ }
56
+ return cfg;
57
+ }
58
+ /**
59
+ * Final cross-field validation.
60
+ */
61
+ export function validateProjectConfigFinal(cfg, source) {
62
+ if (cfg.types) {
63
+ invariant(cfg.types.length > 0, source, "types cannot be empty");
64
+ }
65
+ if (cfg.defaultType && cfg.types) {
66
+ const exists = cfg.types.some((t) => t.value === cfg.defaultType);
67
+ invariant(exists, source, `defaultType "${cfg.defaultType}" must exist in types`);
68
+ }
69
+ return cfg;
70
+ }
@@ -1,5 +1,5 @@
1
- import { execa } from "execa";
2
1
  import { getGitConfig } from "../git/gitConfig.js";
2
+ import { execa } from "execa";
3
3
  export const GIT_BUILTIN_KEYS = [
4
4
  "shortSha",
5
5
  "currentBranch",
@@ -15,8 +15,8 @@ function pickAllKeysIfUndefined(keys) {
15
15
  }
16
16
  async function safeExec(args) {
17
17
  try {
18
- const { stdout } = await execa("git", args);
19
- const value = stdout.trim();
18
+ const { stdout } = (await execa("git", args));
19
+ const value = String(stdout ?? "").trim();
20
20
  return value.length ? value : undefined;
21
21
  }
22
22
  catch {
@@ -1,8 +1,8 @@
1
1
  import { execa } from "execa";
2
2
  export async function getGitConfig(key) {
3
3
  try {
4
- const { stdout } = await execa("git", ["config", "--get", key]);
5
- const value = stdout.trim();
4
+ const { stdout } = (await execa("git", ["config", "--get", key]));
5
+ const value = String(stdout ?? "").trim();
6
6
  return value.length ? value : undefined;
7
7
  }
8
8
  catch {
package/dist/parseArgs.js CHANGED
@@ -21,11 +21,14 @@ export function parseArgs(argv = process.argv) {
21
21
  const parsed = cli.parse(cleaned);
22
22
  const opts = parsed.options;
23
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,
24
+ // Accept numeric or string-like values and coerce to string when present.
25
+ pattern: opts.pattern !== undefined ? String(opts.pattern) : undefined,
26
+ id: opts.id !== undefined ? String(opts.id) : undefined,
27
+ title: opts.title !== undefined ? String(opts.title) : undefined,
28
+ type: opts.type !== undefined ? String(opts.type) : undefined,
28
29
  create: typeof opts.create === "boolean" ? opts.create : undefined,
30
+ // CAC provides `--no-prompt` as `noPrompt` normally, but the flag will
31
+ // also be available as `prompt` when parsed; keep boolean handling.
29
32
  prompt: typeof opts.prompt === "boolean" ? opts.prompt : undefined,
30
33
  quiet: typeof opts.quiet === "boolean" ? opts.quiet : undefined,
31
34
  help: typeof opts.help === "boolean" ? opts.help : undefined,
@@ -1,4 +1,3 @@
1
- import { input, select } from "@inquirer/prompts";
2
1
  import { TYPE_CHOICES } from "../runtime/enums.js";
3
2
  /**
4
3
  * Resolves missing variable values required by a parsed pattern.
@@ -40,10 +39,17 @@ export async function resolveMissingValues(parsed, initialValues, opts) {
40
39
  if (!opts.prompt) {
41
40
  throw new Error(`Missing required value: "${name}"`);
42
41
  }
42
+ // Lazily import the interactive prompts to avoid importing
43
+ // `@inquirer/prompts` at module load time. This prevents environments
44
+ // with incompatible Node.js versions from failing when the CLI is
45
+ // executed in non-interactive mode (e.g., `--no-prompt`).
46
+ const prompts = await import("@inquirer/prompts");
47
+ const { input, select } = prompts;
43
48
  if (name === "type") {
49
+ const choices = opts.typeChoices ?? TYPE_CHOICES;
44
50
  const selected = await select({
45
51
  message: "Select branch type:",
46
- choices: TYPE_CHOICES,
52
+ choices,
47
53
  });
48
54
  values[name] = selected;
49
55
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "new-branch",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Generate and create standardized git branch names from a pattern.",
5
5
  "keywords": [
6
6
  "git",
@@ -42,9 +42,6 @@
42
42
  "bugs": {
43
43
  "url": "https://github.com/teles/new-branch/issues"
44
44
  },
45
- "new-branch": {
46
- "pattern": "{type}/{title:lower}-{id}"
47
- },
48
45
  "homepage": "https://github.com/teles/new-branch#readme",
49
46
  "packageManager": "pnpm@10.22.0",
50
47
  "devDependencies": {
@@ -1,19 +0,0 @@
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
- }