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