gwqadd 0.1.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 +65 -1
- package/bin/gwqadd.mjs +328 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,25 @@ 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 asks one question:
|
|
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
|
+
│ what do you want to do? (any language)
|
|
28
|
+
│ > セッションが期限切れでも通ってしまう不具合を直す
|
|
29
|
+
│
|
|
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 → …
|
|
33
|
+
```
|
|
34
|
+
|
|
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.
|
|
37
|
+
|
|
19
38
|
## Install
|
|
20
39
|
|
|
21
40
|
```sh
|
|
@@ -76,6 +95,49 @@ So the fix is one flag:
|
|
|
76
95
|
gwqadd feat/logout --from main
|
|
77
96
|
```
|
|
78
97
|
|
|
98
|
+
## Naming help
|
|
99
|
+
|
|
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:
|
|
110
|
+
|
|
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`:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
claude -p → codex exec → opencode run → gemini -p
|
|
122
|
+
```
|
|
123
|
+
|
|
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.
|
|
127
|
+
|
|
128
|
+
| | |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| pick a different CLI | `--ai 'gemini -p'`, or `GWQADD_AI='gemini -p'` |
|
|
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.
|
|
137
|
+
|
|
138
|
+
None of this happens when you pass a branch name on the command line, or when
|
|
139
|
+
there is no terminal. Scripts and agents keep the plain, silent contract.
|
|
140
|
+
|
|
79
141
|
## What it does
|
|
80
142
|
|
|
81
143
|
1. Work out which repository you are in — any worktree of it will do.
|
|
@@ -107,6 +169,8 @@ gwqadd [options] [<branch>]
|
|
|
107
169
|
| `--cmd <name>` | function name emitted by `--init` (default: `gwqadd`) |
|
|
108
170
|
| `--from <ref>` | branch from this ref instead of the current HEAD |
|
|
109
171
|
| `--expires <dur>` | hand gwq an expiry (`1h`, `7d`, …) for a throwaway worktree |
|
|
172
|
+
| `--ai <cmd>` | AI CLI used to suggest names (default: autodetected) |
|
|
173
|
+
| `--no-ai` | never ask an AI, even when one is installed |
|
|
110
174
|
| `--no-submodules` | skip `git submodule update --init --recursive` |
|
|
111
175
|
| `-f`, `--force` | move a colliding worktree directory aside instead of failing |
|
|
112
176
|
| `-n`, `--no-cd` | do the work and report the path, but do not move the shell |
|
|
@@ -116,7 +180,7 @@ gwqadd [options] [<branch>]
|
|
|
116
180
|
| `-h`, `--help` | show help |
|
|
117
181
|
| `-V`, `--version` | show version |
|
|
118
182
|
|
|
119
|
-
Run it with no branch name and it asks
|
|
183
|
+
Run it with no branch name and it asks what you want to do, then confirms once.
|
|
120
184
|
|
|
121
185
|
## For scripts and AI agents
|
|
122
186
|
|
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,29 @@ 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} describe the work, confirm, done
|
|
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 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.
|
|
58
|
+
|
|
59
|
+
The AI is whichever of these is on PATH — claude, codex, opencode, gemini —
|
|
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.
|
|
64
|
+
|
|
65
|
+
None of this happens with a branch name on the command line, or without a
|
|
66
|
+
terminal — scripts and agents keep the plain, silent contract.
|
|
67
|
+
|
|
46
68
|
WHERE IT BRANCHES FROM
|
|
47
69
|
The current HEAD, exactly like \`git checkout -b\` — which means running this
|
|
48
70
|
inside a feature worktree branches off that feature, not off main. ${PKG}
|
|
@@ -106,6 +128,8 @@ try {
|
|
|
106
128
|
cmd: { type: 'string' },
|
|
107
129
|
from: { type: 'string' },
|
|
108
130
|
expires: { type: 'string' },
|
|
131
|
+
ai: { type: 'string' },
|
|
132
|
+
'no-ai': { type: 'boolean' },
|
|
109
133
|
'no-submodules': { type: 'boolean' },
|
|
110
134
|
force: { type: 'boolean', short: 'f' },
|
|
111
135
|
'no-cd': { type: 'boolean', short: 'n' },
|
|
@@ -447,12 +471,16 @@ async function confirmYesNo(question) {
|
|
|
447
471
|
return c === 0x0d || c === 0x0a || c === 0x79 || c === 0x59; // Enter, y, Y
|
|
448
472
|
}
|
|
449
473
|
|
|
450
|
-
// A line, not a keypress —
|
|
451
|
-
// prompt goes to stderr so stdout stays the path channel (I1).
|
|
452
|
-
|
|
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 = '') {
|
|
453
479
|
const rl = createInterface({ input: process.stdin, output: stderr, terminal: true });
|
|
454
480
|
try {
|
|
455
|
-
|
|
481
|
+
const answer = rl.question(question);
|
|
482
|
+
if (initial) rl.write(initial);
|
|
483
|
+
return (await answer).trim();
|
|
456
484
|
} finally {
|
|
457
485
|
rl.close();
|
|
458
486
|
}
|
|
@@ -531,6 +559,271 @@ function defaultBranch(dir) {
|
|
|
531
559
|
return head ? head.replace(/^refs\/remotes\/origin\//, '') : '';
|
|
532
560
|
}
|
|
533
561
|
|
|
562
|
+
// ── naming (interactive only) ────────────────────────────────────────────────
|
|
563
|
+
|
|
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, [
|
|
569
|
+
'for-each-ref', '--format=%(refname:short)', '--sort=-committerdate',
|
|
570
|
+
'--count=300', 'refs/heads', 'refs/remotes/origin',
|
|
571
|
+
]);
|
|
572
|
+
const names = new Set();
|
|
573
|
+
for (let b of refs.split('\n')) {
|
|
574
|
+
b = b.trim();
|
|
575
|
+
if (!b || b === 'HEAD' || b === 'origin/HEAD') continue;
|
|
576
|
+
if (b.startsWith('origin/')) b = b.slice('origin/'.length);
|
|
577
|
+
names.add(b); // a branch on both local and origin must count once
|
|
578
|
+
}
|
|
579
|
+
const counts = new Map();
|
|
580
|
+
for (const b of names) {
|
|
581
|
+
const i = b.indexOf('/');
|
|
582
|
+
if (i <= 0) continue;
|
|
583
|
+
const p = b.slice(0, i);
|
|
584
|
+
counts.set(p, (counts.get(p) ?? 0) + 1);
|
|
585
|
+
}
|
|
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
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
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.';
|
|
617
|
+
|
|
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');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ── the AI layer ─────────────────────────────────────────────────────────────
|
|
638
|
+
|
|
639
|
+
// Delegate to whatever agent CLI the user already has, rather than embedding an
|
|
640
|
+
// API client. No key to store, no account to create, no new dependency, and it
|
|
641
|
+
// bills to whatever they already pay for. Order is by how reliably each one
|
|
642
|
+
// answers a bare prompt headlessly.
|
|
643
|
+
const AI_CLIS = [
|
|
644
|
+
{ bin: 'claude', args: ['-p'] },
|
|
645
|
+
{ bin: 'codex', args: ['exec'] },
|
|
646
|
+
{ bin: 'opencode', args: ['run'] },
|
|
647
|
+
{ bin: 'gemini', args: ['-p'] },
|
|
648
|
+
];
|
|
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
|
+
|
|
653
|
+
function detectAi() {
|
|
654
|
+
if (values['no-ai']) return null;
|
|
655
|
+
const override = values.ai ?? process.env.GWQADD_AI;
|
|
656
|
+
if (override != null && override !== '') {
|
|
657
|
+
if (['off', '0', 'false', 'none'].includes(override)) return null;
|
|
658
|
+
// Split on whitespace only: quoting rules would be a shell of our own.
|
|
659
|
+
const parts = override.split(/\s+/).filter(Boolean);
|
|
660
|
+
return { bin: parts[0], args: parts.slice(1) };
|
|
661
|
+
}
|
|
662
|
+
return AI_CLIS.find((c) => commandExists(c.bin)) ?? null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// spawnSync would freeze the terminal for the 6-8 seconds these CLIs take to
|
|
666
|
+
// boot, with no sign of life. Async plus an elapsed counter is the difference
|
|
667
|
+
// between "working" and "hung".
|
|
668
|
+
function runAi(ai, prompt) {
|
|
669
|
+
return new Promise((resolve) => {
|
|
670
|
+
let child;
|
|
671
|
+
try {
|
|
672
|
+
child = spawn(ai.bin, [...ai.args, prompt], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
673
|
+
} catch (err) {
|
|
674
|
+
return resolve({ ok: false, out: '', err: String(err?.message ?? err) });
|
|
675
|
+
}
|
|
676
|
+
let out = '';
|
|
677
|
+
let err = '';
|
|
678
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
679
|
+
child.stderr.on('data', (d) => { err += d; });
|
|
680
|
+
const kill = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 60_000);
|
|
681
|
+
child.on('error', (e) => {
|
|
682
|
+
clearTimeout(kill);
|
|
683
|
+
resolve({ ok: false, out: '', err: String(e?.message ?? e) });
|
|
684
|
+
});
|
|
685
|
+
child.on('close', (code) => {
|
|
686
|
+
clearTimeout(kill);
|
|
687
|
+
resolve({ ok: code === 0, out, err });
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function askAi(ai, prompt) {
|
|
693
|
+
const started = Date.now();
|
|
694
|
+
let tick;
|
|
695
|
+
if (stderrTTY) {
|
|
696
|
+
tick = setInterval(() => {
|
|
697
|
+
const s = Math.round((Date.now() - started) / 1000);
|
|
698
|
+
stderr.write(`\r${dim('│')} ${dim('thinking')} ${dim(`(${aiLabel(ai)}, ${s}s)`)} `);
|
|
699
|
+
}, 250);
|
|
700
|
+
} else {
|
|
701
|
+
log(`${dim('│')} asking ${aiLabel(ai)}…`);
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
return await runAi(ai, prompt);
|
|
705
|
+
} finally {
|
|
706
|
+
if (tick) {
|
|
707
|
+
clearInterval(tick);
|
|
708
|
+
stderr.write(`\r${' '.repeat(48)}\r`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const validBranchName = (name) =>
|
|
714
|
+
!!name && spawnSync('git', ['check-ref-format', '--branch', name], { stdio: 'ignore' }).status === 0;
|
|
715
|
+
|
|
716
|
+
// Models add bullets, backticks and commentary no matter how firmly asked not
|
|
717
|
+
// to. Keep only things that could actually be branch names, and let git have
|
|
718
|
+
// the final say on each one.
|
|
719
|
+
function parseCandidates(text) {
|
|
720
|
+
const cleaned = text.split('\n')
|
|
721
|
+
.map((l) => l.trim())
|
|
722
|
+
.map((l) => l.replace(/^[-*•]\s*/, ''))
|
|
723
|
+
.map((l) => l.replace(/^\d+[.)]\s*/, ''))
|
|
724
|
+
.map((l) => l.replace(/^[`'"]+|[`'"]+$/g, ''))
|
|
725
|
+
.filter(Boolean)
|
|
726
|
+
.filter((l) => !/\s/.test(l))
|
|
727
|
+
.filter((l) => /^[A-Za-z0-9._/-]+$/.test(l));
|
|
728
|
+
|
|
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;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function slugify(s) {
|
|
741
|
+
return s.trim().toLowerCase()
|
|
742
|
+
.replace(/[\s_]+/g, '-')
|
|
743
|
+
.replace(/[^a-z0-9./-]/g, '-')
|
|
744
|
+
.replace(/-{2,}/g, '-')
|
|
745
|
+
.replace(/(^[-/]+)|([-/]+$)/g, '');
|
|
746
|
+
}
|
|
747
|
+
|
|
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 = '') {
|
|
754
|
+
for (;;) {
|
|
755
|
+
const raw = await askLine(`${dim('│')} branch name ${dim('(ascii)')}: `, initial);
|
|
756
|
+
if (!raw) die('E_INTERRUPTED', 'cancelled');
|
|
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);
|
|
763
|
+
if (validBranchName(name)) return name;
|
|
764
|
+
warn(`'${name}' is not a valid branch name — try again`);
|
|
765
|
+
initial = raw;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
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
|
+
);
|
|
779
|
+
for (;;) {
|
|
780
|
+
const buf = await waitForKey();
|
|
781
|
+
if (buf.includes(0x03) || buf[0] === 0x1b) { stderr.write('\n'); die('E_INTERRUPTED', 'cancelled'); }
|
|
782
|
+
const c = buf[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 }; }
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
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) {
|
|
792
|
+
const ai = detectAi();
|
|
793
|
+
if (!ai) return typeItYourself();
|
|
794
|
+
|
|
795
|
+
const ctx = repoContext(dir, repo, base);
|
|
796
|
+
const rejected = [];
|
|
797
|
+
|
|
798
|
+
for (;;) {
|
|
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));
|
|
807
|
+
if (!res.ok) {
|
|
808
|
+
warn(`${aiLabel(ai)} failed — name it yourself instead`);
|
|
809
|
+
const first = (res.err || '').trim().split('\n')[0];
|
|
810
|
+
if (first) log(`${dim('│')} ${dim(first.slice(0, 120))}`);
|
|
811
|
+
return typeItYourself();
|
|
812
|
+
}
|
|
813
|
+
const candidates = parseCandidates(res.out);
|
|
814
|
+
if (candidates.length === 0) {
|
|
815
|
+
warn(`${aiLabel(ai)} returned nothing usable — name it yourself instead`);
|
|
816
|
+
return typeItYourself();
|
|
817
|
+
}
|
|
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);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
534
827
|
// ── width / box ──────────────────────────────────────────────────────────────
|
|
535
828
|
|
|
536
829
|
// Rough East Asian Width: 全角 CJK + 全角ラテン + half-symbols treated as wide.
|
|
@@ -643,39 +936,41 @@ async function main() {
|
|
|
643
936
|
const repo = resolveRepo();
|
|
644
937
|
const cwd = process.cwd();
|
|
645
938
|
|
|
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
939
|
const base = resolveBase(cwd, values.from);
|
|
665
|
-
const branchExisted = hasLocalBranch(cwd, branch);
|
|
666
940
|
|
|
941
|
+
// Where you are and what you are cutting from come first — before any naming,
|
|
942
|
+
// because both are things you would want to correct before investing in a
|
|
943
|
+
// branch name, and the base especially is easy to get silently wrong.
|
|
944
|
+
log(`${dim('┌')} ${bold(PKG)} ${dim(repo.name)}`);
|
|
667
945
|
log(`${dim('│')} repo ${cyan(repo.name)} ${dim(repo.root)}`);
|
|
668
946
|
if (repo.inLinkedWorktree) {
|
|
669
947
|
log(`${dim('│')} cwd ${dim('a linked worktree of it')} ${dim(repo.cwdTop)}`);
|
|
670
948
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
949
|
+
log(`${dim('│')} base ${cyan(base.ref)} ${dim(base.sha.slice(0, 7))}`);
|
|
950
|
+
// The silent version of this is the whole reason --from exists: running
|
|
951
|
+
// inside a feature worktree branches off that feature, not off main.
|
|
952
|
+
const def = defaultBranch(cwd);
|
|
953
|
+
if (!values.from && def && base.ref !== def && base.ref !== `origin/${def}`) {
|
|
954
|
+
warn(`branching from ${base.ref}, not the default branch — pass \`--from ${def}\` if that is not what you meant`);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
let branch = positionals[0];
|
|
958
|
+
if (branch) {
|
|
959
|
+
// git would reject this later with a less obvious message.
|
|
960
|
+
if (!validBranchName(branch)) {
|
|
961
|
+
die('E_VALIDATION', `'${branch}' is not a valid branch name`);
|
|
962
|
+
}
|
|
963
|
+
} else {
|
|
964
|
+
if (isNonInteractive) {
|
|
965
|
+
die('E_VALIDATION', 'a branch name is required — `gwqadd <branch>`');
|
|
678
966
|
}
|
|
967
|
+
// composeBranchName only ever returns a name git has already accepted.
|
|
968
|
+
branch = await composeBranchName(cwd, repo, base);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const branchExisted = hasLocalBranch(cwd, branch);
|
|
972
|
+
if (branchExisted) {
|
|
973
|
+
log(`${dim('│')} ${dim(`${branch} already exists — the base above is not used`)}`);
|
|
679
974
|
}
|
|
680
975
|
|
|
681
976
|
git(cwd, ['worktree', 'prune'], { stdio: 'ignore' }); // clear hand-deleted leftovers
|
|
@@ -695,7 +990,7 @@ async function main() {
|
|
|
695
990
|
let addArgs;
|
|
696
991
|
if (branchExisted) {
|
|
697
992
|
addArgs = [branch];
|
|
698
|
-
log(`${dim('│')} ${dim('
|
|
993
|
+
log(`${dim('│')} ${dim('creating its worktree only')}`);
|
|
699
994
|
} else if (values.from) {
|
|
700
995
|
const made = git(cwd, ['branch', branch, values.from]);
|
|
701
996
|
if (made.status !== 0) {
|