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.
- package/README.md +69 -1
- package/bin/gwqadd.mjs +333 -29
- 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
|
|
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}
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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('
|
|
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) {
|