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.
Files changed (3) hide show
  1. package/README.md +74 -3
  2. package/bin/gwqadd.mjs +424 -26
  3. 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. `git submodule update --init --recursive` when the tree has submodules.
173
- 4. Hand the path back so the shell can `cd` there.
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
- The AI is whichever of these is on PATH claude, codex, opencode, gemini —
75
- invoked headlessly. Nothing is sent anywhere until you answer the question,
76
- and nothing is created until you confirm. Override with --ai '<cmd>' or
77
- GWQADD_AI='<cmd>'; disable with --no-ai or GWQADD_AI=off, which leaves a plain
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. \`git submodule update --init --recursive\` when the tree has submodules
98
- 4. hand the path back so the shell can cd there
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","cd":true}
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], { encoding: 'utf8', ...opts });
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 { bin: parts[0], args: parts.slice(1) };
1247
+ return {
1248
+ candidates: [{ bin: parts[0], args: parts.slice(1) }],
1249
+ automatic: false,
1250
+ };
883
1251
  }
884
- return AI_CLIS.find((c) => commandExists(c.bin)) ?? null;
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 ai = detectAi();
1073
- if (!ai) return { branch: await typeItYourself(), named: 'manual' };
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
- const res = await askAi(ai, namingPrompt(ctx, description, rejected));
1087
- if (!res.ok) {
1088
- warn(`${aiLabel(ai)} failed — name it yourself instead`);
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({ repo, branch, base, path: existing, created: 'none', named });
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gwqadd",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "Create a branch and its gwq worktree in the repository you are in, and cd there.",
5
5
  "type": "module",
6
6
  "bin": {