gwqadd 0.4.3 → 0.5.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 +74 -3
- package/bin/gwqadd.mjs +424 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -139,9 +139,14 @@ One question, one confirmation:
|
|
|
139
139
|
**The AI** is whichever of these is on your `PATH`:
|
|
140
140
|
|
|
141
141
|
```
|
|
142
|
-
claude -p → codex exec → opencode run → gemini -p
|
|
142
|
+
claude -p → codex exec (if Claude fails) → opencode run → gemini -p
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
+
Automatic detection tries Claude first and falls back to `codex exec` when it
|
|
146
|
+
fails or returns no usable branch name, then continues through the other
|
|
147
|
+
available CLIs. An explicit `--ai '<cmd>'` or `GWQADD_AI='<cmd>'` selects one
|
|
148
|
+
command and keeps the existing manual fallback if that command fails.
|
|
149
|
+
|
|
145
150
|
No API key to obtain, no account to create — it uses what you already have.
|
|
146
151
|
Expect 6–8 seconds, almost all of it the CLI's own start-up; an elapsed counter
|
|
147
152
|
runs while it works.
|
|
@@ -169,8 +174,9 @@ there is no terminal. Scripts and agents keep the plain, silent contract.
|
|
|
169
174
|
1. Work out which repository you are in — any worktree of it will do.
|
|
170
175
|
2. Create the branch and its worktree. If the branch already exists, create
|
|
171
176
|
just the worktree. If both exist, go there.
|
|
172
|
-
3.
|
|
173
|
-
4.
|
|
177
|
+
3. Copy the Git-ignored files it does not have yet from the main working tree.
|
|
178
|
+
4. `git submodule update --init --recursive` when the tree has submodules.
|
|
179
|
+
5. Hand the path back so the shell can `cd` there.
|
|
174
180
|
|
|
175
181
|
Re-running is safe.
|
|
176
182
|
|
|
@@ -182,6 +188,69 @@ Re-running is safe.
|
|
|
182
188
|
- A branch **`gwqadd` created** *is* rolled back if the worktree could not be
|
|
183
189
|
made — otherwise `git worktree add -b`'s half-finished state would turn every
|
|
184
190
|
later attempt into `branch already exists`.
|
|
191
|
+
- The ignored-file copy never overwrites and never deletes. A file the new
|
|
192
|
+
worktree already has is left exactly as it is.
|
|
193
|
+
|
|
194
|
+
## Your .env comes with you
|
|
195
|
+
|
|
196
|
+
A fresh worktree has everything git tracks and nothing it does not, which means
|
|
197
|
+
no `.env`, no credentials, no local config — nothing the project needs to
|
|
198
|
+
actually run. So they are copied over:
|
|
199
|
+
|
|
200
|
+
```console
|
|
201
|
+
$ gwqadd feat/login
|
|
202
|
+
┌ gwqadd api
|
|
203
|
+
│ repo api /Users/alice/ghq/github.com/alice/api
|
|
204
|
+
│ base main 8f2c1a9
|
|
205
|
+
│ copying ignored files from /Users/alice/ghq/github.com/alice/api
|
|
206
|
+
│ copied 6 ignored file(s), skipped 41932 in node_modules, .next
|
|
207
|
+
└ ✓ feat/login → /Users/alice/worktrees/github.com/alice/api/feat-login
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The source is the **main working tree**, not the worktree you happen to be
|
|
211
|
+
standing in: ignored files belong to the repository, not to a branch. "Ignored"
|
|
212
|
+
means whatever `git ls-files --others --ignored --exclude-standard` reports, so
|
|
213
|
+
`.git/info/exclude` and your machine's global `core.excludesFile` count too.
|
|
214
|
+
|
|
215
|
+
Dependency and build directories are **not** copied. They are reproducible from
|
|
216
|
+
what git does track, and copying one is slow and frequently wrong — a `.next`
|
|
217
|
+
cache carries absolute paths, and a half-filled `node_modules` is worse than an
|
|
218
|
+
empty one. git has no idea which ignored paths are regenerable: `--directory`
|
|
219
|
+
only tells you a directory is ignored as a whole, and that is just as true of
|
|
220
|
+
`.secrets/`, while a size budget would give a different answer on every machine.
|
|
221
|
+
So the exclusion is by name, the list is fixed, and every run says how many
|
|
222
|
+
files it skipped and which of these they were in:
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
.angular .astro .cache .dart_tool .direnv .docusaurus .eggs .gradle
|
|
226
|
+
.mypy_cache .next .nuxt .nyc_output .output .parcel-cache .pnpm-store
|
|
227
|
+
.pytest_cache .ruff_cache .sass-cache .serverless .stack-work
|
|
228
|
+
.svelte-kit .terraform .terragrunt-cache .tox .turbo .venv
|
|
229
|
+
.virtualenvs .vite .yarn Carthage Pods __pycache__ _build
|
|
230
|
+
bower_components build coverage deps dist jspm_packages node_modules
|
|
231
|
+
out site-packages target tmp vendor venv
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The worktrees of this repository are skipped as well — that one is not a
|
|
235
|
+
guess but a reading of `git worktree list`, and it matters when gwq's basedir
|
|
236
|
+
lives inside the repository, where worktrees would otherwise copy each other.
|
|
237
|
+
|
|
238
|
+
Everything else sitting in the directory gwq puts worktrees in is skipped too — a
|
|
239
|
+
`.bak-` this tool moved aside with `-f`, or a worktree whose `.git` file went
|
|
240
|
+
missing — because each of those is another full checkout of the repository.
|
|
241
|
+
|
|
242
|
+
Relative symlinks stay relative, so a copied `.secrets/bin/key -> ../real/key`
|
|
243
|
+
does not end up pointing back into the main working tree.
|
|
244
|
+
|
|
245
|
+
Nothing is overwritten and nothing is deleted, so an `.env` you edited inside a
|
|
246
|
+
worktree stays yours and re-running is a no-op. A copy that fails is a warning,
|
|
247
|
+
never a failed run: the worktree is created either way. In `--json` that trouble
|
|
248
|
+
is reported in the payload instead — the copy did its job when
|
|
249
|
+
`ignoredFiles.enabled` is true, `ignoredFiles.error` is null and
|
|
250
|
+
`ignoredFiles.failed` is 0.
|
|
251
|
+
|
|
252
|
+
`--no-copy-ignored-files` turns it off. `--copy-ignored-files` is the default
|
|
253
|
+
and is accepted so a script can say so out loud.
|
|
185
254
|
|
|
186
255
|
## Usage
|
|
187
256
|
|
|
@@ -200,6 +269,8 @@ gwqadd [options] [<branch>]
|
|
|
200
269
|
| `--random` | skip the questions and generate a name |
|
|
201
270
|
| `--no-random` | start by describing the work instead of rolling a name |
|
|
202
271
|
| `--no-submodules` | skip `git submodule update --init --recursive` |
|
|
272
|
+
| `--copy-ignored-files` | copy the repository's Git-ignored files in (the default) |
|
|
273
|
+
| `--no-copy-ignored-files` | do not copy them |
|
|
203
274
|
| `-f`, `--force` | move a colliding worktree directory aside instead of failing |
|
|
204
275
|
| `-n`, `--no-cd` | do the work and report the path, but do not move the shell |
|
|
205
276
|
| `--json` | stdout = 1-line JSON |
|
package/bin/gwqadd.mjs
CHANGED
|
@@ -5,10 +5,10 @@ import { parseArgs } from 'node:util';
|
|
|
5
5
|
import { Buffer } from 'node:buffer';
|
|
6
6
|
import {
|
|
7
7
|
readFileSync, existsSync, readdirSync, renameSync, realpathSync,
|
|
8
|
-
mkdtempSync, rmSync,
|
|
8
|
+
mkdtempSync, rmSync, cpSync, lstatSync, mkdirSync,
|
|
9
9
|
} from 'node:fs';
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
|
-
import { join as joinPath } from 'node:path';
|
|
11
|
+
import { join as joinPath, dirname, resolve as resolvePath, sep } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { createInterface } from 'node:readline/promises';
|
|
14
14
|
|
|
@@ -38,6 +38,11 @@ OPTIONS
|
|
|
38
38
|
--random skip the questions and generate a name
|
|
39
39
|
--no-random start by describing the work instead of rolling a name
|
|
40
40
|
--no-submodules skip \`git submodule update --init --recursive\`
|
|
41
|
+
--copy-ignored-files
|
|
42
|
+
copy the repository's Git-ignored files in — the default,
|
|
43
|
+
accepted so a script can say so out loud
|
|
44
|
+
--no-copy-ignored-files
|
|
45
|
+
do not copy them
|
|
41
46
|
-f, --force move a colliding worktree directory aside instead of failing
|
|
42
47
|
-n, --no-cd do the work and report the path, but do not move the shell
|
|
43
48
|
--json stdout = 1-line JSON
|
|
@@ -71,10 +76,11 @@ NAMING HELP
|
|
|
71
76
|
modified, so the AI picks the prefix and the wording that match the repo. A
|
|
72
77
|
rejected suggestion is passed back as an exclusion, so "n" does not return it.
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
and nothing is created until you
|
|
77
|
-
|
|
79
|
+
Automatic detection tries claude -p first, then codex exec if Claude fails or
|
|
80
|
+
returns no usable name, followed by opencode run and gemini -p. Nothing is
|
|
81
|
+
sent anywhere until you answer the question, and nothing is created until you
|
|
82
|
+
confirm. Override with --ai '<cmd>' or GWQADD_AI='<cmd>' to use one command
|
|
83
|
+
explicitly; disable with --no-ai or GWQADD_AI=off, which leaves a plain
|
|
78
84
|
prompt for an ASCII name.
|
|
79
85
|
|
|
80
86
|
--no-random (or GWQADD_RANDOM=off) starts at the description prompt instead.
|
|
@@ -94,19 +100,71 @@ WHAT IT DOES
|
|
|
94
100
|
1. work out which repository you are in (any worktree of it will do)
|
|
95
101
|
2. create the branch and its worktree — or just the worktree if the branch
|
|
96
102
|
already exists, or neither if both do
|
|
97
|
-
3.
|
|
98
|
-
4.
|
|
103
|
+
3. copy the Git-ignored files it does not have yet from the main working tree
|
|
104
|
+
4. \`git submodule update --init --recursive\` when the tree has submodules
|
|
105
|
+
5. hand the path back so the shell can cd there
|
|
99
106
|
|
|
100
107
|
Re-running is safe. A half-created branch is rolled back rather than left
|
|
101
108
|
to collide with the next attempt.
|
|
102
109
|
|
|
110
|
+
IGNORED FILES
|
|
111
|
+
A worktree starts without the files git never tracked — .env, credentials,
|
|
112
|
+
local config — so it starts unable to run anything. They are copied over from
|
|
113
|
+
the main working tree, not from the worktree you happen to be standing in,
|
|
114
|
+
because they belong to the repository rather than to a branch.
|
|
115
|
+
|
|
116
|
+
Dependency and build directories are skipped: they are reproducible from what
|
|
117
|
+
git does track, and copying one is slow and often wrong. git cannot tell them
|
|
118
|
+
from an .env, so the exclusion is by name. It matches parent directories at
|
|
119
|
+
any depth, so conf/tmp/app.conf goes too, while a file called dist stays:
|
|
120
|
+
|
|
121
|
+
.angular .astro .cache .dart_tool .direnv .docusaurus .eggs
|
|
122
|
+
.gradle .mypy_cache .next .nuxt .nyc_output .output
|
|
123
|
+
.parcel-cache .pnpm-store .pytest_cache .ruff_cache .sass-cache
|
|
124
|
+
.serverless .stack-work .svelte-kit .terraform .terragrunt-cache
|
|
125
|
+
.tox .turbo .venv .virtualenvs .vite .yarn Carthage Pods
|
|
126
|
+
__pycache__ _build bower_components build coverage deps dist
|
|
127
|
+
jspm_packages node_modules out site-packages target tmp vendor
|
|
128
|
+
venv
|
|
129
|
+
|
|
130
|
+
Every run says how many entries it skipped and which of these they were in.
|
|
131
|
+
An entry is a path git listed: one file under node_modules, but one whole
|
|
132
|
+
directory where git stops at a repository boundary — so a nested worktree
|
|
133
|
+
counts once, whatever it holds.
|
|
134
|
+
|
|
135
|
+
The worktrees of this repository are skipped as well, and so is everything
|
|
136
|
+
else sitting in the directory gwq puts worktrees in — a \`.bak-\` moved aside
|
|
137
|
+
by -f, or a worktree whose .git file went missing. That matters when gwq's
|
|
138
|
+
basedir is inside the repository, where each of those is a full checkout that
|
|
139
|
+
would otherwise be copied into every new worktree.
|
|
140
|
+
|
|
141
|
+
The set is whatever git itself ignores, which is not only .gitignore: it
|
|
142
|
+
includes .git/info/exclude and the machine's global core.excludesFile.
|
|
143
|
+
|
|
144
|
+
Nothing is ever overwritten or deleted: a file the destination already has is
|
|
145
|
+
left exactly as it is, so re-running is safe and an .env you edited in a
|
|
146
|
+
worktree stays yours. A copy that fails is a warning, not a failure — the
|
|
147
|
+
worktree is created either way.
|
|
148
|
+
|
|
149
|
+
--no-copy-ignored-files turns it off. --copy-ignored-files is the default and
|
|
150
|
+
is accepted so a script can say so out loud.
|
|
151
|
+
|
|
103
152
|
OUTPUT
|
|
104
153
|
Progress goes to stderr. stdout carries only the machine-readable result:
|
|
105
154
|
the path in --quiet, one line of JSON in --json, nothing in pretty mode.
|
|
106
155
|
|
|
107
156
|
--json:
|
|
108
157
|
{"schemaVersion":1,"path":"…","branch":"…","base":{"ref":"…","sha":"…"},
|
|
109
|
-
"repo":{"root":"…","name":"…"},"created":"branch+worktree",
|
|
158
|
+
"repo":{"root":"…","name":"…"},"created":"branch+worktree",
|
|
159
|
+
"ignoredFiles":{"copied":0,"kept":0,"skipped":0,"failed":0,"error":null,
|
|
160
|
+
"enabled":true},
|
|
161
|
+
"cd":true}
|
|
162
|
+
|
|
163
|
+
The copy did everything it set out to do when ignoredFiles.enabled is true,
|
|
164
|
+
ignoredFiles.error is null and ignoredFiles.failed is 0. enabled is there
|
|
165
|
+
because the counters of a copy that never ran are the counters of a repository
|
|
166
|
+
with nothing to copy. The copy never affects the exit code, and in --json this
|
|
167
|
+
payload is the only place its trouble is reported.
|
|
110
168
|
|
|
111
169
|
On error in --json mode, stdout is empty and stderr gets:
|
|
112
170
|
{"schemaVersion":1,"error":{"code":"E_NOT_REPO","message":"…"},"exitCode":2}
|
|
@@ -152,6 +210,8 @@ try {
|
|
|
152
210
|
random: { type: 'boolean' },
|
|
153
211
|
'no-random': { type: 'boolean' },
|
|
154
212
|
'no-submodules': { type: 'boolean' },
|
|
213
|
+
'copy-ignored-files': { type: 'boolean' },
|
|
214
|
+
'no-copy-ignored-files': { type: 'boolean' },
|
|
155
215
|
force: { type: 'boolean', short: 'f' },
|
|
156
216
|
'no-cd': { type: 'boolean', short: 'n' },
|
|
157
217
|
json: { type: 'boolean' },
|
|
@@ -416,6 +476,9 @@ if (positionals.length > 1) {
|
|
|
416
476
|
}
|
|
417
477
|
|
|
418
478
|
const doSubmodules = !values['no-submodules'];
|
|
479
|
+
// On by default: a worktree without its .env cannot run the project, and having
|
|
480
|
+
// to remember a flag for that is the whole complaint this answers.
|
|
481
|
+
const copyIgnored = !values['no-copy-ignored-files'];
|
|
419
482
|
const force = !!values.force;
|
|
420
483
|
const stayOut = !!values['no-cd'];
|
|
421
484
|
|
|
@@ -430,6 +493,10 @@ if (values.random && values['no-random']) {
|
|
|
430
493
|
die('E_VALIDATION', '--random and --no-random cannot both be given');
|
|
431
494
|
}
|
|
432
495
|
|
|
496
|
+
if (values['copy-ignored-files'] && values['no-copy-ignored-files']) {
|
|
497
|
+
die('E_VALIDATION', '--copy-ignored-files and --no-copy-ignored-files cannot both be given');
|
|
498
|
+
}
|
|
499
|
+
|
|
433
500
|
// ── interactivity ────────────────────────────────────────────────────────────
|
|
434
501
|
|
|
435
502
|
const stdinTTY = !!process.stdin.isTTY;
|
|
@@ -554,8 +621,17 @@ async function askLine(question, initial = '') {
|
|
|
554
621
|
|
|
555
622
|
// ── git helpers ──────────────────────────────────────────────────────────────
|
|
556
623
|
|
|
624
|
+
// spawnSync's default maxBuffer is 1 MiB, and `ls-files --others --ignored` in a
|
|
625
|
+
// repository that has had `npm install` run in it goes straight past that: the
|
|
626
|
+
// child is killed with SIGTERM, stdout arrives truncated and status is null.
|
|
627
|
+
// That used to read as "could not list the ignored files" and copy nothing at
|
|
628
|
+
// all — .env included, and silently in --json. The listing is bounded by the
|
|
629
|
+
// number of paths in the repository, so give it room.
|
|
630
|
+
const GIT_MAX_BUFFER = 512 * 1024 * 1024;
|
|
631
|
+
|
|
557
632
|
const git = (dir, args, opts = {}) =>
|
|
558
|
-
spawnSync('git', ['-C', dir, ...args],
|
|
633
|
+
spawnSync('git', ['-C', dir, ...args],
|
|
634
|
+
{ encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, ...opts });
|
|
559
635
|
|
|
560
636
|
const gitOut = (dir, args) => {
|
|
561
637
|
const r = git(dir, args);
|
|
@@ -573,6 +649,295 @@ function samePath(a, b) {
|
|
|
573
649
|
try { return realpathSync(a) === realpathSync(b); } catch { return false; }
|
|
574
650
|
}
|
|
575
651
|
|
|
652
|
+
// ── ignored files ────────────────────────────────────────────────────────────
|
|
653
|
+
|
|
654
|
+
// The shape --json reports when the copy did not run at all. `enabled: false`
|
|
655
|
+
// exists because {copied:0,kept:0,skipped:0,failed:0,error:null} was identical
|
|
656
|
+
// to a successful copy of a repository with no ignored files, and an agent
|
|
657
|
+
// following "error is null and failed is 0" would then believe the .env is there.
|
|
658
|
+
const noCopy = () => ({
|
|
659
|
+
copied: 0, kept: 0, skipped: 0, failed: 0, error: null, enabled: false,
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
function pathExists(path) {
|
|
663
|
+
try {
|
|
664
|
+
lstatSync(path);
|
|
665
|
+
return true;
|
|
666
|
+
} catch {
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function isWithin(root, candidate) {
|
|
672
|
+
const rootPath = resolvePath(root);
|
|
673
|
+
const candidatePath = resolvePath(candidate);
|
|
674
|
+
return candidatePath === rootPath || candidatePath.startsWith(`${rootPath}${sep}`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Lexical containment does not protect a write through a symlinked parent.
|
|
678
|
+
// Check every existing component before mkdir/copy; the destination root is
|
|
679
|
+
// realpathed by seedIgnoredFiles so the root itself cannot redirect the write.
|
|
680
|
+
//
|
|
681
|
+
// Returns why the path is unusable, or '' when it is fine. It reports the
|
|
682
|
+
// reason rather than a boolean because "crosses a symlink" was being printed
|
|
683
|
+
// for an ENOTDIR — a destination blocked by an ordinary file, where nothing is
|
|
684
|
+
// a symlink at all.
|
|
685
|
+
//
|
|
686
|
+
// `verified` memoises directories this run has already walked past. Only real
|
|
687
|
+
// directories go in, and only the pre-mkdir call passes it: the post-mkdir call
|
|
688
|
+
// has to lstat the component mkdir just made, which is the whole point of
|
|
689
|
+
// looking twice.
|
|
690
|
+
function destinationBlockedBy(root, candidate, verified) {
|
|
691
|
+
const rootPath = resolvePath(root);
|
|
692
|
+
let current = resolvePath(candidate);
|
|
693
|
+
if (!isWithin(rootPath, current)) return 'escapes the worktree';
|
|
694
|
+
const walked = [];
|
|
695
|
+
while (current !== rootPath) {
|
|
696
|
+
if (verified?.has(current)) break;
|
|
697
|
+
try {
|
|
698
|
+
const st = lstatSync(current);
|
|
699
|
+
if (st.isSymbolicLink()) return 'crosses a symlink in the worktree';
|
|
700
|
+
if (st.isDirectory()) walked.push(current);
|
|
701
|
+
} catch (err) {
|
|
702
|
+
if (err.code !== 'ENOENT') return `blocked by ${err.code} in the worktree`;
|
|
703
|
+
}
|
|
704
|
+
const parent = dirname(current);
|
|
705
|
+
if (parent === current) return 'escapes the worktree';
|
|
706
|
+
current = parent;
|
|
707
|
+
}
|
|
708
|
+
if (verified) for (const d of walked) verified.add(d);
|
|
709
|
+
return '';
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Every working tree of this repository, resolved both ways: git reports
|
|
713
|
+
// resolved paths, we assemble unresolved ones.
|
|
714
|
+
function ownWorktrees(dir) {
|
|
715
|
+
const paths = new Set();
|
|
716
|
+
for (const line of gitOut(dir, ['worktree', 'list', '--porcelain']).split('\n')) {
|
|
717
|
+
if (!line.startsWith('worktree ')) continue;
|
|
718
|
+
const p = line.slice('worktree '.length);
|
|
719
|
+
paths.add(resolvePath(p));
|
|
720
|
+
try { paths.add(realpathSync(p)); } catch { /* pruned since */ }
|
|
721
|
+
}
|
|
722
|
+
return paths;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Ignored paths a package manager or a build tool puts back on its own. git
|
|
726
|
+
// cannot tell these from an .env: `--directory` only says a directory is
|
|
727
|
+
// ignored as a whole, which is just as true of `.secrets/`, and a size budget
|
|
728
|
+
// would change the answer with the state of the disk. The only honest
|
|
729
|
+
// discriminator is the name, so the list is fixed, sorted, reproduced in
|
|
730
|
+
// --help, and every run says how much it skipped and where.
|
|
731
|
+
const REGENERABLE_DIRS = [
|
|
732
|
+
'.angular', '.astro', '.cache', '.dart_tool', '.direnv', '.docusaurus',
|
|
733
|
+
'.eggs', '.gradle', '.mypy_cache', '.next', '.nuxt', '.nyc_output',
|
|
734
|
+
'.output', '.parcel-cache', '.pnpm-store', '.pytest_cache', '.ruff_cache',
|
|
735
|
+
'.sass-cache', '.serverless', '.stack-work', '.svelte-kit', '.terraform',
|
|
736
|
+
'.terragrunt-cache', '.tox', '.turbo', '.venv', '.virtualenvs', '.vite',
|
|
737
|
+
'.yarn', 'Carthage', 'Pods', '__pycache__', '_build', 'bower_components',
|
|
738
|
+
'build', 'coverage', 'deps', 'dist', 'jspm_packages', 'node_modules',
|
|
739
|
+
'out', 'site-packages', 'target', 'tmp', 'vendor', 'venv',
|
|
740
|
+
];
|
|
741
|
+
const REGENERABLE = new Set(REGENERABLE_DIRS);
|
|
742
|
+
|
|
743
|
+
// The name of the regenerable directory this entry lives in, or ''. Only parent
|
|
744
|
+
// components count: a file called `dist` is a file, not a build directory.
|
|
745
|
+
function regenerableDir(entry) {
|
|
746
|
+
const parts = entry.split('/');
|
|
747
|
+
parts.pop();
|
|
748
|
+
for (const part of parts) if (REGENERABLE.has(part)) return part;
|
|
749
|
+
return '';
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// A new worktree gets everything git tracks and nothing it does not, so it
|
|
753
|
+
// starts without the .env and the credentials the project needs to run. Those
|
|
754
|
+
// live in the main working tree; copy over the ones the destination lacks.
|
|
755
|
+
//
|
|
756
|
+
// Three rules make this safe to have on by default:
|
|
757
|
+
// - never overwrite and never delete, so an .env edited in a worktree is the
|
|
758
|
+
// user's and re-running is a no-op;
|
|
759
|
+
// - never leave the destination, checked lexically and against symlinked
|
|
760
|
+
// parents, because the list comes from the filesystem;
|
|
761
|
+
// - never fail the command. A worktree missing its .env is worse than one
|
|
762
|
+
// with it, but a worktree that was never created is worse than both, so
|
|
763
|
+
// every failure here is a warning (cf. the naming layer, I22).
|
|
764
|
+
function seedIgnoredFiles(sourceDirIn, destinationDir) {
|
|
765
|
+
// Resolve the source too. `destinationRoot` is realpathed below and git prints
|
|
766
|
+
// resolved paths in `worktree list`, so a source that arrives unresolved makes
|
|
767
|
+
// every path comparison in here compare two spellings of the same place and
|
|
768
|
+
// answer "no" — which turns **both** worktree guards off at once and lets the
|
|
769
|
+
// worktree being created be copied into itself. Here the source comes from
|
|
770
|
+
// `git worktree list` and is already resolved, so the call is insurance; it
|
|
771
|
+
// keeps this function identical to gwqpull's, where a symlinked ghq.root did
|
|
772
|
+
// exactly that. The resolved form stays inside this function: comparisons
|
|
773
|
+
// need it, output does not.
|
|
774
|
+
let sourceDir = sourceDirIn;
|
|
775
|
+
try {
|
|
776
|
+
sourceDir = realpathSync(sourceDirIn);
|
|
777
|
+
} catch {
|
|
778
|
+
// Keep what we were given: a source we cannot resolve is a source we
|
|
779
|
+
// cannot copy from either, and the listing below will say so.
|
|
780
|
+
}
|
|
781
|
+
// `error` carries a listing failure into --json, where warn() is silent and
|
|
782
|
+
// {copied:0,kept:0,skipped:0} is otherwise indistinguishable from a
|
|
783
|
+
// repository that simply has no ignored files.
|
|
784
|
+
const result = {
|
|
785
|
+
copied: 0, kept: 0, skipped: 0, failed: 0, error: null, enabled: true,
|
|
786
|
+
};
|
|
787
|
+
if (samePath(sourceDir, destinationDir)) return result;
|
|
788
|
+
|
|
789
|
+
let destinationRoot;
|
|
790
|
+
try {
|
|
791
|
+
destinationRoot = realpathSync(destinationDir);
|
|
792
|
+
} catch (err) {
|
|
793
|
+
result.error = `could not resolve ${destinationDir}: ${err.message}`;
|
|
794
|
+
warn(result.error);
|
|
795
|
+
return result;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const r = git(sourceDir, [
|
|
799
|
+
'ls-files', '--others', '--ignored', '--exclude-standard', '-z',
|
|
800
|
+
]);
|
|
801
|
+
if (r.status !== 0 || r.error) {
|
|
802
|
+
// Say why. The reason used to be dropped, which made an ENOBUFS truncation
|
|
803
|
+
// look like a repository with nothing to copy.
|
|
804
|
+
const why = r.error?.code
|
|
805
|
+
?? (r.signal ? `killed by ${r.signal}` : `git exited ${r.status}`);
|
|
806
|
+
result.error = `could not list the ignored files in ${sourceDirIn} (${why})`;
|
|
807
|
+
warn(result.error);
|
|
808
|
+
return result;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const entries = (r.stdout ?? '').split('\0').filter(Boolean);
|
|
812
|
+
if (!entries.length) return result;
|
|
813
|
+
|
|
814
|
+
// Prune before touching the filesystem: git lists every single file inside
|
|
815
|
+
// node_modules, and there can be hundreds of thousands of them.
|
|
816
|
+
//
|
|
817
|
+
// Our own worktrees go too. A gwq basedir inside the repository makes every
|
|
818
|
+
// worktree an ignored directory of it, and git reports such a directory as
|
|
819
|
+
// one indivisible entry — so worktrees start duplicating each other, one
|
|
820
|
+
// level deeper on every run, with only cpSync's "subdirectory of self" check
|
|
821
|
+
// stopping the recursion. That is a structural fact from `git worktree list`,
|
|
822
|
+
// not another name to guess at (I25b).
|
|
823
|
+
const worktrees = ownWorktrees(sourceDir);
|
|
824
|
+
// `git worktree list` knows only the live ones. Everything else beside them
|
|
825
|
+
// in gwq's basedir is a full checkout of this repository that git reports as
|
|
826
|
+
// an ordinary ignored directory: a `<path>.bak-<timestamp>` this tool moved
|
|
827
|
+
// aside itself (I4), or a worktree whose `.git` file went missing — and our
|
|
828
|
+
// own `git worktree prune` runs before this, so that entry is already gone.
|
|
829
|
+
// The directory the destination sits in is therefore pruned wholesale — one
|
|
830
|
+
// level, not gwq's whole basedir. gwq does report that (`gwq config get
|
|
831
|
+
// worktree.basedir`, G5), but the value comes back unexpanded, it is the
|
|
832
|
+
// configured basedir rather than where this worktree actually went, and it
|
|
833
|
+
// costs another gwq start-up per run — and it would not change the case
|
|
834
|
+
// below, because those leftovers sit beside the destination. With a
|
|
835
|
+
// naming template that nests (host/owner/repo/branch) a leftover further up
|
|
836
|
+
// is still copied; that needs a layout change to happen at all, and taking
|
|
837
|
+
// the topmost ancestor instead would prune a real config directory whenever
|
|
838
|
+
// someone points the basedir inside one. The samePath guard is for a basedir
|
|
839
|
+
// at the repository root, where pruning the holder would prune everything.
|
|
840
|
+
const holder = dirname(destinationRoot);
|
|
841
|
+
const holdsWorktrees = isWithin(sourceDir, holder) && !samePath(holder, sourceDir);
|
|
842
|
+
const isOwnWorktree = (p) => {
|
|
843
|
+
if (holdsWorktrees && isWithin(holder, p)) return true;
|
|
844
|
+
// The arguments look backwards and are not: `worktrees` contains the main
|
|
845
|
+
// working tree, so asking isWithin(w, p) would put every entry inside it and
|
|
846
|
+
// prune the lot. git collapses a healthy worktree into exactly one entry, so
|
|
847
|
+
// p === w is the case that matters here.
|
|
848
|
+
for (const w of worktrees) if (isWithin(p, w)) return true;
|
|
849
|
+
return false;
|
|
850
|
+
};
|
|
851
|
+
const pruned = new Map();
|
|
852
|
+
const wanted = [];
|
|
853
|
+
for (const entry of entries) {
|
|
854
|
+
const dir = regenerableDir(entry);
|
|
855
|
+
const label = dir || (isOwnWorktree(resolvePath(sourceDir, entry)) ? 'worktrees of this repository' : '');
|
|
856
|
+
if (label) pruned.set(label, (pruned.get(label) ?? 0) + 1);
|
|
857
|
+
else wanted.push(entry);
|
|
858
|
+
}
|
|
859
|
+
result.skipped = entries.length - wanted.length;
|
|
860
|
+
|
|
861
|
+
// Printed as we were handed it, so this agrees with `repo.root` in --json.
|
|
862
|
+
if (wanted.length) log(`${dim('│')} copying ignored files from ${dim(sourceDirIn)}`);
|
|
863
|
+
|
|
864
|
+
// node_modules and build output are in scope by design, so this can be tens
|
|
865
|
+
// of thousands of files. A silent multi-minute pause reads as a hang, so keep
|
|
866
|
+
// a counter moving whenever there is a terminal to move it on.
|
|
867
|
+
const showProgress = stderrTTY && !isJson;
|
|
868
|
+
let lastTick = 0;
|
|
869
|
+
let processed = 0;
|
|
870
|
+
// The sample is capped; the count is not. Reporting `skipped.length` as the
|
|
871
|
+
// number of failures under-reported everything past the hundredth.
|
|
872
|
+
const samples = [];
|
|
873
|
+
const skip = (reason) => {
|
|
874
|
+
result.failed++;
|
|
875
|
+
if (samples.length < 3) samples.push(reason);
|
|
876
|
+
};
|
|
877
|
+
const verified = new Set();
|
|
878
|
+
|
|
879
|
+
for (const entry of wanted) {
|
|
880
|
+
// Tick first: kept and skipped entries do work too, and a re-run that keeps
|
|
881
|
+
// everything is exactly the silent wait the counter exists for.
|
|
882
|
+
processed++;
|
|
883
|
+
if (showProgress && Date.now() - lastTick > 200) {
|
|
884
|
+
lastTick = Date.now();
|
|
885
|
+
stderr.write(`\r\x1b[K${dim('│')} ${processed} / ${wanted.length}`);
|
|
886
|
+
}
|
|
887
|
+
const sourcePath = resolvePath(sourceDir, entry);
|
|
888
|
+
const destinationPath = resolvePath(destinationRoot, entry);
|
|
889
|
+
if (!isWithin(sourceDir, sourcePath) || !isWithin(destinationRoot, destinationPath)) {
|
|
890
|
+
skip(`${entry} (escapes the worktree)`);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
if (!pathExists(sourcePath)) continue;
|
|
894
|
+
if (pathExists(destinationPath)) {
|
|
895
|
+
result.kept++;
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
const blocked = destinationBlockedBy(destinationRoot, destinationPath, verified);
|
|
899
|
+
if (blocked) {
|
|
900
|
+
skip(`${entry} (${blocked})`);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
try {
|
|
904
|
+
mkdirSync(dirname(destinationPath), { recursive: true });
|
|
905
|
+
// Look again: mkdir may have followed a link that appeared meanwhile, so
|
|
906
|
+
// this call deliberately does not use the memo.
|
|
907
|
+
const raced = destinationBlockedBy(destinationRoot, destinationPath);
|
|
908
|
+
if (raced) {
|
|
909
|
+
skip(`${entry} (${raced})`);
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
// verbatimSymlinks: a relative link is a link within the tree being
|
|
913
|
+
// copied. Resolving it, which is cpSync's default, rewrites
|
|
914
|
+
// `.secrets/bin/key -> ../real/key` into an absolute path back into
|
|
915
|
+
// the main working tree. (Not a node_modules example: I25b never
|
|
916
|
+
// copies those.)
|
|
917
|
+
cpSync(sourcePath, destinationPath,
|
|
918
|
+
{ recursive: true, force: false, verbatimSymlinks: true });
|
|
919
|
+
result.copied++;
|
|
920
|
+
} catch (err) {
|
|
921
|
+
skip(`${entry} (${err.message})`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (showProgress) stderr.write('\r\x1b[K');
|
|
925
|
+
|
|
926
|
+
// Name what was left behind: an exclusion nobody can see is a silent
|
|
927
|
+
// surprise the first time a project keeps something real in `dist/`.
|
|
928
|
+
const names = [...pruned.entries()].sort((a, b) => b[1] - a[1]).map(([n]) => n);
|
|
929
|
+
log(`${dim('│')} copied ${result.copied} ignored file(s)` +
|
|
930
|
+
(result.kept ? `, kept ${result.kept} the worktree already had` : '') +
|
|
931
|
+
(result.skipped
|
|
932
|
+
? `, skipped ${result.skipped} entr${result.skipped === 1 ? 'y' : 'ies'} in ` +
|
|
933
|
+
names.slice(0, 3).join(', ') + (names.length > 3 ? ', …' : '')
|
|
934
|
+
: ''));
|
|
935
|
+
if (result.failed) {
|
|
936
|
+
warn(`could not copy ${result.failed} ignored file(s), starting with ${samples[0]}`);
|
|
937
|
+
}
|
|
938
|
+
return result;
|
|
939
|
+
}
|
|
940
|
+
|
|
576
941
|
// The worktree path for a branch, or '' — read from git rather than
|
|
577
942
|
// reimplementing gwq's naming template, which we do not control.
|
|
578
943
|
function worktreePath(dir, branch) {
|
|
@@ -879,9 +1244,15 @@ function detectAi() {
|
|
|
879
1244
|
if (['off', '0', 'false', 'none'].includes(override)) return null;
|
|
880
1245
|
// Split on whitespace only: quoting rules would be a shell of our own.
|
|
881
1246
|
const parts = override.split(/\s+/).filter(Boolean);
|
|
882
|
-
return {
|
|
1247
|
+
return {
|
|
1248
|
+
candidates: [{ bin: parts[0], args: parts.slice(1) }],
|
|
1249
|
+
automatic: false,
|
|
1250
|
+
};
|
|
883
1251
|
}
|
|
884
|
-
return
|
|
1252
|
+
return {
|
|
1253
|
+
candidates: AI_CLIS.filter((c) => commandExists(c.bin)),
|
|
1254
|
+
automatic: true,
|
|
1255
|
+
};
|
|
885
1256
|
}
|
|
886
1257
|
|
|
887
1258
|
// These CLIs are agents, not text transformers: run one inside a repository and
|
|
@@ -1069,11 +1440,14 @@ async function composeBranchName(dir, repo, base) {
|
|
|
1069
1440
|
if (chosen) return chosen;
|
|
1070
1441
|
}
|
|
1071
1442
|
|
|
1072
|
-
const
|
|
1073
|
-
if (!
|
|
1443
|
+
const selection = detectAi();
|
|
1444
|
+
if (!selection || selection.candidates.length === 0) {
|
|
1445
|
+
return { branch: await typeItYourself(), named: 'manual' };
|
|
1446
|
+
}
|
|
1074
1447
|
|
|
1075
1448
|
const ctx = repoContext(dir, repo, base);
|
|
1076
1449
|
const rejected = [];
|
|
1450
|
+
let aiIndex = 0;
|
|
1077
1451
|
|
|
1078
1452
|
for (;;) {
|
|
1079
1453
|
log(`${dim('│')}`);
|
|
@@ -1083,17 +1457,23 @@ async function composeBranchName(dir, repo, base) {
|
|
|
1083
1457
|
// An empty answer is the escape hatch out of the AI entirely.
|
|
1084
1458
|
if (!description) return { branch: await typeItYourself(), named: 'manual' };
|
|
1085
1459
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1460
|
+
let ai;
|
|
1461
|
+
let candidates;
|
|
1462
|
+
for (;;) {
|
|
1463
|
+
ai = selection.candidates[aiIndex];
|
|
1464
|
+
const res = await askAi(ai, namingPrompt(ctx, description, rejected));
|
|
1465
|
+
candidates = res.ok ? parseCandidates(res.out) : [];
|
|
1466
|
+
if (res.ok && candidates.length > 0) break;
|
|
1467
|
+
|
|
1468
|
+
const reason = res.ok ? 'returned nothing usable' : 'failed';
|
|
1469
|
+
const next = selection.automatic
|
|
1470
|
+
? selection.candidates[aiIndex + 1]
|
|
1471
|
+
: null;
|
|
1472
|
+
warn(`${aiLabel(ai)} ${reason} — ${next ? `trying ${aiLabel(next)} instead` : 'name it yourself instead'}`);
|
|
1089
1473
|
const first = (res.err || '').trim().split('\n')[0];
|
|
1090
1474
|
if (first) log(`${dim('│')} ${dim(first.slice(0, 120))}`);
|
|
1091
|
-
return { branch: await typeItYourself(), named: 'manual' };
|
|
1092
|
-
|
|
1093
|
-
const candidates = parseCandidates(res.out);
|
|
1094
|
-
if (candidates.length === 0) {
|
|
1095
|
-
warn(`${aiLabel(ai)} returned nothing usable — name it yourself instead`);
|
|
1096
|
-
return { branch: await typeItYourself(), named: 'manual' };
|
|
1475
|
+
if (!next) return { branch: await typeItYourself(), named: 'manual' };
|
|
1476
|
+
aiIndex++;
|
|
1097
1477
|
}
|
|
1098
1478
|
|
|
1099
1479
|
const choice = await confirmCreate(candidates[0], ctx.base);
|
|
@@ -1301,8 +1681,15 @@ async function main() {
|
|
|
1301
1681
|
const existing = worktreePath(cwd, branch);
|
|
1302
1682
|
if (existing && existsSync(existing)) {
|
|
1303
1683
|
log(`${dim('│')} ${dim('worktree already exists')}`);
|
|
1684
|
+
// Still seed it: the worktree may predate this feature, or the main working
|
|
1685
|
+
// tree may have gained an .env since. Missing-only, so this cannot clobber.
|
|
1686
|
+
const ignoredFiles = copyIgnored
|
|
1687
|
+
? seedIgnoredFiles(repo.root, existing)
|
|
1688
|
+
: noCopy();
|
|
1304
1689
|
log(`${dim('└')} ${green('✓')} ${cyan(branch)} ${dim('→')} ${existing}`);
|
|
1305
|
-
return finish({
|
|
1690
|
+
return finish({
|
|
1691
|
+
repo, branch, base, path: existing, created: 'none', named, ignoredFiles,
|
|
1692
|
+
});
|
|
1306
1693
|
}
|
|
1307
1694
|
|
|
1308
1695
|
// Two ways in. Without --from, `gwq add -b` creates branch and worktree in
|
|
@@ -1385,6 +1772,13 @@ async function main() {
|
|
|
1385
1772
|
die('E_WORKTREE', `gwq reported success but no worktree for ${branch} could be found`);
|
|
1386
1773
|
}
|
|
1387
1774
|
|
|
1775
|
+
// The source is the main working tree, never the worktree we are standing in:
|
|
1776
|
+
// ignored files belong to the repository, not to whichever branch you had
|
|
1777
|
+
// checked out when you ran this.
|
|
1778
|
+
const ignoredFiles = copyIgnored
|
|
1779
|
+
? seedIgnoredFiles(repo.root, created)
|
|
1780
|
+
: noCopy();
|
|
1781
|
+
|
|
1388
1782
|
if (doSubmodules && existsSync(`${created}/.gitmodules`)) {
|
|
1389
1783
|
log(`${dim('│')} initialising submodules`);
|
|
1390
1784
|
const r = git(created, ['submodule', 'update', '--init', '--recursive'], { stdio: childStdio });
|
|
@@ -1393,14 +1787,14 @@ async function main() {
|
|
|
1393
1787
|
|
|
1394
1788
|
log(`${dim('└')} ${green('✓')} ${cyan(branch)} ${dim('→')} ${created}`);
|
|
1395
1789
|
return finish({
|
|
1396
|
-
repo, branch, base, path: created, named,
|
|
1790
|
+
repo, branch, base, path: created, named, ignoredFiles,
|
|
1397
1791
|
created: branchExisted ? 'worktree' : 'branch+worktree',
|
|
1398
1792
|
});
|
|
1399
1793
|
}
|
|
1400
1794
|
|
|
1401
1795
|
// ── output ───────────────────────────────────────────────────────────────────
|
|
1402
1796
|
|
|
1403
|
-
async function finish({ repo, branch, base, path, created, named }) {
|
|
1797
|
+
async function finish({ repo, branch, base, path, created, named, ignoredFiles }) {
|
|
1404
1798
|
if (isJson) {
|
|
1405
1799
|
process.stdout.write(JSON.stringify({
|
|
1406
1800
|
schemaVersion: SCHEMA_VERSION,
|
|
@@ -1409,6 +1803,10 @@ async function finish({ repo, branch, base, path, created, named }) {
|
|
|
1409
1803
|
base: { ref: base.ref, sha: base.sha },
|
|
1410
1804
|
repo: { root: repo.root, name: repo.name },
|
|
1411
1805
|
created,
|
|
1806
|
+
// What the ignored-file copy did, so a caller can tell a worktree that
|
|
1807
|
+
// got its .env from one that did not. Adding a field does not bump
|
|
1808
|
+
// schemaVersion (I10).
|
|
1809
|
+
ignoredFiles: ignoredFiles ?? noCopy(),
|
|
1412
1810
|
// How the name was chosen, so a caller can tell a name it picked from one
|
|
1413
1811
|
// the tool invented. Adding a field does not bump schemaVersion (I10).
|
|
1414
1812
|
named,
|