gwqadd 0.1.0 → 0.2.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 +69 -1
  2. package/bin/gwqadd.mjs +333 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,6 +16,35 @@ 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 helps you pick one:
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
+ │ type?
28
+ │ 1) feat/ (12) 2) fix/ (7) 3) chore/ (2)
29
+ │ 4) docs/ 5) refactor/ 6) test/
30
+ │ 7) perf/
31
+ │ 0) no prefix o) type your own
32
+ │ > 1
33
+
34
+ │ what are you doing? (any language — Enter alone to name it yourself)
35
+ │ > ログイン画面で連打するとセッションが壊れる
36
+
37
+ │ 1) feat/login-rapid-click-session
38
+ │ 2) feat/login-double-submit-guard
39
+ │ 3) feat/prevent-session-corruption
40
+ │ e) type it yourself r) regenerate
41
+ │ > 1
42
+ └ ✓ feat/login-rapid-click-session → …
43
+ ```
44
+
45
+ The type list is built from the prefixes **this repository actually uses**, so a
46
+ repo that says `feature/` is never offered `feat/`.
47
+
19
48
  ## Install
20
49
 
21
50
  ```sh
@@ -76,6 +105,43 @@ So the fix is one flag:
76
105
  gwqadd feat/logout --from main
77
106
  ```
78
107
 
108
+ ## Naming help
109
+
110
+ Two layers, and the first needs no network at all.
111
+
112
+ **The type menu** is derived from `git for-each-ref` — the prefixes this
113
+ repository already uses come first, with their counts, and the Conventional
114
+ Commits types fill out the rest. Pick `o` to type a prefix of your own, or `0`
115
+ for none.
116
+
117
+ **The suggestions** come from whichever AI CLI is already on your `PATH`:
118
+
119
+ ```
120
+ claude -p → codex exec → opencode run → gemini -p
121
+ ```
122
+
123
+ There is no API key to obtain and no account to create — it uses what you
124
+ already have, and bills to whatever you already pay for. Expect 6–8 seconds,
125
+ almost all of it the CLI's own start-up; an elapsed counter runs while it works.
126
+
127
+ Because the prompt includes a sample of the repository's existing branch names,
128
+ the suggestions match your house style rather than a generic template. Describe
129
+ the work in any language; the names come back in ASCII.
130
+
131
+ | | |
132
+ | --- | --- |
133
+ | pick a different CLI | `--ai 'gemini -p'`, or `GWQADD_AI='gemini -p'` |
134
+ | turn it off | `--no-ai`, or `GWQADD_AI=off` |
135
+ | skip it for one run | press Enter on an empty description |
136
+
137
+ **What is sent, and when.** Nothing leaves your machine until you type a
138
+ description. At that point the description, the chosen prefix and up to 15
139
+ existing branch names from the repository are passed to that CLI. A suggestion
140
+ is never accepted for you — you always pick, edit or regenerate.
141
+
142
+ None of this happens when you pass a branch name on the command line, or when
143
+ there is no terminal. Scripts and agents keep the plain, silent contract.
144
+
79
145
  ## What it does
80
146
 
81
147
  1. Work out which repository you are in — any worktree of it will do.
@@ -107,6 +173,8 @@ gwqadd [options] [<branch>]
107
173
  | `--cmd <name>` | function name emitted by `--init` (default: `gwqadd`) |
108
174
  | `--from <ref>` | branch from this ref instead of the current HEAD |
109
175
  | `--expires <dur>` | hand gwq an expiry (`1h`, `7d`, …) for a throwaway worktree |
176
+ | `--ai <cmd>` | AI CLI used to suggest names (default: autodetected) |
177
+ | `--no-ai` | never ask an AI, even when one is installed |
110
178
  | `--no-submodules` | skip `git submodule update --init --recursive` |
111
179
  | `-f`, `--force` | move a colliding worktree directory aside instead of failing |
112
180
  | `-n`, `--no-cd` | do the work and report the path, but do not move the shell |
@@ -116,7 +184,7 @@ gwqadd [options] [<branch>]
116
184
  | `-h`, `--help` | show help |
117
185
  | `-V`, `--version` | show version |
118
186
 
119
- Run it with no branch name and it asks for one.
187
+ Run it with no branch name and it walks you through the naming help above.
120
188
 
121
189
  ## For scripts and AI agents
122
190
 
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,24 @@ 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} picks a type, then helps you name it
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 for a type (the list is built from
50
+ the prefixes this repository actually uses), then for a one-line description
51
+ in any language. Describe the work and an AI CLI turns it into three
52
+ candidates that match the repo's existing branch style; press Enter on an
53
+ empty description to skip that and type the name yourself.
54
+
55
+ The AI is whichever of these is on PATH — claude, codex, opencode, gemini —
56
+ invoked headlessly. Nothing is sent anywhere until you type a description,
57
+ and the suggestion is never accepted for you. Override with --ai '<cmd>' or
58
+ GWQADD_AI='<cmd>'; disable with --no-ai or GWQADD_AI=off.
59
+
60
+ None of this happens with a branch name on the command line, or without a
61
+ terminal — scripts and agents keep the plain, silent contract.
62
+
46
63
  WHERE IT BRANCHES FROM
47
64
  The current HEAD, exactly like \`git checkout -b\` — which means running this
48
65
  inside a feature worktree branches off that feature, not off main. ${PKG}
@@ -106,6 +123,8 @@ try {
106
123
  cmd: { type: 'string' },
107
124
  from: { type: 'string' },
108
125
  expires: { type: 'string' },
126
+ ai: { type: 'string' },
127
+ 'no-ai': { type: 'boolean' },
109
128
  'no-submodules': { type: 'boolean' },
110
129
  force: { type: 'boolean', short: 'f' },
111
130
  'no-cd': { type: 'boolean', short: 'n' },
@@ -531,6 +550,289 @@ function defaultBranch(dir) {
531
550
  return head ? head.replace(/^refs\/remotes\/origin\//, '') : '';
532
551
  }
533
552
 
553
+ // ── naming help (interactive only) ───────────────────────────────────────────
554
+
555
+ // Conventional Commits types, used to fill out the menu when the repository is
556
+ // new or has no prefix habit of its own.
557
+ const CONVENTIONAL = ['feat', 'fix', 'docs', 'refactor', 'test', 'chore', 'perf'];
558
+
559
+ // What this repository actually calls things. A repo that says `feature/` must
560
+ // not be offered `feat/` first — house style beats the spec.
561
+ function branchPrefixes(dir) {
562
+ const out = gitOut(dir, [
563
+ 'for-each-ref', '--format=%(refname:short)', '--sort=-committerdate',
564
+ '--count=300', 'refs/heads', 'refs/remotes/origin',
565
+ ]);
566
+ const names = new Set();
567
+ for (let b of out.split('\n')) {
568
+ b = b.trim();
569
+ if (!b || b === 'HEAD' || b === 'origin/HEAD') continue;
570
+ if (b.startsWith('origin/')) b = b.slice('origin/'.length);
571
+ names.add(b); // dedupe: a branch present locally and on origin counts once
572
+ }
573
+ const counts = new Map();
574
+ for (const b of names) {
575
+ const i = b.indexOf('/');
576
+ if (i <= 0) continue;
577
+ const p = b.slice(0, i);
578
+ counts.set(p, (counts.get(p) ?? 0) + 1);
579
+ }
580
+ return { counts: [...counts.entries()].sort((a, b) => b[1] - a[1]), names: [...names] };
581
+ }
582
+
583
+ function typeMenu(prefixes) {
584
+ const items = [];
585
+ const seen = new Set();
586
+ for (const [prefix, count] of prefixes) {
587
+ if (items.length >= 6) break;
588
+ seen.add(prefix);
589
+ items.push({ prefix, count });
590
+ }
591
+ for (const prefix of CONVENTIONAL) {
592
+ if (items.length >= 8) break;
593
+ if (seen.has(prefix)) continue;
594
+ seen.add(prefix);
595
+ items.push({ prefix, count: 0 });
596
+ }
597
+ return items;
598
+ }
599
+
600
+ // Numbers, not mnemonic letters: `feat` and `fix` both want `f`, and the menu
601
+ // is built from the repository so the entries are not known in advance.
602
+ async function pickType(items) {
603
+ log(`${dim('│')}`);
604
+ log(`${dim('│')} ${bold('type?')}`);
605
+ const cols = [];
606
+ for (const [i, it] of items.entries()) {
607
+ const n = cyan(String(i + 1));
608
+ const used = it.count ? dim(` (${it.count})`) : '';
609
+ cols.push(`${n}) ${it.prefix}/${used}`);
610
+ }
611
+ for (let i = 0; i < cols.length; i += 3) {
612
+ log(`${dim('│')} ${cols.slice(i, i + 3).join(' ')}`);
613
+ }
614
+ log(`${dim('│')} ${cyan('0')}) no prefix ${cyan('o')}) type your own`);
615
+ stderr.write(`${dim('│')} ${dim('>')} `);
616
+
617
+ const buf = await waitForKey();
618
+ stderr.write('\n');
619
+ if (buf.includes(0x03) || buf[0] === 0x1b) die('E_INTERRUPTED', 'cancelled');
620
+ const c = buf[0];
621
+ if (c === 0x30) return ''; // 0
622
+ if (c === 0x6f || c === 0x4f) { // o / O
623
+ const own = (await askLine(`${dim('│')} prefix (no slash): `)).replace(/\/+$/, '');
624
+ return own;
625
+ }
626
+ const idx = c - 0x31; // 1..8
627
+ if (idx >= 0 && idx < items.length) return items[idx].prefix;
628
+ return items[0]?.prefix ?? '';
629
+ }
630
+
631
+ // ── the AI layer ─────────────────────────────────────────────────────────────
632
+
633
+ // Delegate to whatever agent CLI the user already has, rather than embedding an
634
+ // API client. No key to store, no account to create, no new dependency, and it
635
+ // bills to whatever they already pay for. Order is by how reliably each one
636
+ // answers a bare prompt headlessly.
637
+ const AI_CLIS = [
638
+ { bin: 'claude', args: ['-p'] },
639
+ { bin: 'codex', args: ['exec'] },
640
+ { bin: 'opencode', args: ['run'] },
641
+ { bin: 'gemini', args: ['-p'] },
642
+ ];
643
+
644
+ function detectAi() {
645
+ if (values['no-ai']) return null;
646
+ const override = values.ai ?? process.env.GWQADD_AI;
647
+ if (override != null && override !== '') {
648
+ if (['off', '0', 'false', 'none'].includes(override)) return null;
649
+ // Split on whitespace only: quoting rules would be a shell of our own.
650
+ const parts = override.split(/\s+/).filter(Boolean);
651
+ return { bin: parts[0], args: parts.slice(1) };
652
+ }
653
+ return AI_CLIS.find((c) => commandExists(c.bin)) ?? null;
654
+ }
655
+
656
+ function namingPrompt(prefix, description, examples) {
657
+ return [
658
+ 'You name git branches. Reply with exactly 3 candidate names, one per line.',
659
+ 'No numbering, no bullets, no quotes, no code fences, no explanation.',
660
+ prefix
661
+ ? `Every candidate MUST begin with "${prefix}/".`
662
+ : 'Do not add a type prefix.',
663
+ 'After the prefix use lowercase ASCII words joined by hyphens. Keep each candidate under 40 characters. Be specific, not generic.',
664
+ examples.length
665
+ ? `Existing branches in this repository — match their style:\n${examples.join('\n')}`
666
+ : '',
667
+ `The work to name, written in the author's own language:\n${description}`,
668
+ ].filter(Boolean).join('\n\n');
669
+ }
670
+
671
+ // spawnSync would freeze the terminal for the 6-8 seconds these CLIs take to
672
+ // boot, with no sign of life. Async plus an elapsed counter is the difference
673
+ // between "working" and "hung".
674
+ function runAi(ai, prompt) {
675
+ return new Promise((resolve) => {
676
+ let child;
677
+ try {
678
+ child = spawn(ai.bin, [...ai.args, prompt], { stdio: ['ignore', 'pipe', 'pipe'] });
679
+ } catch (err) {
680
+ return resolve({ ok: false, out: '', err: String(err?.message ?? err) });
681
+ }
682
+ let out = '';
683
+ let err = '';
684
+ child.stdout.on('data', (d) => { out += d; });
685
+ child.stderr.on('data', (d) => { err += d; });
686
+ const kill = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 60_000);
687
+ child.on('error', (e) => {
688
+ clearTimeout(kill);
689
+ resolve({ ok: false, out: '', err: String(e?.message ?? e) });
690
+ });
691
+ child.on('close', (code) => {
692
+ clearTimeout(kill);
693
+ resolve({ ok: code === 0, out, err });
694
+ });
695
+ });
696
+ }
697
+
698
+ async function askAi(ai, prompt) {
699
+ const label = `${ai.bin}${ai.args.length ? ' ' + ai.args.join(' ') : ''}`;
700
+ const started = Date.now();
701
+ let tick;
702
+ if (stderrTTY) {
703
+ tick = setInterval(() => {
704
+ const s = Math.round((Date.now() - started) / 1000);
705
+ stderr.write(`\r${dim('│')} asking ${cyan(ai.bin)}… ${dim(`${s}s`)} `);
706
+ }, 250);
707
+ } else {
708
+ log(`${dim('│')} asking ${label}…`);
709
+ }
710
+ try {
711
+ return await runAi(ai, prompt);
712
+ } finally {
713
+ if (tick) {
714
+ clearInterval(tick);
715
+ stderr.write(`\r${' '.repeat(40)}\r`);
716
+ }
717
+ }
718
+ }
719
+
720
+ // Models add bullets, backticks and commentary no matter how firmly asked not
721
+ // to. Keep only things that could actually be branch names, and let git have
722
+ // the final say on each one.
723
+ function parseCandidates(text, prefix) {
724
+ const cleaned = text.split('\n')
725
+ .map((l) => l.trim())
726
+ .map((l) => l.replace(/^[-*•]\s*/, ''))
727
+ .map((l) => l.replace(/^\d+[.)]\s*/, ''))
728
+ .map((l) => l.replace(/^[`'"]+|[`'"]+$/g, ''))
729
+ .filter(Boolean)
730
+ .filter((l) => !/\s/.test(l))
731
+ .filter((l) => /^[A-Za-z0-9._/-]+$/.test(l));
732
+
733
+ const take = (list) => {
734
+ const seen = new Set();
735
+ const out = [];
736
+ for (const name of list) {
737
+ if (seen.has(name) || !validBranchName(name)) continue;
738
+ seen.add(name);
739
+ out.push(name);
740
+ if (out.length === 3) break;
741
+ }
742
+ return out;
743
+ };
744
+
745
+ if (!prefix) return take(cleaned);
746
+ const onPrefix = take(cleaned.filter((l) => l.startsWith(`${prefix}/`)));
747
+ if (onPrefix.length) return onPrefix;
748
+ // The model ignored the prefix. Its words are still useful; the shape is ours.
749
+ return take(cleaned.map((l) => `${prefix}/${l.replace(/^[^/]+\//, '')}`));
750
+ }
751
+
752
+ const validBranchName = (name) =>
753
+ !!name && spawnSync('git', ['check-ref-format', '--branch', name], { stdio: 'ignore' }).status === 0;
754
+
755
+ // Only ASCII is reshaped. A description in Japanese is what the AI is for, and
756
+ // mangling it into hyphens here would produce a worse name than leaving it be.
757
+ function slugify(s) {
758
+ const t = s.trim();
759
+ if (/[^\x00-\x7F]/.test(t)) return t.replace(/\s+/g, '-');
760
+ return t.toLowerCase()
761
+ .replace(/[\s_]+/g, '-')
762
+ .replace(/[^a-z0-9./-]/g, '-')
763
+ .replace(/-{2,}/g, '-')
764
+ .replace(/(^[-/]+)|([-/]+$)/g, '');
765
+ }
766
+
767
+ const join = (prefix, rest) => (prefix ? `${prefix}/${rest}` : rest);
768
+
769
+ async function typeItYourself(prefix) {
770
+ for (;;) {
771
+ const raw = await askLine(`${dim('│')} ${prefix ? `${prefix}/` : 'branch: '}`);
772
+ if (!raw) die('E_INTERRUPTED', 'cancelled');
773
+ const name = join(prefix, slugify(raw));
774
+ if (validBranchName(name)) return name;
775
+ warn(`'${name}' is not a valid branch name — try again`);
776
+ }
777
+ }
778
+
779
+ async function chooseFromCandidates(candidates, prefix) {
780
+ for (;;) {
781
+ log(`${dim('│')}`);
782
+ for (const [i, c] of candidates.entries()) {
783
+ log(`${dim('│')} ${cyan(String(i + 1))}) ${c}`);
784
+ }
785
+ log(`${dim('│')} ${cyan('e')}) type it yourself ${cyan('r')}) regenerate`);
786
+ stderr.write(`${dim('│')} ${dim('>')} `);
787
+ const buf = await waitForKey();
788
+ stderr.write('\n');
789
+ if (buf.includes(0x03) || buf[0] === 0x1b) die('E_INTERRUPTED', 'cancelled');
790
+ const c = buf[0];
791
+ if (c === 0x65 || c === 0x45) return { pick: await typeItYourself(prefix) };
792
+ if (c === 0x72 || c === 0x52) return { regenerate: true };
793
+ const idx = c - 0x31;
794
+ if (idx >= 0 && idx < candidates.length) return { pick: candidates[idx] };
795
+ // Enter, or anything unrecognised, takes the first suggestion.
796
+ if (c === 0x0d || c === 0x0a) return { pick: candidates[0] };
797
+ }
798
+ }
799
+
800
+ // The whole interactive path. Returns a branch name that git has already
801
+ // accepted; never runs unless there is a terminal and no positional was given.
802
+ async function composeBranchName(dir) {
803
+ const { counts, names } = branchPrefixes(dir);
804
+ const prefix = await pickType(typeMenu(counts));
805
+
806
+ const ai = detectAi();
807
+ const hint = ai
808
+ ? `${dim('(any language — Enter alone to name it yourself)')}`
809
+ : `${dim('(Enter alone to name it yourself)')}`;
810
+ log(`${dim('│')}`);
811
+ const description = ai
812
+ ? await askLine(`${dim('│')} what are you doing? ${hint}\n${dim('│')} ${dim('>')} `)
813
+ : '';
814
+
815
+ if (!description) return typeItYourself(prefix);
816
+
817
+ const examples = names.filter((n) => n.includes('/')).slice(0, 15);
818
+ for (;;) {
819
+ const res = await askAi(ai, namingPrompt(prefix, description, examples));
820
+ if (!res.ok) {
821
+ warn(`${ai.bin} failed — naming it yourself instead`);
822
+ const first = (res.err || '').trim().split('\n')[0];
823
+ if (first) log(`${dim('│')} ${dim(first.slice(0, 120))}`);
824
+ return typeItYourself(prefix);
825
+ }
826
+ const candidates = parseCandidates(res.out, prefix);
827
+ if (candidates.length === 0) {
828
+ warn(`${ai.bin} returned nothing usable — naming it yourself instead`);
829
+ return typeItYourself(prefix);
830
+ }
831
+ const choice = await chooseFromCandidates(candidates, prefix);
832
+ if (choice.pick) return choice.pick;
833
+ }
834
+ }
835
+
534
836
  // ── width / box ──────────────────────────────────────────────────────────────
535
837
 
536
838
  // Rough East Asian Width: 全角 CJK + 全角ラテン + half-symbols treated as wide.
@@ -643,39 +945,41 @@ async function main() {
643
945
  const repo = resolveRepo();
644
946
  const cwd = process.cwd();
645
947
 
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
948
  const base = resolveBase(cwd, values.from);
665
- const branchExisted = hasLocalBranch(cwd, branch);
666
949
 
950
+ // Where you are and what you are cutting from come first — before any naming,
951
+ // because both are things you would want to correct before investing in a
952
+ // branch name, and the base especially is easy to get silently wrong.
953
+ log(`${dim('┌')} ${bold(PKG)} ${dim(repo.name)}`);
667
954
  log(`${dim('│')} repo ${cyan(repo.name)} ${dim(repo.root)}`);
668
955
  if (repo.inLinkedWorktree) {
669
956
  log(`${dim('│')} cwd ${dim('a linked worktree of it')} ${dim(repo.cwdTop)}`);
670
957
  }
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`);
958
+ log(`${dim('│')} base ${cyan(base.ref)} ${dim(base.sha.slice(0, 7))}`);
959
+ // The silent version of this is the whole reason --from exists: running
960
+ // inside a feature worktree branches off that feature, not off main.
961
+ const def = defaultBranch(cwd);
962
+ if (!values.from && def && base.ref !== def && base.ref !== `origin/${def}`) {
963
+ warn(`branching from ${base.ref}, not the default branch pass \`--from ${def}\` if that is not what you meant`);
964
+ }
965
+
966
+ let branch = positionals[0];
967
+ if (branch) {
968
+ // git would reject this later with a less obvious message.
969
+ if (!validBranchName(branch)) {
970
+ die('E_VALIDATION', `'${branch}' is not a valid branch name`);
971
+ }
972
+ } else {
973
+ if (isNonInteractive) {
974
+ die('E_VALIDATION', 'a branch name is required — `gwqadd <branch>`');
678
975
  }
976
+ // composeBranchName only ever returns a name git has already accepted.
977
+ branch = await composeBranchName(cwd);
978
+ }
979
+
980
+ const branchExisted = hasLocalBranch(cwd, branch);
981
+ if (branchExisted) {
982
+ log(`${dim('│')} ${dim(`${branch} already exists — the base above is not used`)}`);
679
983
  }
680
984
 
681
985
  git(cwd, ['worktree', 'prune'], { stdio: 'ignore' }); // clear hand-deleted leftovers
@@ -695,7 +999,7 @@ async function main() {
695
999
  let addArgs;
696
1000
  if (branchExisted) {
697
1001
  addArgs = [branch];
698
- log(`${dim('│')} ${dim('branch exists — creating its worktree only')}`);
1002
+ log(`${dim('│')} ${dim('creating its worktree only')}`);
699
1003
  } else if (values.from) {
700
1004
  const made = git(cwd, ['branch', branch, values.from]);
701
1005
  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.2.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": {