gwqadd 0.2.0 → 0.3.1

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 +39 -38
  2. package/bin/gwqadd.mjs +190 -164
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@ 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:
19
+ Run it with no branch name and it asks one question:
20
20
 
21
21
  ```console
22
22
  ~/ghq/github.com/you/api $ gwqadd
@@ -24,26 +24,16 @@ Run it with no branch name and it helps you pick one:
24
24
  │ repo api /Users/you/ghq/github.com/you/api
25
25
  │ base main 8f2c1a9
26
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
27
+ what do you want to do? (any language)
28
+ > セッションが期限切れでも通ってしまう不具合を直す
33
29
 
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 → …
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 → …
43
33
  ```
44
34
 
45
- The type list is built from the prefixes **this repository actually uses**, so a
46
- repo that says `feature/` is never offered `feat/`.
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.
47
37
 
48
38
  ## Install
49
39
 
@@ -107,37 +97,48 @@ gwqadd feat/logout --from main
107
97
 
108
98
  ## Naming help
109
99
 
110
- Two layers, and the first needs no network at all.
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:
111
110
 
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.
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.
116
117
 
117
- **The suggestions** come from whichever AI CLI is already on your `PATH`:
118
+ **The AI** is whichever of these is on your `PATH`:
118
119
 
119
120
  ```
120
121
  claude -p → codex exec → opencode run → gemini -p
121
122
  ```
122
123
 
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.
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.
130
127
 
131
128
  | | |
132
129
  | --- | --- |
133
130
  | 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 |
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.
136
137
 
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.
138
+ The CLI is run in an **empty temporary directory**, not in your repository.
139
+ These tools are agents: given a repo they will read `CLAUDE.md`, your source and
140
+ your git log, and then name the branch after what they found instead of what you
141
+ asked for. Everything they should know is already in the prompt.
141
142
 
142
143
  None of this happens when you pass a branch name on the command line, or when
143
144
  there is no terminal. Scripts and agents keep the plain, silent contract.
@@ -184,7 +185,7 @@ gwqadd [options] [<branch>]
184
185
  | `-h`, `--help` | show help |
185
186
  | `-V`, `--version` | show version |
186
187
 
187
- Run it with no branch name and it walks you through the naming help above.
188
+ Run it with no branch name and it asks what you want to do, then confirms once.
188
189
 
189
190
  ## For scripts and AI agents
190
191
 
package/bin/gwqadd.mjs CHANGED
@@ -2,7 +2,12 @@
2
2
  import { spawnSync, spawn } from 'node:child_process';
3
3
  import { parseArgs } from 'node:util';
4
4
  import { Buffer } from 'node:buffer';
5
- import { readFileSync, existsSync, readdirSync, renameSync, realpathSync } from 'node:fs';
5
+ import {
6
+ readFileSync, existsSync, readdirSync, renameSync, realpathSync,
7
+ mkdtempSync, rmSync,
8
+ } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join as joinPath } from 'node:path';
6
11
  import { fileURLToPath } from 'node:url';
7
12
  import { createInterface } from 'node:readline/promises';
8
13
 
@@ -42,20 +47,25 @@ EXAMPLES
42
47
  ${PKG} feat/login branch off HEAD, worktree, cd
43
48
  ${PKG} feat/login --from main branch off main wherever you happen to be
44
49
  ${PKG} hotfix/x --expires 1d gwq will mark it expired after a day
45
- ${PKG} picks a type, then helps you name it
50
+ ${PKG} describe the work, confirm, done
46
51
  ${PKG} -n --json feat/login machine-readable, shell stays put
47
52
 
48
53
  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
+ Run ${PKG} with no branch name and it asks one question what you want to do,
55
+ in any language. An AI CLI turns that into a branch name and you confirm once:
56
+
57
+ Y create it n describe it again e edit the name
58
+
59
+ There is no type menu. The prompt carries this repository's own prefixes and
60
+ their counts, a sample of its branch names, and the paths you have already
61
+ modified, so the AI picks the prefix and the wording that match the repo. A
62
+ rejected suggestion is passed back as an exclusion, so "n" does not return it.
54
63
 
55
64
  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.
65
+ invoked headlessly. Nothing is sent anywhere until you answer the question,
66
+ and nothing is created until you confirm. Override with --ai '<cmd>' or
67
+ GWQADD_AI='<cmd>'; disable with --no-ai or GWQADD_AI=off, which leaves a plain
68
+ prompt for an ASCII name.
59
69
 
60
70
  None of this happens with a branch name on the command line, or without a
61
71
  terminal — scripts and agents keep the plain, silent contract.
@@ -466,12 +476,16 @@ async function confirmYesNo(question) {
466
476
  return c === 0x0d || c === 0x0a || c === 0x79 || c === 0x59; // Enter, y, Y
467
477
  }
468
478
 
469
- // A line, not a keypress — the branch name needs editing and history. The
470
- // prompt goes to stderr so stdout stays the path channel (I1).
471
- async function askLine(question) {
479
+ // A line, not a keypress — a description or a branch name needs editing. The
480
+ // prompt goes to stderr so stdout stays the path channel (I1). `initial`
481
+ // pre-fills the line so "edit this suggestion" starts from the suggestion
482
+ // rather than from an empty prompt.
483
+ async function askLine(question, initial = '') {
472
484
  const rl = createInterface({ input: process.stdin, output: stderr, terminal: true });
473
485
  try {
474
- return (await rl.question(question)).trim();
486
+ const answer = rl.question(question);
487
+ if (initial) rl.write(initial);
488
+ return (await answer).trim();
475
489
  } finally {
476
490
  rl.close();
477
491
  }
@@ -550,25 +564,22 @@ function defaultBranch(dir) {
550
564
  return head ? head.replace(/^refs\/remotes\/origin\//, '') : '';
551
565
  }
552
566
 
553
- // ── naming help (interactive only) ───────────────────────────────────────────
567
+ // ── naming (interactive only) ────────────────────────────────────────────────
554
568
 
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, [
569
+ // Everything about the repository worth telling a model that is choosing a
570
+ // branch name. The point of gathering it here is that the model picks the
571
+ // prefix too there is no menu, so the convention has to travel in the prompt.
572
+ function repoContext(dir, repo, base) {
573
+ const refs = gitOut(dir, [
563
574
  'for-each-ref', '--format=%(refname:short)', '--sort=-committerdate',
564
575
  '--count=300', 'refs/heads', 'refs/remotes/origin',
565
576
  ]);
566
577
  const names = new Set();
567
- for (let b of out.split('\n')) {
578
+ for (let b of refs.split('\n')) {
568
579
  b = b.trim();
569
580
  if (!b || b === 'HEAD' || b === 'origin/HEAD') continue;
570
581
  if (b.startsWith('origin/')) b = b.slice('origin/'.length);
571
- names.add(b); // dedupe: a branch present locally and on origin counts once
582
+ names.add(b); // a branch on both local and origin must count once
572
583
  }
573
584
  const counts = new Map();
574
585
  for (const b of names) {
@@ -577,55 +588,56 @@ function branchPrefixes(dir) {
577
588
  const p = b.slice(0, i);
578
589
  counts.set(p, (counts.get(p) ?? 0) + 1);
579
590
  }
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;
591
+ const prefixes = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
592
+
593
+ // Work already in progress is the strongest hint there is about what the
594
+ // branch is for. Paths only — never contents.
595
+ // Not gitOut(): it trims the whole output, which eats the leading space of
596
+ // porcelain's first line and takes the first character of that path with it.
597
+ // Every entry is `XY<space>path`, and a rename is `R <space>old -> new`.
598
+ const status = git(dir, ['status', '--porcelain', '--untracked-files=no']);
599
+ const dirty = (status.status === 0 ? status.stdout ?? '' : '')
600
+ .split('\n')
601
+ .filter((l) => l.length > 3)
602
+ .map((l) => l.slice(3))
603
+ .map((l) => (l.includes(' -> ') ? l.slice(l.indexOf(' -> ') + 4) : l))
604
+ .map((l) => l.replace(/^"(.*)"$/, '$1').trim()) // porcelain quotes odd paths
605
+ .filter(Boolean)
606
+ .slice(0, 20);
607
+
608
+ return {
609
+ name: repo.name,
610
+ base: base.ref,
611
+ prefixes,
612
+ examples: [...names].filter((n) => n.includes('/')).slice(0, 20),
613
+ dirty,
614
+ };
598
615
  }
599
616
 
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('>')} `);
617
+ function namingPrompt(ctx, description, rejected) {
618
+ const conventions = ctx.prefixes.length
619
+ ? `This repository's branch prefixes, most used first — pick one of these unless the work clearly does not fit:\n${
620
+ ctx.prefixes.map(([p, n]) => ` ${p}/ (${n} branches)`).join('\n')}`
621
+ : 'This repository has no prefix convention yet. Use a Conventional Commits type: feat, fix, docs, refactor, test, chore or perf.';
616
622
 
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 ?? '';
623
+ return [
624
+ 'You name git branches. Reply with exactly 3 candidate names, one per line.',
625
+ 'No numbering, no bullets, no quotes, no code fences, no explanation.',
626
+ `Repository: ${ctx.name}. The branch will be cut from: ${ctx.base}.`,
627
+ conventions,
628
+ ctx.examples.length
629
+ ? `Existing branch names — match their wording and length:\n${ctx.examples.map((e) => ` ${e}`).join('\n')}`
630
+ : '',
631
+ ctx.dirty.length
632
+ ? `Files already modified in the working tree, which is what the work touches:\n${ctx.dirty.map((f) => ` ${f}`).join('\n')}`
633
+ : '',
634
+ '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.',
635
+ 'The description below is the only thing the branch is about. Do not name it after anything else you can see.',
636
+ rejected.length
637
+ ? `These were rejected — do not propose them or close variants:\n${rejected.map((r) => ` ${r}`).join('\n')}`
638
+ : '',
639
+ `The work to name, written in the author's own language:\n${description}`,
640
+ ].filter(Boolean).join('\n\n');
629
641
  }
630
642
 
631
643
  // ── the AI layer ─────────────────────────────────────────────────────────────
@@ -641,6 +653,9 @@ const AI_CLIS = [
641
653
  { bin: 'gemini', args: ['-p'] },
642
654
  ];
643
655
 
656
+ // GWQADD_AI may be an absolute path; only the command name is worth showing.
657
+ const aiLabel = (ai) => ai.bin.split('/').pop();
658
+
644
659
  function detectAi() {
645
660
  if (values['no-ai']) return null;
646
661
  const override = values.ai ?? process.env.GWQADD_AI;
@@ -653,30 +668,42 @@ function detectAi() {
653
668
  return AI_CLIS.find((c) => commandExists(c.bin)) ?? null;
654
669
  }
655
670
 
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
671
+ // These CLIs are agents, not text transformers: run one inside a repository and
672
+ // it reads CLAUDE.md, the source and the git history, then names the branch
673
+ // after what it found instead of what the user asked for. Measured in this very
674
+ // repository description "uiのバグの修正", three runs each:
675
+ //
676
+ // cwd = the repo feat/ui-bug-fix, fix/ui-display-bug, feat/ui-bug-fix
677
+ // cwd = empty dir fix/ui-display-bug, fix/ui-display-bug, fix/ui-display-bug
678
+ //
679
+ // In-repo it both wavered and picked `feat/` for a bug fix twice. It once
680
+ // answered `feat/naming-prompt-repo-context`, which is a phrase straight out of
681
+ // this repo's CLAUDE.md. Everything the model legitimately needs is already in
682
+ // the prompt, so it runs in an empty directory and is given nothing else.
683
+ //
684
+ // spawnSync would also freeze the terminal for the 6-8 seconds these CLIs take
685
+ // to boot, with no sign of life; async plus an elapsed counter is the difference
673
686
  // between "working" and "hung".
674
687
  function runAi(ai, prompt) {
675
688
  return new Promise((resolve) => {
689
+ let sandbox = '';
690
+ try {
691
+ sandbox = mkdtempSync(joinPath(tmpdir(), 'gwqadd-ai-'));
692
+ } catch { /* fall back to inheriting cwd rather than not answering at all */ }
693
+ const cleanup = () => {
694
+ if (!sandbox) return;
695
+ try { rmSync(sandbox, { recursive: true, force: true }); } catch { /* ignore */ }
696
+ sandbox = '';
697
+ };
698
+
676
699
  let child;
677
700
  try {
678
- child = spawn(ai.bin, [...ai.args, prompt], { stdio: ['ignore', 'pipe', 'pipe'] });
701
+ child = spawn(ai.bin, [...ai.args, prompt], {
702
+ stdio: ['ignore', 'pipe', 'pipe'],
703
+ ...(sandbox ? { cwd: sandbox } : {}),
704
+ });
679
705
  } catch (err) {
706
+ cleanup();
680
707
  return resolve({ ok: false, out: '', err: String(err?.message ?? err) });
681
708
  }
682
709
  let out = '';
@@ -686,41 +713,45 @@ function runAi(ai, prompt) {
686
713
  const kill = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 60_000);
687
714
  child.on('error', (e) => {
688
715
  clearTimeout(kill);
716
+ cleanup();
689
717
  resolve({ ok: false, out: '', err: String(e?.message ?? e) });
690
718
  });
691
719
  child.on('close', (code) => {
692
720
  clearTimeout(kill);
721
+ cleanup();
693
722
  resolve({ ok: code === 0, out, err });
694
723
  });
695
724
  });
696
725
  }
697
726
 
698
727
  async function askAi(ai, prompt) {
699
- const label = `${ai.bin}${ai.args.length ? ' ' + ai.args.join(' ') : ''}`;
700
728
  const started = Date.now();
701
729
  let tick;
702
730
  if (stderrTTY) {
703
731
  tick = setInterval(() => {
704
732
  const s = Math.round((Date.now() - started) / 1000);
705
- stderr.write(`\r${dim('│')} asking ${cyan(ai.bin)} ${dim(`${s}s`)} `);
733
+ stderr.write(`\r${dim('│')} ${dim('thinking')} ${dim(`(${aiLabel(ai)}, ${s}s)`)} `);
706
734
  }, 250);
707
735
  } else {
708
- log(`${dim('│')} asking ${label}…`);
736
+ log(`${dim('│')} asking ${aiLabel(ai)}…`);
709
737
  }
710
738
  try {
711
739
  return await runAi(ai, prompt);
712
740
  } finally {
713
741
  if (tick) {
714
742
  clearInterval(tick);
715
- stderr.write(`\r${' '.repeat(40)}\r`);
743
+ stderr.write(`\r${' '.repeat(48)}\r`);
716
744
  }
717
745
  }
718
746
  }
719
747
 
748
+ const validBranchName = (name) =>
749
+ !!name && spawnSync('git', ['check-ref-format', '--branch', name], { stdio: 'ignore' }).status === 0;
750
+
720
751
  // Models add bullets, backticks and commentary no matter how firmly asked not
721
752
  // to. Keep only things that could actually be branch names, and let git have
722
753
  // the final say on each one.
723
- function parseCandidates(text, prefix) {
754
+ function parseCandidates(text) {
724
755
  const cleaned = text.split('\n')
725
756
  .map((l) => l.trim())
726
757
  .map((l) => l.replace(/^[-*•]\s*/, ''))
@@ -730,109 +761,104 @@ function parseCandidates(text, prefix) {
730
761
  .filter((l) => !/\s/.test(l))
731
762
  .filter((l) => /^[A-Za-z0-9._/-]+$/.test(l));
732
763
 
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(/^[^/]+\//, '')}`));
764
+ const seen = new Set();
765
+ const out = [];
766
+ for (const name of cleaned) {
767
+ if (seen.has(name) || !validBranchName(name)) continue;
768
+ seen.add(name);
769
+ out.push(name);
770
+ if (out.length === 3) break;
771
+ }
772
+ return out;
750
773
  }
751
774
 
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
775
  function slugify(s) {
758
- const t = s.trim();
759
- if (/[^\x00-\x7F]/.test(t)) return t.replace(/\s+/g, '-');
760
- return t.toLowerCase()
776
+ return s.trim().toLowerCase()
761
777
  .replace(/[\s_]+/g, '-')
762
778
  .replace(/[^a-z0-9./-]/g, '-')
763
779
  .replace(/-{2,}/g, '-')
764
780
  .replace(/(^[-/]+)|([-/]+$)/g, '');
765
781
  }
766
782
 
767
- const join = (prefix, rest) => (prefix ? `${prefix}/${rest}` : rest);
768
-
769
- async function typeItYourself(prefix) {
783
+ // This prompt is where the AI path lands when it is unavailable or declined, so
784
+ // a user who was mid-way through describing the work in their own language will
785
+ // type that language here. Slugifying it would silently create a branch named
786
+ // in Japanese; git allows it, but no CI, URL or tab-completion wants it. Say so
787
+ // and point at the two things that do work.
788
+ async function typeItYourself(initial = '') {
770
789
  for (;;) {
771
- const raw = await askLine(`${dim('│')} ${prefix ? `${prefix}/` : 'branch: '}`);
790
+ const raw = await askLine(`${dim('│')} branch name ${dim('(ascii)')}: `, initial);
772
791
  if (!raw) die('E_INTERRUPTED', 'cancelled');
773
- const name = join(prefix, slugify(raw));
792
+ if (/[^\x00-\x7F]/.test(raw)) {
793
+ 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.');
794
+ initial = '';
795
+ continue;
796
+ }
797
+ const name = slugify(raw);
774
798
  if (validBranchName(name)) return name;
775
799
  warn(`'${name}' is not a valid branch name — try again`);
800
+ initial = raw;
776
801
  }
777
802
  }
778
803
 
779
- async function chooseFromCandidates(candidates, prefix) {
804
+ // The single checkpoint before anything is created. `n` sends the user back to
805
+ // describing the work, which is what they asked for; `e` is there because a
806
+ // suggestion that is one word off should not cost another round trip.
807
+ async function confirmCreate(name, base) {
808
+ log(`${dim('│')}`);
809
+ log(`${dim('│')} ${bold(cyan(name))} ${dim(`off ${base}`)}`);
810
+ stderr.write(
811
+ `${dim('│')} create it? ${dim('[Y]es')} ${dim('·')} ${dim('[n]o, describe again')} ` +
812
+ `${dim('·')} ${dim('[e]dit the name')} `,
813
+ );
780
814
  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
815
  const buf = await waitForKey();
788
- stderr.write('\n');
789
- if (buf.includes(0x03) || buf[0] === 0x1b) die('E_INTERRUPTED', 'cancelled');
816
+ if (buf.includes(0x03) || buf[0] === 0x1b) { stderr.write('\n'); die('E_INTERRUPTED', 'cancelled'); }
790
817
  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] };
818
+ if (c === 0x79 || c === 0x59 || c === 0x0d || c === 0x0a) { stderr.write('\n'); return { create: true }; }
819
+ if (c === 0x6e || c === 0x4e) { stderr.write('\n'); return { again: true }; }
820
+ if (c === 0x65 || c === 0x45) { stderr.write('\n'); return { edit: true }; }
797
821
  }
798
822
  }
799
823
 
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
-
824
+ // The whole interactive path. Returns a branch name git has already accepted;
825
+ // never runs unless there is a terminal and no positional was given.
826
+ async function composeBranchName(dir, repo, base) {
806
827
  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
- : '';
828
+ if (!ai) return typeItYourself();
814
829
 
815
- if (!description) return typeItYourself(prefix);
830
+ const ctx = repoContext(dir, repo, base);
831
+ const rejected = [];
816
832
 
817
- const examples = names.filter((n) => n.includes('/')).slice(0, 15);
818
833
  for (;;) {
819
- const res = await askAi(ai, namingPrompt(prefix, description, examples));
834
+ log(`${dim('│')}`);
835
+ const description = await askLine(
836
+ `${dim('│')} what do you want to do? ${dim('(any language)')}\n${dim('│')} ${dim('>')} `,
837
+ );
838
+ // An empty answer is the escape hatch out of the AI entirely.
839
+ if (!description) return typeItYourself();
840
+
841
+ const res = await askAi(ai, namingPrompt(ctx, description, rejected));
820
842
  if (!res.ok) {
821
- warn(`${ai.bin} failed — naming it yourself instead`);
843
+ warn(`${aiLabel(ai)} failed — name it yourself instead`);
822
844
  const first = (res.err || '').trim().split('\n')[0];
823
845
  if (first) log(`${dim('│')} ${dim(first.slice(0, 120))}`);
824
- return typeItYourself(prefix);
846
+ return typeItYourself();
825
847
  }
826
- const candidates = parseCandidates(res.out, prefix);
848
+ const candidates = parseCandidates(res.out);
827
849
  if (candidates.length === 0) {
828
- warn(`${ai.bin} returned nothing usable — naming it yourself instead`);
829
- return typeItYourself(prefix);
850
+ warn(`${aiLabel(ai)} returned nothing usable — name it yourself instead`);
851
+ return typeItYourself();
830
852
  }
831
- const choice = await chooseFromCandidates(candidates, prefix);
832
- if (choice.pick) return choice.pick;
853
+
854
+ const choice = await confirmCreate(candidates[0], ctx.base);
855
+ if (choice.create) return candidates[0];
856
+ if (choice.edit) return typeItYourself(candidates[0]);
857
+ // Rejected: remember every suggestion from this round so the next prompt
858
+ // cannot come back with a near-identical name.
859
+ rejected.push(...candidates);
833
860
  }
834
861
  }
835
-
836
862
  // ── width / box ──────────────────────────────────────────────────────────────
837
863
 
838
864
  // Rough East Asian Width: 全角 CJK + 全角ラテン + half-symbols treated as wide.
@@ -974,7 +1000,7 @@ async function main() {
974
1000
  die('E_VALIDATION', 'a branch name is required — `gwqadd <branch>`');
975
1001
  }
976
1002
  // composeBranchName only ever returns a name git has already accepted.
977
- branch = await composeBranchName(cwd);
1003
+ branch = await composeBranchName(cwd, repo, base);
978
1004
  }
979
1005
 
980
1006
  const branchExisted = hasLocalBranch(cwd, branch);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gwqadd",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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": {