gwqadd 0.2.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.
- package/README.md +34 -38
- package/bin/gwqadd.mjs +151 -160
- 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
|
|
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
|
-
│
|
|
28
|
-
│
|
|
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
|
-
│
|
|
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
|
-
|
|
46
|
-
|
|
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,43 @@ gwqadd feat/logout --from main
|
|
|
107
97
|
|
|
108
98
|
## Naming help
|
|
109
99
|
|
|
110
|
-
|
|
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 |
|
|
111
108
|
|
|
112
|
-
**
|
|
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.
|
|
109
|
+
**Why the suggestions fit.** The prompt is not just your sentence. It carries:
|
|
116
110
|
|
|
117
|
-
|
|
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`:
|
|
118
119
|
|
|
119
120
|
```
|
|
120
121
|
claude -p → codex exec → opencode run → gemini -p
|
|
121
122
|
```
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 |
|
|
136
132
|
|
|
137
|
-
**What is sent, and when.** Nothing leaves your machine until you
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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.
|
|
141
137
|
|
|
142
138
|
None of this happens when you pass a branch name on the command line, or when
|
|
143
139
|
there is no terminal. Scripts and agents keep the plain, silent contract.
|
|
@@ -184,7 +180,7 @@ gwqadd [options] [<branch>]
|
|
|
184
180
|
| `-h`, `--help` | show help |
|
|
185
181
|
| `-V`, `--version` | show version |
|
|
186
182
|
|
|
187
|
-
Run it with no branch name and it
|
|
183
|
+
Run it with no branch name and it asks what you want to do, then confirms once.
|
|
188
184
|
|
|
189
185
|
## For scripts and AI agents
|
|
190
186
|
|
package/bin/gwqadd.mjs
CHANGED
|
@@ -42,20 +42,25 @@ EXAMPLES
|
|
|
42
42
|
${PKG} feat/login branch off HEAD, worktree, cd
|
|
43
43
|
${PKG} feat/login --from main branch off main wherever you happen to be
|
|
44
44
|
${PKG} hotfix/x --expires 1d gwq will mark it expired after a day
|
|
45
|
-
${PKG}
|
|
45
|
+
${PKG} describe the work, confirm, done
|
|
46
46
|
${PKG} -n --json feat/login machine-readable, shell stays put
|
|
47
47
|
|
|
48
48
|
NAMING HELP
|
|
49
|
-
Run ${PKG} with no branch name and it asks
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
54
58
|
|
|
55
59
|
The AI is whichever of these is on PATH — claude, codex, opencode, gemini —
|
|
56
|
-
invoked headlessly. Nothing is sent anywhere until you
|
|
57
|
-
and
|
|
58
|
-
GWQADD_AI='<cmd>'; disable with --no-ai or GWQADD_AI=off
|
|
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.
|
|
59
64
|
|
|
60
65
|
None of this happens with a branch name on the command line, or without a
|
|
61
66
|
terminal — scripts and agents keep the plain, silent contract.
|
|
@@ -466,12 +471,16 @@ async function confirmYesNo(question) {
|
|
|
466
471
|
return c === 0x0d || c === 0x0a || c === 0x79 || c === 0x59; // Enter, y, Y
|
|
467
472
|
}
|
|
468
473
|
|
|
469
|
-
// A line, not a keypress —
|
|
470
|
-
// prompt goes to stderr so stdout stays the path channel (I1).
|
|
471
|
-
|
|
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 = '') {
|
|
472
479
|
const rl = createInterface({ input: process.stdin, output: stderr, terminal: true });
|
|
473
480
|
try {
|
|
474
|
-
|
|
481
|
+
const answer = rl.question(question);
|
|
482
|
+
if (initial) rl.write(initial);
|
|
483
|
+
return (await answer).trim();
|
|
475
484
|
} finally {
|
|
476
485
|
rl.close();
|
|
477
486
|
}
|
|
@@ -550,25 +559,22 @@ function defaultBranch(dir) {
|
|
|
550
559
|
return head ? head.replace(/^refs\/remotes\/origin\//, '') : '';
|
|
551
560
|
}
|
|
552
561
|
|
|
553
|
-
// ── naming
|
|
562
|
+
// ── naming (interactive only) ────────────────────────────────────────────────
|
|
554
563
|
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
// not be offered `feat/` first — house style beats the spec.
|
|
561
|
-
function branchPrefixes(dir) {
|
|
562
|
-
const out = gitOut(dir, [
|
|
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, [
|
|
563
569
|
'for-each-ref', '--format=%(refname:short)', '--sort=-committerdate',
|
|
564
570
|
'--count=300', 'refs/heads', 'refs/remotes/origin',
|
|
565
571
|
]);
|
|
566
572
|
const names = new Set();
|
|
567
|
-
for (let b of
|
|
573
|
+
for (let b of refs.split('\n')) {
|
|
568
574
|
b = b.trim();
|
|
569
575
|
if (!b || b === 'HEAD' || b === 'origin/HEAD') continue;
|
|
570
576
|
if (b.startsWith('origin/')) b = b.slice('origin/'.length);
|
|
571
|
-
names.add(b); //
|
|
577
|
+
names.add(b); // a branch on both local and origin must count once
|
|
572
578
|
}
|
|
573
579
|
const counts = new Map();
|
|
574
580
|
for (const b of names) {
|
|
@@ -577,55 +583,55 @@ function branchPrefixes(dir) {
|
|
|
577
583
|
const p = b.slice(0, i);
|
|
578
584
|
counts.set(p, (counts.get(p) ?? 0) + 1);
|
|
579
585
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
return
|
|
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
|
+
};
|
|
598
610
|
}
|
|
599
611
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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('>')} `);
|
|
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.';
|
|
616
617
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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');
|
|
629
635
|
}
|
|
630
636
|
|
|
631
637
|
// ── the AI layer ─────────────────────────────────────────────────────────────
|
|
@@ -641,6 +647,9 @@ const AI_CLIS = [
|
|
|
641
647
|
{ bin: 'gemini', args: ['-p'] },
|
|
642
648
|
];
|
|
643
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
|
+
|
|
644
653
|
function detectAi() {
|
|
645
654
|
if (values['no-ai']) return null;
|
|
646
655
|
const override = values.ai ?? process.env.GWQADD_AI;
|
|
@@ -653,21 +662,6 @@ function detectAi() {
|
|
|
653
662
|
return AI_CLIS.find((c) => commandExists(c.bin)) ?? null;
|
|
654
663
|
}
|
|
655
664
|
|
|
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
665
|
// spawnSync would freeze the terminal for the 6-8 seconds these CLIs take to
|
|
672
666
|
// boot, with no sign of life. Async plus an elapsed counter is the difference
|
|
673
667
|
// between "working" and "hung".
|
|
@@ -696,31 +690,33 @@ function runAi(ai, prompt) {
|
|
|
696
690
|
}
|
|
697
691
|
|
|
698
692
|
async function askAi(ai, prompt) {
|
|
699
|
-
const label = `${ai.bin}${ai.args.length ? ' ' + ai.args.join(' ') : ''}`;
|
|
700
693
|
const started = Date.now();
|
|
701
694
|
let tick;
|
|
702
695
|
if (stderrTTY) {
|
|
703
696
|
tick = setInterval(() => {
|
|
704
697
|
const s = Math.round((Date.now() - started) / 1000);
|
|
705
|
-
stderr.write(`\r${dim('│')}
|
|
698
|
+
stderr.write(`\r${dim('│')} ${dim('thinking')} ${dim(`(${aiLabel(ai)}, ${s}s)`)} `);
|
|
706
699
|
}, 250);
|
|
707
700
|
} else {
|
|
708
|
-
log(`${dim('│')} asking ${
|
|
701
|
+
log(`${dim('│')} asking ${aiLabel(ai)}…`);
|
|
709
702
|
}
|
|
710
703
|
try {
|
|
711
704
|
return await runAi(ai, prompt);
|
|
712
705
|
} finally {
|
|
713
706
|
if (tick) {
|
|
714
707
|
clearInterval(tick);
|
|
715
|
-
stderr.write(`\r${' '.repeat(
|
|
708
|
+
stderr.write(`\r${' '.repeat(48)}\r`);
|
|
716
709
|
}
|
|
717
710
|
}
|
|
718
711
|
}
|
|
719
712
|
|
|
713
|
+
const validBranchName = (name) =>
|
|
714
|
+
!!name && spawnSync('git', ['check-ref-format', '--branch', name], { stdio: 'ignore' }).status === 0;
|
|
715
|
+
|
|
720
716
|
// Models add bullets, backticks and commentary no matter how firmly asked not
|
|
721
717
|
// to. Keep only things that could actually be branch names, and let git have
|
|
722
718
|
// the final say on each one.
|
|
723
|
-
function parseCandidates(text
|
|
719
|
+
function parseCandidates(text) {
|
|
724
720
|
const cleaned = text.split('\n')
|
|
725
721
|
.map((l) => l.trim())
|
|
726
722
|
.map((l) => l.replace(/^[-*•]\s*/, ''))
|
|
@@ -730,109 +726,104 @@ function parseCandidates(text, prefix) {
|
|
|
730
726
|
.filter((l) => !/\s/.test(l))
|
|
731
727
|
.filter((l) => /^[A-Za-z0-9._/-]+$/.test(l));
|
|
732
728
|
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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(/^[^/]+\//, '')}`));
|
|
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;
|
|
750
738
|
}
|
|
751
739
|
|
|
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
740
|
function slugify(s) {
|
|
758
|
-
|
|
759
|
-
if (/[^\x00-\x7F]/.test(t)) return t.replace(/\s+/g, '-');
|
|
760
|
-
return t.toLowerCase()
|
|
741
|
+
return s.trim().toLowerCase()
|
|
761
742
|
.replace(/[\s_]+/g, '-')
|
|
762
743
|
.replace(/[^a-z0-9./-]/g, '-')
|
|
763
744
|
.replace(/-{2,}/g, '-')
|
|
764
745
|
.replace(/(^[-/]+)|([-/]+$)/g, '');
|
|
765
746
|
}
|
|
766
747
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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 = '') {
|
|
770
754
|
for (;;) {
|
|
771
|
-
const raw = await askLine(`${dim('│')}
|
|
755
|
+
const raw = await askLine(`${dim('│')} branch name ${dim('(ascii)')}: `, initial);
|
|
772
756
|
if (!raw) die('E_INTERRUPTED', 'cancelled');
|
|
773
|
-
|
|
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);
|
|
774
763
|
if (validBranchName(name)) return name;
|
|
775
764
|
warn(`'${name}' is not a valid branch name — try again`);
|
|
765
|
+
initial = raw;
|
|
776
766
|
}
|
|
777
767
|
}
|
|
778
768
|
|
|
779
|
-
|
|
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
|
+
);
|
|
780
779
|
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
780
|
const buf = await waitForKey();
|
|
788
|
-
stderr.write('\n');
|
|
789
|
-
if (buf.includes(0x03) || buf[0] === 0x1b) die('E_INTERRUPTED', 'cancelled');
|
|
781
|
+
if (buf.includes(0x03) || buf[0] === 0x1b) { stderr.write('\n'); die('E_INTERRUPTED', 'cancelled'); }
|
|
790
782
|
const c = buf[0];
|
|
791
|
-
if (c ===
|
|
792
|
-
if (c ===
|
|
793
|
-
|
|
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] };
|
|
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 }; }
|
|
797
786
|
}
|
|
798
787
|
}
|
|
799
788
|
|
|
800
|
-
// The whole interactive path. Returns a branch name
|
|
801
|
-
//
|
|
802
|
-
async function composeBranchName(dir) {
|
|
803
|
-
const { counts, names } = branchPrefixes(dir);
|
|
804
|
-
const prefix = await pickType(typeMenu(counts));
|
|
805
|
-
|
|
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) {
|
|
806
792
|
const ai = detectAi();
|
|
807
|
-
|
|
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
|
-
: '';
|
|
793
|
+
if (!ai) return typeItYourself();
|
|
814
794
|
|
|
815
|
-
|
|
795
|
+
const ctx = repoContext(dir, repo, base);
|
|
796
|
+
const rejected = [];
|
|
816
797
|
|
|
817
|
-
const examples = names.filter((n) => n.includes('/')).slice(0, 15);
|
|
818
798
|
for (;;) {
|
|
819
|
-
|
|
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));
|
|
820
807
|
if (!res.ok) {
|
|
821
|
-
warn(`${ai
|
|
808
|
+
warn(`${aiLabel(ai)} failed — name it yourself instead`);
|
|
822
809
|
const first = (res.err || '').trim().split('\n')[0];
|
|
823
810
|
if (first) log(`${dim('│')} ${dim(first.slice(0, 120))}`);
|
|
824
|
-
return typeItYourself(
|
|
811
|
+
return typeItYourself();
|
|
825
812
|
}
|
|
826
|
-
const candidates = parseCandidates(res.out
|
|
813
|
+
const candidates = parseCandidates(res.out);
|
|
827
814
|
if (candidates.length === 0) {
|
|
828
|
-
warn(`${ai
|
|
829
|
-
return typeItYourself(
|
|
815
|
+
warn(`${aiLabel(ai)} returned nothing usable — name it yourself instead`);
|
|
816
|
+
return typeItYourself();
|
|
830
817
|
}
|
|
831
|
-
|
|
832
|
-
|
|
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);
|
|
833
825
|
}
|
|
834
826
|
}
|
|
835
|
-
|
|
836
827
|
// ── width / box ──────────────────────────────────────────────────────────────
|
|
837
828
|
|
|
838
829
|
// Rough East Asian Width: 全角 CJK + 全角ラテン + half-symbols treated as wide.
|
|
@@ -974,7 +965,7 @@ async function main() {
|
|
|
974
965
|
die('E_VALIDATION', 'a branch name is required — `gwqadd <branch>`');
|
|
975
966
|
}
|
|
976
967
|
// composeBranchName only ever returns a name git has already accepted.
|
|
977
|
-
branch = await composeBranchName(cwd);
|
|
968
|
+
branch = await composeBranchName(cwd, repo, base);
|
|
978
969
|
}
|
|
979
970
|
|
|
980
971
|
const branchExisted = hasLocalBranch(cwd, branch);
|