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.
- package/README.md +39 -38
- package/bin/gwqadd.mjs +190 -164
- 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,48 @@ 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 |
|
|
108
|
+
|
|
109
|
+
**Why the suggestions fit.** The prompt is not just your sentence. It carries:
|
|
111
110
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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 {
|
|
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}
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
57
|
-
and
|
|
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 —
|
|
470
|
-
// prompt goes to stderr so stdout stays the path channel (I1).
|
|
471
|
-
|
|
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
|
-
|
|
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
|
|
567
|
+
// ── naming (interactive only) ────────────────────────────────────────────────
|
|
554
568
|
|
|
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, [
|
|
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
|
|
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); //
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
return
|
|
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
|
-
|
|
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('>')} `);
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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], {
|
|
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('│')}
|
|
733
|
+
stderr.write(`\r${dim('│')} ${dim('thinking')} ${dim(`(${aiLabel(ai)}, ${s}s)`)} `);
|
|
706
734
|
}, 250);
|
|
707
735
|
} else {
|
|
708
|
-
log(`${dim('│')} asking ${
|
|
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(
|
|
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
|
|
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
|
|
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(/^[^/]+\//, '')}`));
|
|
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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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('│')}
|
|
790
|
+
const raw = await askLine(`${dim('│')} branch name ${dim('(ascii)')}: `, initial);
|
|
772
791
|
if (!raw) die('E_INTERRUPTED', 'cancelled');
|
|
773
|
-
|
|
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
|
-
|
|
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 ===
|
|
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] };
|
|
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
|
|
801
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
846
|
+
return typeItYourself();
|
|
825
847
|
}
|
|
826
|
-
const candidates = parseCandidates(res.out
|
|
848
|
+
const candidates = parseCandidates(res.out);
|
|
827
849
|
if (candidates.length === 0) {
|
|
828
|
-
warn(`${ai
|
|
829
|
-
return typeItYourself(
|
|
850
|
+
warn(`${aiLabel(ai)} returned nothing usable — name it yourself instead`);
|
|
851
|
+
return typeItYourself();
|
|
830
852
|
}
|
|
831
|
-
|
|
832
|
-
|
|
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);
|