gwqadd 0.1.0 → 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.
Files changed (3) hide show
  1. package/README.md +65 -1
  2. package/bin/gwqadd.mjs +328 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,6 +16,25 @@ Create a branch and its [gwq](https://github.com/d-kuro/gwq) worktree in the rep
16
16
  One command instead of: `git checkout -b` → realise you wanted a worktree →
17
17
  `gwq add -b` → find where it landed → `cd`.
18
18
 
19
+ Run it with no branch name and it asks one question:
20
+
21
+ ```console
22
+ ~/ghq/github.com/you/api $ gwqadd
23
+ ┌ gwqadd api
24
+ │ repo api /Users/you/ghq/github.com/you/api
25
+ │ base main 8f2c1a9
26
+
27
+ │ what do you want to do? (any language)
28
+ │ > セッションが期限切れでも通ってしまう不具合を直す
29
+
30
+ │ bugfix/expired-session-accepted off main
31
+ │ create it? [Y]es · [n]o, describe again · [e]dit the name
32
+ └ ✓ bugfix/expired-session-accepted → …
33
+ ```
34
+
35
+ No type menu, no list to choose from. Note the prefix: this repository uses
36
+ `bugfix/`, not `fix/`, and the suggestion followed it without being told.
37
+
19
38
  ## Install
20
39
 
21
40
  ```sh
@@ -76,6 +95,49 @@ So the fix is one flag:
76
95
  gwqadd feat/logout --from main
77
96
  ```
78
97
 
98
+ ## Naming help
99
+
100
+ One question, one confirmation:
101
+
102
+ | key | |
103
+ | --- | --- |
104
+ | `Y` / Enter | create it |
105
+ | `n` | describe the work again — the rejected name is excluded next time |
106
+ | `e` | edit the suggested name in place |
107
+ | Esc | give up, create nothing |
108
+
109
+ **Why the suggestions fit.** The prompt is not just your sentence. It carries:
110
+
111
+ - this repository's branch prefixes with their counts, so the AI picks the one
112
+ you actually use — `feature/` over `feat/`, `bugfix/` over `fix/`;
113
+ - up to 20 existing branch names, for wording and length;
114
+ - the repository name and the ref being branched from;
115
+ - the paths you have already modified, if the working tree is dirty — often the
116
+ clearest signal about what the work is.
117
+
118
+ **The AI** is whichever of these is on your `PATH`:
119
+
120
+ ```
121
+ claude -p → codex exec → opencode run → gemini -p
122
+ ```
123
+
124
+ No API key to obtain, no account to create — it uses what you already have.
125
+ Expect 6–8 seconds, almost all of it the CLI's own start-up; an elapsed counter
126
+ runs while it works.
127
+
128
+ | | |
129
+ | --- | --- |
130
+ | pick a different CLI | `--ai 'gemini -p'`, or `GWQADD_AI='gemini -p'` |
131
+ | turn it off | `--no-ai`, or `GWQADD_AI=off` — leaves a plain ASCII-name prompt |
132
+
133
+ **What is sent, and when.** Nothing leaves your machine until you answer the
134
+ question. At that point the sentence, the repository name, its branch names and
135
+ your modified file *paths* (never contents) go to that CLI. Nothing is created
136
+ until you confirm.
137
+
138
+ None of this happens when you pass a branch name on the command line, or when
139
+ there is no terminal. Scripts and agents keep the plain, silent contract.
140
+
79
141
  ## What it does
80
142
 
81
143
  1. Work out which repository you are in — any worktree of it will do.
@@ -107,6 +169,8 @@ gwqadd [options] [<branch>]
107
169
  | `--cmd <name>` | function name emitted by `--init` (default: `gwqadd`) |
108
170
  | `--from <ref>` | branch from this ref instead of the current HEAD |
109
171
  | `--expires <dur>` | hand gwq an expiry (`1h`, `7d`, …) for a throwaway worktree |
172
+ | `--ai <cmd>` | AI CLI used to suggest names (default: autodetected) |
173
+ | `--no-ai` | never ask an AI, even when one is installed |
110
174
  | `--no-submodules` | skip `git submodule update --init --recursive` |
111
175
  | `-f`, `--force` | move a colliding worktree directory aside instead of failing |
112
176
  | `-n`, `--no-cd` | do the work and report the path, but do not move the shell |
@@ -116,7 +180,7 @@ gwqadd [options] [<branch>]
116
180
  | `-h`, `--help` | show help |
117
181
  | `-V`, `--version` | show version |
118
182
 
119
- Run it with no branch name and it asks for one.
183
+ Run it with no branch name and it asks what you want to do, then confirms once.
120
184
 
121
185
  ## For scripts and AI agents
122
186
 
package/bin/gwqadd.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from 'node:child_process';
2
+ import { spawnSync, spawn } from 'node:child_process';
3
3
  import { parseArgs } from 'node:util';
4
4
  import { Buffer } from 'node:buffer';
5
5
  import { readFileSync, existsSync, readdirSync, renameSync, realpathSync } from 'node:fs';
@@ -27,6 +27,8 @@ OPTIONS
27
27
  --cmd <name> function name emitted by --init (default: ${PKG})
28
28
  --from <ref> branch from this ref instead of the current HEAD
29
29
  --expires <dur> hand gwq an expiry (1h, 7d, …) for a throwaway worktree
30
+ --ai <cmd> AI CLI used to suggest names (default: autodetected)
31
+ --no-ai never ask an AI, even when one is installed
30
32
  --no-submodules skip \`git submodule update --init --recursive\`
31
33
  -f, --force move a colliding worktree directory aside instead of failing
32
34
  -n, --no-cd do the work and report the path, but do not move the shell
@@ -40,9 +42,29 @@ EXAMPLES
40
42
  ${PKG} feat/login branch off HEAD, worktree, cd
41
43
  ${PKG} feat/login --from main branch off main wherever you happen to be
42
44
  ${PKG} hotfix/x --expires 1d gwq will mark it expired after a day
43
- ${PKG} asks for the branch name
45
+ ${PKG} describe the work, confirm, done
44
46
  ${PKG} -n --json feat/login machine-readable, shell stays put
45
47
 
48
+ NAMING HELP
49
+ Run ${PKG} with no branch name and it asks one question — what you want to do,
50
+ in any language. An AI CLI turns that into a branch name and you confirm once:
51
+
52
+ Y create it n describe it again e edit the name
53
+
54
+ There is no type menu. The prompt carries this repository's own prefixes and
55
+ their counts, a sample of its branch names, and the paths you have already
56
+ modified, so the AI picks the prefix and the wording that match the repo. A
57
+ rejected suggestion is passed back as an exclusion, so "n" does not return it.
58
+
59
+ The AI is whichever of these is on PATH — claude, codex, opencode, gemini —
60
+ invoked headlessly. Nothing is sent anywhere until you answer the question,
61
+ and nothing is created until you confirm. Override with --ai '<cmd>' or
62
+ GWQADD_AI='<cmd>'; disable with --no-ai or GWQADD_AI=off, which leaves a plain
63
+ prompt for an ASCII name.
64
+
65
+ None of this happens with a branch name on the command line, or without a
66
+ terminal — scripts and agents keep the plain, silent contract.
67
+
46
68
  WHERE IT BRANCHES FROM
47
69
  The current HEAD, exactly like \`git checkout -b\` — which means running this
48
70
  inside a feature worktree branches off that feature, not off main. ${PKG}
@@ -106,6 +128,8 @@ try {
106
128
  cmd: { type: 'string' },
107
129
  from: { type: 'string' },
108
130
  expires: { type: 'string' },
131
+ ai: { type: 'string' },
132
+ 'no-ai': { type: 'boolean' },
109
133
  'no-submodules': { type: 'boolean' },
110
134
  force: { type: 'boolean', short: 'f' },
111
135
  'no-cd': { type: 'boolean', short: 'n' },
@@ -447,12 +471,16 @@ async function confirmYesNo(question) {
447
471
  return c === 0x0d || c === 0x0a || c === 0x79 || c === 0x59; // Enter, y, Y
448
472
  }
449
473
 
450
- // A line, not a keypress — the branch name needs editing and history. The
451
- // prompt goes to stderr so stdout stays the path channel (I1).
452
- async function askLine(question) {
474
+ // A line, not a keypress — a description or a branch name needs editing. The
475
+ // prompt goes to stderr so stdout stays the path channel (I1). `initial`
476
+ // pre-fills the line so "edit this suggestion" starts from the suggestion
477
+ // rather than from an empty prompt.
478
+ async function askLine(question, initial = '') {
453
479
  const rl = createInterface({ input: process.stdin, output: stderr, terminal: true });
454
480
  try {
455
- return (await rl.question(question)).trim();
481
+ const answer = rl.question(question);
482
+ if (initial) rl.write(initial);
483
+ return (await answer).trim();
456
484
  } finally {
457
485
  rl.close();
458
486
  }
@@ -531,6 +559,271 @@ function defaultBranch(dir) {
531
559
  return head ? head.replace(/^refs\/remotes\/origin\//, '') : '';
532
560
  }
533
561
 
562
+ // ── naming (interactive only) ────────────────────────────────────────────────
563
+
564
+ // Everything about the repository worth telling a model that is choosing a
565
+ // branch name. The point of gathering it here is that the model picks the
566
+ // prefix too — there is no menu, so the convention has to travel in the prompt.
567
+ function repoContext(dir, repo, base) {
568
+ const refs = gitOut(dir, [
569
+ 'for-each-ref', '--format=%(refname:short)', '--sort=-committerdate',
570
+ '--count=300', 'refs/heads', 'refs/remotes/origin',
571
+ ]);
572
+ const names = new Set();
573
+ for (let b of refs.split('\n')) {
574
+ b = b.trim();
575
+ if (!b || b === 'HEAD' || b === 'origin/HEAD') continue;
576
+ if (b.startsWith('origin/')) b = b.slice('origin/'.length);
577
+ names.add(b); // a branch on both local and origin must count once
578
+ }
579
+ const counts = new Map();
580
+ for (const b of names) {
581
+ const i = b.indexOf('/');
582
+ if (i <= 0) continue;
583
+ const p = b.slice(0, i);
584
+ counts.set(p, (counts.get(p) ?? 0) + 1);
585
+ }
586
+ const prefixes = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
587
+
588
+ // Work already in progress is the strongest hint there is about what the
589
+ // branch is for. Paths only — never contents.
590
+ // Not gitOut(): it trims the whole output, which eats the leading space of
591
+ // porcelain's first line and takes the first character of that path with it.
592
+ // Every entry is `XY<space>path`, and a rename is `R <space>old -> new`.
593
+ const status = git(dir, ['status', '--porcelain', '--untracked-files=no']);
594
+ const dirty = (status.status === 0 ? status.stdout ?? '' : '')
595
+ .split('\n')
596
+ .filter((l) => l.length > 3)
597
+ .map((l) => l.slice(3))
598
+ .map((l) => (l.includes(' -> ') ? l.slice(l.indexOf(' -> ') + 4) : l))
599
+ .map((l) => l.replace(/^"(.*)"$/, '$1').trim()) // porcelain quotes odd paths
600
+ .filter(Boolean)
601
+ .slice(0, 20);
602
+
603
+ return {
604
+ name: repo.name,
605
+ base: base.ref,
606
+ prefixes,
607
+ examples: [...names].filter((n) => n.includes('/')).slice(0, 20),
608
+ dirty,
609
+ };
610
+ }
611
+
612
+ function namingPrompt(ctx, description, rejected) {
613
+ const conventions = ctx.prefixes.length
614
+ ? `This repository's branch prefixes, most used first — pick one of these unless the work clearly does not fit:\n${
615
+ ctx.prefixes.map(([p, n]) => ` ${p}/ (${n} branches)`).join('\n')}`
616
+ : 'This repository has no prefix convention yet. Use a Conventional Commits type: feat, fix, docs, refactor, test, chore or perf.';
617
+
618
+ return [
619
+ 'You name git branches. Reply with exactly 3 candidate names, one per line.',
620
+ 'No numbering, no bullets, no quotes, no code fences, no explanation.',
621
+ `Repository: ${ctx.name}. The branch will be cut from: ${ctx.base}.`,
622
+ conventions,
623
+ ctx.examples.length
624
+ ? `Existing branch names — match their wording and length:\n${ctx.examples.map((e) => ` ${e}`).join('\n')}`
625
+ : '',
626
+ ctx.dirty.length
627
+ ? `Files already modified in the working tree, which is what the work touches:\n${ctx.dirty.map((f) => ` ${f}`).join('\n')}`
628
+ : '',
629
+ 'Include the prefix and a slash. After it use lowercase ASCII words joined by hyphens. Under 40 characters. Be specific about this change, not generic.',
630
+ rejected.length
631
+ ? `These were rejected — do not propose them or close variants:\n${rejected.map((r) => ` ${r}`).join('\n')}`
632
+ : '',
633
+ `The work to name, written in the author's own language:\n${description}`,
634
+ ].filter(Boolean).join('\n\n');
635
+ }
636
+
637
+ // ── the AI layer ─────────────────────────────────────────────────────────────
638
+
639
+ // Delegate to whatever agent CLI the user already has, rather than embedding an
640
+ // API client. No key to store, no account to create, no new dependency, and it
641
+ // bills to whatever they already pay for. Order is by how reliably each one
642
+ // answers a bare prompt headlessly.
643
+ const AI_CLIS = [
644
+ { bin: 'claude', args: ['-p'] },
645
+ { bin: 'codex', args: ['exec'] },
646
+ { bin: 'opencode', args: ['run'] },
647
+ { bin: 'gemini', args: ['-p'] },
648
+ ];
649
+
650
+ // GWQADD_AI may be an absolute path; only the command name is worth showing.
651
+ const aiLabel = (ai) => ai.bin.split('/').pop();
652
+
653
+ function detectAi() {
654
+ if (values['no-ai']) return null;
655
+ const override = values.ai ?? process.env.GWQADD_AI;
656
+ if (override != null && override !== '') {
657
+ if (['off', '0', 'false', 'none'].includes(override)) return null;
658
+ // Split on whitespace only: quoting rules would be a shell of our own.
659
+ const parts = override.split(/\s+/).filter(Boolean);
660
+ return { bin: parts[0], args: parts.slice(1) };
661
+ }
662
+ return AI_CLIS.find((c) => commandExists(c.bin)) ?? null;
663
+ }
664
+
665
+ // spawnSync would freeze the terminal for the 6-8 seconds these CLIs take to
666
+ // boot, with no sign of life. Async plus an elapsed counter is the difference
667
+ // between "working" and "hung".
668
+ function runAi(ai, prompt) {
669
+ return new Promise((resolve) => {
670
+ let child;
671
+ try {
672
+ child = spawn(ai.bin, [...ai.args, prompt], { stdio: ['ignore', 'pipe', 'pipe'] });
673
+ } catch (err) {
674
+ return resolve({ ok: false, out: '', err: String(err?.message ?? err) });
675
+ }
676
+ let out = '';
677
+ let err = '';
678
+ child.stdout.on('data', (d) => { out += d; });
679
+ child.stderr.on('data', (d) => { err += d; });
680
+ const kill = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 60_000);
681
+ child.on('error', (e) => {
682
+ clearTimeout(kill);
683
+ resolve({ ok: false, out: '', err: String(e?.message ?? e) });
684
+ });
685
+ child.on('close', (code) => {
686
+ clearTimeout(kill);
687
+ resolve({ ok: code === 0, out, err });
688
+ });
689
+ });
690
+ }
691
+
692
+ async function askAi(ai, prompt) {
693
+ const started = Date.now();
694
+ let tick;
695
+ if (stderrTTY) {
696
+ tick = setInterval(() => {
697
+ const s = Math.round((Date.now() - started) / 1000);
698
+ stderr.write(`\r${dim('│')} ${dim('thinking')} ${dim(`(${aiLabel(ai)}, ${s}s)`)} `);
699
+ }, 250);
700
+ } else {
701
+ log(`${dim('│')} asking ${aiLabel(ai)}…`);
702
+ }
703
+ try {
704
+ return await runAi(ai, prompt);
705
+ } finally {
706
+ if (tick) {
707
+ clearInterval(tick);
708
+ stderr.write(`\r${' '.repeat(48)}\r`);
709
+ }
710
+ }
711
+ }
712
+
713
+ const validBranchName = (name) =>
714
+ !!name && spawnSync('git', ['check-ref-format', '--branch', name], { stdio: 'ignore' }).status === 0;
715
+
716
+ // Models add bullets, backticks and commentary no matter how firmly asked not
717
+ // to. Keep only things that could actually be branch names, and let git have
718
+ // the final say on each one.
719
+ function parseCandidates(text) {
720
+ const cleaned = text.split('\n')
721
+ .map((l) => l.trim())
722
+ .map((l) => l.replace(/^[-*•]\s*/, ''))
723
+ .map((l) => l.replace(/^\d+[.)]\s*/, ''))
724
+ .map((l) => l.replace(/^[`'"]+|[`'"]+$/g, ''))
725
+ .filter(Boolean)
726
+ .filter((l) => !/\s/.test(l))
727
+ .filter((l) => /^[A-Za-z0-9._/-]+$/.test(l));
728
+
729
+ const seen = new Set();
730
+ const out = [];
731
+ for (const name of cleaned) {
732
+ if (seen.has(name) || !validBranchName(name)) continue;
733
+ seen.add(name);
734
+ out.push(name);
735
+ if (out.length === 3) break;
736
+ }
737
+ return out;
738
+ }
739
+
740
+ function slugify(s) {
741
+ return s.trim().toLowerCase()
742
+ .replace(/[\s_]+/g, '-')
743
+ .replace(/[^a-z0-9./-]/g, '-')
744
+ .replace(/-{2,}/g, '-')
745
+ .replace(/(^[-/]+)|([-/]+$)/g, '');
746
+ }
747
+
748
+ // This prompt is where the AI path lands when it is unavailable or declined, so
749
+ // a user who was mid-way through describing the work in their own language will
750
+ // type that language here. Slugifying it would silently create a branch named
751
+ // in Japanese; git allows it, but no CI, URL or tab-completion wants it. Say so
752
+ // and point at the two things that do work.
753
+ async function typeItYourself(initial = '') {
754
+ for (;;) {
755
+ const raw = await askLine(`${dim('│')} branch name ${dim('(ascii)')}: `, initial);
756
+ if (!raw) die('E_INTERRUPTED', 'cancelled');
757
+ if (/[^\x00-\x7F]/.test(raw)) {
758
+ warn('this prompt takes an ASCII name. Describe the work at the previous prompt to have it translated, or pass a name as an argument to use it verbatim.');
759
+ initial = '';
760
+ continue;
761
+ }
762
+ const name = slugify(raw);
763
+ if (validBranchName(name)) return name;
764
+ warn(`'${name}' is not a valid branch name — try again`);
765
+ initial = raw;
766
+ }
767
+ }
768
+
769
+ // The single checkpoint before anything is created. `n` sends the user back to
770
+ // describing the work, which is what they asked for; `e` is there because a
771
+ // suggestion that is one word off should not cost another round trip.
772
+ async function confirmCreate(name, base) {
773
+ log(`${dim('│')}`);
774
+ log(`${dim('│')} ${bold(cyan(name))} ${dim(`off ${base}`)}`);
775
+ stderr.write(
776
+ `${dim('│')} create it? ${dim('[Y]es')} ${dim('·')} ${dim('[n]o, describe again')} ` +
777
+ `${dim('·')} ${dim('[e]dit the name')} `,
778
+ );
779
+ for (;;) {
780
+ const buf = await waitForKey();
781
+ if (buf.includes(0x03) || buf[0] === 0x1b) { stderr.write('\n'); die('E_INTERRUPTED', 'cancelled'); }
782
+ const c = buf[0];
783
+ if (c === 0x79 || c === 0x59 || c === 0x0d || c === 0x0a) { stderr.write('\n'); return { create: true }; }
784
+ if (c === 0x6e || c === 0x4e) { stderr.write('\n'); return { again: true }; }
785
+ if (c === 0x65 || c === 0x45) { stderr.write('\n'); return { edit: true }; }
786
+ }
787
+ }
788
+
789
+ // The whole interactive path. Returns a branch name git has already accepted;
790
+ // never runs unless there is a terminal and no positional was given.
791
+ async function composeBranchName(dir, repo, base) {
792
+ const ai = detectAi();
793
+ if (!ai) return typeItYourself();
794
+
795
+ const ctx = repoContext(dir, repo, base);
796
+ const rejected = [];
797
+
798
+ for (;;) {
799
+ log(`${dim('│')}`);
800
+ const description = await askLine(
801
+ `${dim('│')} what do you want to do? ${dim('(any language)')}\n${dim('│')} ${dim('>')} `,
802
+ );
803
+ // An empty answer is the escape hatch out of the AI entirely.
804
+ if (!description) return typeItYourself();
805
+
806
+ const res = await askAi(ai, namingPrompt(ctx, description, rejected));
807
+ if (!res.ok) {
808
+ warn(`${aiLabel(ai)} failed — name it yourself instead`);
809
+ const first = (res.err || '').trim().split('\n')[0];
810
+ if (first) log(`${dim('│')} ${dim(first.slice(0, 120))}`);
811
+ return typeItYourself();
812
+ }
813
+ const candidates = parseCandidates(res.out);
814
+ if (candidates.length === 0) {
815
+ warn(`${aiLabel(ai)} returned nothing usable — name it yourself instead`);
816
+ return typeItYourself();
817
+ }
818
+
819
+ const choice = await confirmCreate(candidates[0], ctx.base);
820
+ if (choice.create) return candidates[0];
821
+ if (choice.edit) return typeItYourself(candidates[0]);
822
+ // Rejected: remember every suggestion from this round so the next prompt
823
+ // cannot come back with a near-identical name.
824
+ rejected.push(...candidates);
825
+ }
826
+ }
534
827
  // ── width / box ──────────────────────────────────────────────────────────────
535
828
 
536
829
  // Rough East Asian Width: 全角 CJK + 全角ラテン + half-symbols treated as wide.
@@ -643,39 +936,41 @@ async function main() {
643
936
  const repo = resolveRepo();
644
937
  const cwd = process.cwd();
645
938
 
646
- let branch = positionals[0];
647
- if (!branch) {
648
- if (isNonInteractive) {
649
- die('E_VALIDATION', 'a branch name is required — `gwqadd <branch>`');
650
- }
651
- log(`${dim('┌')} ${bold(PKG)} ${dim(repo.name)}`);
652
- branch = await askLine(`${dim('│')} new branch: `);
653
- if (!branch) die('E_INTERRUPTED', 'cancelled');
654
- } else {
655
- log(`${dim('┌')} ${bold(PKG)} ${dim(repo.name)}`);
656
- }
657
-
658
- // git would reject this later with a less obvious message.
659
- const check = spawnSync('git', ['check-ref-format', '--branch', branch], { stdio: 'ignore' });
660
- if (check.status !== 0) {
661
- die('E_VALIDATION', `'${branch}' is not a valid branch name`);
662
- }
663
-
664
939
  const base = resolveBase(cwd, values.from);
665
- const branchExisted = hasLocalBranch(cwd, branch);
666
940
 
941
+ // Where you are and what you are cutting from come first — before any naming,
942
+ // because both are things you would want to correct before investing in a
943
+ // branch name, and the base especially is easy to get silently wrong.
944
+ log(`${dim('┌')} ${bold(PKG)} ${dim(repo.name)}`);
667
945
  log(`${dim('│')} repo ${cyan(repo.name)} ${dim(repo.root)}`);
668
946
  if (repo.inLinkedWorktree) {
669
947
  log(`${dim('│')} cwd ${dim('a linked worktree of it')} ${dim(repo.cwdTop)}`);
670
948
  }
671
- if (!branchExisted) {
672
- log(`${dim('│')} base ${cyan(base.ref)} ${dim(base.sha.slice(0, 7))}`);
673
- // The silent version of this is the whole reason --from exists: running
674
- // inside a feature worktree branches off that feature, not off main.
675
- const def = defaultBranch(cwd);
676
- if (!values.from && def && base.ref !== def && base.ref !== `origin/${def}`) {
677
- warn(`branching from ${base.ref}, not the default branch — pass \`--from ${def}\` if that is not what you meant`);
949
+ log(`${dim('│')} base ${cyan(base.ref)} ${dim(base.sha.slice(0, 7))}`);
950
+ // The silent version of this is the whole reason --from exists: running
951
+ // inside a feature worktree branches off that feature, not off main.
952
+ const def = defaultBranch(cwd);
953
+ if (!values.from && def && base.ref !== def && base.ref !== `origin/${def}`) {
954
+ warn(`branching from ${base.ref}, not the default branch pass \`--from ${def}\` if that is not what you meant`);
955
+ }
956
+
957
+ let branch = positionals[0];
958
+ if (branch) {
959
+ // git would reject this later with a less obvious message.
960
+ if (!validBranchName(branch)) {
961
+ die('E_VALIDATION', `'${branch}' is not a valid branch name`);
962
+ }
963
+ } else {
964
+ if (isNonInteractive) {
965
+ die('E_VALIDATION', 'a branch name is required — `gwqadd <branch>`');
678
966
  }
967
+ // composeBranchName only ever returns a name git has already accepted.
968
+ branch = await composeBranchName(cwd, repo, base);
969
+ }
970
+
971
+ const branchExisted = hasLocalBranch(cwd, branch);
972
+ if (branchExisted) {
973
+ log(`${dim('│')} ${dim(`${branch} already exists — the base above is not used`)}`);
679
974
  }
680
975
 
681
976
  git(cwd, ['worktree', 'prune'], { stdio: 'ignore' }); // clear hand-deleted leftovers
@@ -695,7 +990,7 @@ async function main() {
695
990
  let addArgs;
696
991
  if (branchExisted) {
697
992
  addArgs = [branch];
698
- log(`${dim('│')} ${dim('branch exists — creating its worktree only')}`);
993
+ log(`${dim('│')} ${dim('creating its worktree only')}`);
699
994
  } else if (values.from) {
700
995
  const made = git(cwd, ['branch', branch, values.from]);
701
996
  if (made.status !== 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gwqadd",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Create a branch and its gwq worktree in the repository you are in, and cd there.",
5
5
  "type": "module",
6
6
  "bin": {