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.
- package/README.md +68 -2
- package/bin/gwqadd.mjs +391 -9
- 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.
|
|
173
|
-
4.
|
|
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.
|
|
98
|
-
4.
|
|
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",
|
|
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],
|
|
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({
|
|
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,
|