@gjsify/cli 0.52.0 → 0.54.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 (107) hide show
  1. package/dist/cli.gjs.mjs +351 -305
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +72 -1
  5. package/lib/bundler-pick.js +175 -17
  6. package/lib/cli-app.js +7 -2
  7. package/lib/commands/blueprint/index.d.ts +4 -0
  8. package/lib/commands/blueprint/index.js +15 -0
  9. package/lib/commands/blueprint/types.d.ts +8 -0
  10. package/lib/commands/blueprint/types.js +124 -0
  11. package/lib/commands/devtools.d.ts +21 -0
  12. package/lib/commands/devtools.js +146 -0
  13. package/lib/commands/env.d.ts +31 -0
  14. package/lib/commands/env.js +114 -0
  15. package/lib/commands/exec.d.ts +10 -0
  16. package/lib/commands/exec.js +208 -0
  17. package/lib/commands/foreach.js +5 -97
  18. package/lib/commands/format.js +26 -6
  19. package/lib/commands/index.d.ts +5 -0
  20. package/lib/commands/index.js +5 -0
  21. package/lib/commands/install.d.ts +2 -2
  22. package/lib/commands/install.js +5 -5
  23. package/lib/commands/self-update.js +2 -1
  24. package/lib/commands/ship.js +51 -8
  25. package/lib/commands/test.d.ts +18 -9
  26. package/lib/commands/test.js +56 -11
  27. package/lib/commands/tsc.js +4 -4
  28. package/lib/commands/upgrade.d.ts +15 -0
  29. package/lib/commands/upgrade.js +76 -2
  30. package/lib/commands/webext/index.d.ts +2 -0
  31. package/lib/commands/webext/index.js +15 -0
  32. package/lib/commands/webext/subcommands.d.ts +16 -0
  33. package/lib/commands/webext/subcommands.js +204 -0
  34. package/lib/types/config-data.d.ts +1 -1
  35. package/lib/utils/bin-shim.d.ts +8 -2
  36. package/lib/utils/bin-shim.js +6 -5
  37. package/lib/utils/bundle-inputs.d.ts +84 -0
  38. package/lib/utils/bundle-inputs.js +268 -0
  39. package/lib/utils/check-system-deps.d.ts +4 -2
  40. package/lib/utils/check-system-deps.js +21 -26
  41. package/lib/utils/dev-link.d.ts +9 -6
  42. package/lib/utils/dev-link.js +71 -19
  43. package/lib/utils/exec-bin.d.ts +137 -0
  44. package/lib/utils/exec-bin.js +321 -0
  45. package/lib/utils/gi-runtime-paths.js +3 -2
  46. package/lib/utils/gitignore.d.ts +15 -0
  47. package/lib/utils/gitignore.js +178 -0
  48. package/lib/utils/gjsify-shim.d.ts +47 -0
  49. package/lib/utils/gjsify-shim.js +97 -8
  50. package/lib/utils/install-backend-native.d.ts +19 -1
  51. package/lib/utils/install-backend-native.js +93 -7
  52. package/lib/utils/install-lock.js +4 -25
  53. package/lib/utils/native-skip-scan.d.ts +20 -0
  54. package/lib/utils/native-skip-scan.js +343 -0
  55. package/lib/utils/node-script.js +1 -1
  56. package/lib/utils/oxc-resolve.js +46 -5
  57. package/lib/utils/process-table.d.ts +22 -0
  58. package/lib/utils/process-table.js +188 -0
  59. package/lib/utils/prune-prefix.js +1 -1
  60. package/lib/utils/run-gjs.d.ts +15 -1
  61. package/lib/utils/run-gjs.js +16 -7
  62. package/lib/utils/run-node.d.ts +2 -0
  63. package/lib/utils/run-node.js +2 -1
  64. package/lib/utils/runtimes.js +14 -13
  65. package/lib/utils/ship/app-runtime.d.ts +7 -5
  66. package/lib/utils/ship/app-runtime.js +11 -8
  67. package/lib/utils/ship/appimage.d.ts +9 -0
  68. package/lib/utils/ship/appimage.js +2 -1
  69. package/lib/utils/ship/deb.d.ts +10 -0
  70. package/lib/utils/ship/deb.js +7 -0
  71. package/lib/utils/ship/depends.d.ts +37 -3
  72. package/lib/utils/ship/depends.js +21 -9
  73. package/lib/utils/ship/gi-namespaces.d.ts +16 -0
  74. package/lib/utils/ship/gi-namespaces.js +64 -1
  75. package/lib/utils/ship/gzip.js +1 -1
  76. package/lib/utils/ship/icons.d.ts +6 -0
  77. package/lib/utils/ship/icons.js +6 -5
  78. package/lib/utils/ship/launcher.js +2 -2
  79. package/lib/utils/ship/layout.js +25 -2
  80. package/lib/utils/ship/rpm.d.ts +5 -0
  81. package/lib/utils/ship/rpm.js +42 -0
  82. package/lib/utils/ship/settings.js +6 -0
  83. package/lib/utils/ship/signing.d.ts +7 -2
  84. package/lib/utils/ship/signing.js +59 -32
  85. package/lib/utils/ship/stage-manifest.d.ts +14 -0
  86. package/lib/utils/ship/stage-manifest.js +7 -0
  87. package/lib/utils/spawn.d.ts +13 -0
  88. package/lib/utils/spawn.js +10 -5
  89. package/lib/utils/watch-loop.d.ts +15 -1
  90. package/lib/utils/watch-loop.js +9 -5
  91. package/lib/utils/webext/build.d.ts +42 -0
  92. package/lib/utils/webext/build.js +120 -0
  93. package/lib/utils/webext/config.d.ts +72 -0
  94. package/lib/utils/webext/config.js +234 -0
  95. package/lib/utils/webext/icons.d.ts +24 -0
  96. package/lib/utils/webext/icons.js +83 -0
  97. package/lib/utils/webext/launch.d.ts +32 -0
  98. package/lib/utils/webext/launch.js +77 -0
  99. package/lib/utils/webext/manifest.d.ts +53 -0
  100. package/lib/utils/webext/manifest.js +219 -0
  101. package/lib/utils/webext/pages.d.ts +26 -0
  102. package/lib/utils/webext/pages.js +61 -0
  103. package/lib/utils/webext/targets.d.ts +13 -0
  104. package/lib/utils/webext/targets.js +48 -0
  105. package/lib/utils/win32-command.d.ts +17 -0
  106. package/lib/utils/win32-command.js +21 -6
  107. package/package.json +20 -19
@@ -0,0 +1,268 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The SECOND half of "what are this bundle's build inputs" — what the BUNDLER
3
+ // read, recorded at build time beside the bundle it produced.
4
+ //
5
+ // `package-inputs.ts` answers the other half by walking the package, and that
6
+ // walk cannot see outside the package. A consumer's test bundle routinely reads
7
+ // outside it: a workspace package reached through `node_modules/<name>` — a
8
+ // SYMLINK into a sibling directory — is a file the build read and the walk
9
+ // never enumerates. An edit there left `gjsify test` judging a fresh bundle and
10
+ // rerunning it: a stale GREEN, the worst kind of wrong, and the only remedy a
11
+ // consumer had was `rm -rf dist`.
12
+ //
13
+ // The set is recorded from the bundler's own module graph rather than guessed
14
+ // from a directory list: a bare specifier's resolution is known to the bundler
15
+ // and to nobody else, and a list of "likely dirs" is wrong for every layout
16
+ // nobody anticipated — the same failure `package-inputs.ts` documents twice.
17
+ //
18
+ // TWO halves, because the bundler is not the only reader: a `transform`
19
+ // observer sees every MODULE, and the engine's watch list carries what a plugin
20
+ // read and declared itself (a stylesheet's `@import` chain is read by
21
+ // `gjsify-css-as-string`, not by the bundler, so no graph names it). Neither
22
+ // engine reports both, so the recorder takes the union. Deciding freshness
23
+ // stays cheap: the manifest is written once per build and read as `stat` calls.
24
+ //
25
+ // `package-inputs.ts` is not replaced by this. Its walk answers "what MAY this
26
+ // package's build read", which covers a file the current build does not import
27
+ // yet; this answers "what DID this build read", which reaches outside the
28
+ // package. The union is the honest input set, and both halves must exist.
29
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
30
+ import { dirname, join, sep } from 'node:path';
31
+ /**
32
+ * Bumped when the shape changes; a manifest of any other version is ignored.
33
+ *
34
+ * v2 adds `watchList`. A v1 manifest says nothing about whether the engine that
35
+ * wrote it could report a watch list at all, and the safe reading of "unknown"
36
+ * is "do not trust it": see {@link readBundleInputs}.
37
+ */
38
+ export const BUNDLE_INPUTS_VERSION = 2;
39
+ /** The manifest that belongs to `outfile`. */
40
+ export function bundleInputsPath(outfile) {
41
+ return `${outfile}.inputs.json`;
42
+ }
43
+ /**
44
+ * A module id as a path, or `null` when it names no file we can stat.
45
+ *
46
+ * A query or a hash is PART OF THE ID, not of the path: `@scope/pkg/x.css?raw`
47
+ * and `main-window.blp?shared-tree` (the blueprint plugin's shared-tree exit)
48
+ * both stat as ENOENT, and dropping the id silently would drop the file with
49
+ * it. `\0` is the bundler's virtual-module marker — no file, ever.
50
+ */
51
+ function idToPath(id) {
52
+ if (id === '' || id.startsWith('\0'))
53
+ return null;
54
+ const cut = id.search(/[?#]/);
55
+ if (cut === -1)
56
+ return id;
57
+ const stripped = id.slice(0, cut);
58
+ // The fallback is not a nicety: `#` is LEGAL in a Linux filename, and
59
+ // `a#b.blp` is that file's whole name. Stripping turned it into `a`, which
60
+ // does not exist, and the input was dropped. So the unstripped id gets its
61
+ // turn — and when neither is a file, `inputs()` drops it as before.
62
+ return isFile(stripped) ? stripped : id;
63
+ }
64
+ function isFile(abs) {
65
+ try {
66
+ return statSync(abs).isFile();
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /**
73
+ * Files the TOOLCHAIN writes and deletes between builds, which therefore must
74
+ * never enter the input set.
75
+ *
76
+ * Not a filter on staleness but on membership: a vanished recorded input forces
77
+ * a rebuild (see {@link newestBundleInputMtimeMs}), so recording the
78
+ * globals-inject stub — `node_modules/.cache/gjsify/auto-globals-<hash>.mjs`,
79
+ * rewritten per build and DELETED when its config changes — would rebuild every
80
+ * run, forever. The same directory holds the cached GJS plugin bundles, which
81
+ * carry their own freshness rule (`bundleFileForGjsCached`).
82
+ *
83
+ * **Not a `tmpdir()` test**, which is the obvious wrong answer and the one this
84
+ * started as: the stub is written into the project's own cache directory
85
+ * (`utils/scan-globals.ts`), never to a temp dir, while a PROJECT under
86
+ * `/tmp` is a project — a CI job, a container, this suite's own fixtures. A
87
+ * freshness check that goes blind in those trees is the defect being fixed,
88
+ * reached from the other side.
89
+ */
90
+ function isToolchainScratch(abs) {
91
+ return abs.includes(`${sep}node_modules${sep}.cache${sep}`);
92
+ }
93
+ /**
94
+ * The resolution files of the package a recorded input belongs to: the nearest
95
+ * `package.json` above it, and the `tsconfig.json` beside it when there is one.
96
+ *
97
+ * Neither is ever a module. A workspace package reached through a symlink
98
+ * contributes its files, but a `package.json#exports` edit — which moves what
99
+ * every bare specifier in the app resolves to — or a `tsconfig.json` that turns
100
+ * on a `paths` mapping changes the output with no file of its own in the graph.
101
+ * Per package, deduplicated, so the walk is a handful of stats however many
102
+ * modules that package contributed.
103
+ */
104
+ function resolutionFilesFor(abs) {
105
+ const found = [];
106
+ for (let dir = dirname(abs);; dir = dirname(dir)) {
107
+ const manifest = join(dir, 'package.json');
108
+ if (existsSync(manifest)) {
109
+ found.push(manifest);
110
+ const tsconfig = join(dir, 'tsconfig.json');
111
+ if (existsSync(tsconfig))
112
+ found.push(tsconfig);
113
+ return found;
114
+ }
115
+ const parent = dirname(dir);
116
+ if (parent === dir)
117
+ return found;
118
+ }
119
+ }
120
+ /**
121
+ * A plugin that records every module the build transforms, plus the writer for
122
+ * the manifest.
123
+ *
124
+ * `transform` rather than a graph API (`getModuleIds`/`getModuleInfo`) because
125
+ * it is the one hook both engines run and the native bridge translates
126
+ * (`bundler-pick.ts` picks it verbatim). Every module that reaches the
127
+ * transform stage was LOADED from disk, so the observed set is what the build
128
+ * read — the modules it then tree-shook away are still inputs of the next
129
+ * build, which is the direction that errs safely.
130
+ *
131
+ * The observation is KEPT alongside {@link BundleInputsRecorder.addWatchFiles}
132
+ * rather than replaced by it, and neither engine is the reason: the native
133
+ * engine has no watch list to hand over (`BundleResult` carries warnings and
134
+ * output only, and its JS context had no `addWatchFile` at all until
135
+ * `rolldown-native` grew one), so on GJS the observed set is the only account
136
+ * of the modules; on npm the watch list is a superset of it. Union: the cost of
137
+ * a redundant path is a `stat`, the cost of a missing one is a stale green.
138
+ */
139
+ export function createBundleInputsRecorder(outfile) {
140
+ const seen = new Set();
141
+ // Sticky, and pessimistic: ONE pass without a watch list is enough to know
142
+ // the build cannot see a plugin-read input, and the artifact must not be
143
+ // judged fresh on the strength of the passes that could.
144
+ let watchList = true;
145
+ const record = (raw) => {
146
+ const abs = idToPath(raw);
147
+ if (abs !== null)
148
+ seen.add(abs);
149
+ };
150
+ const plugin = {
151
+ name: 'gjsify:bundle-inputs',
152
+ transform(_code, id) {
153
+ record(id);
154
+ return undefined;
155
+ },
156
+ };
157
+ const inputs = () => {
158
+ const out = [];
159
+ const packages = new Set();
160
+ for (const abs of seen) {
161
+ let isFile;
162
+ try {
163
+ isFile = statSync(abs).isFile();
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ if (!isFile || isToolchainScratch(abs))
169
+ continue;
170
+ out.push(abs);
171
+ // `resolutionFilesFor` walks to the package root; one call per
172
+ // DIRECTORY keeps a 1000-module bundle from walking 1000 times.
173
+ const dir = dirname(abs);
174
+ if (packages.has(dir))
175
+ continue;
176
+ packages.add(dir);
177
+ for (const meta of resolutionFilesFor(abs))
178
+ out.push(meta);
179
+ }
180
+ return [...new Set(out)].sort();
181
+ };
182
+ return {
183
+ plugin,
184
+ addWatchFiles(watch) {
185
+ if (!watch.reportedByEngine)
186
+ watchList = false;
187
+ for (const id of watch.files)
188
+ record(id);
189
+ },
190
+ inputs,
191
+ write() {
192
+ const path = bundleInputsPath(outfile);
193
+ const manifest = { version: BUNDLE_INPUTS_VERSION, inputs: inputs(), watchList };
194
+ const tmp = `${path}.tmp`;
195
+ try {
196
+ mkdirSync(dirname(path), { recursive: true });
197
+ // Rename, not write: a manifest is read by the NEXT process, and
198
+ // a half-written one is a parse error there — which reads as "no
199
+ // manifest" and silently degrades the check to the package walk.
200
+ writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8');
201
+ renameSync(tmp, path);
202
+ }
203
+ catch (err) {
204
+ try {
205
+ rmSync(tmp, { force: true });
206
+ }
207
+ catch {
208
+ // Nothing left to clean up — the write never got that far.
209
+ }
210
+ console.warn(`[gjsify test] could not record the build inputs beside ${path} (${err.message}) — ` +
211
+ 'the next run judges freshness by the package walk alone, which cannot see outside this package.');
212
+ }
213
+ },
214
+ };
215
+ }
216
+ /**
217
+ * The inputs recorded for `outfile` by the build that wrote it, or `null` when
218
+ * there is none to trust — never written, unreadable, or written by another
219
+ * version of this shape. `null` is the fallback the caller must handle, not an
220
+ * error: a bundle built by an older CLI, or copied in from elsewhere, has no
221
+ * manifest and is judged by the package walk alone.
222
+ */
223
+ export function readBundleInputs(outfile) {
224
+ let parsed;
225
+ try {
226
+ parsed = JSON.parse(readFileSync(bundleInputsPath(outfile), 'utf-8'));
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ if (parsed?.version !== BUNDLE_INPUTS_VERSION || !Array.isArray(parsed.inputs))
232
+ return null;
233
+ return {
234
+ inputs: parsed.inputs.filter((abs) => typeof abs === 'string'),
235
+ // v2 only, so a missing flag cannot be v2 — but the pessimistic reading
236
+ // is kept anyway: a manifest that does not vouch for its engine's watch
237
+ // list is not one to judge freshness on.
238
+ watchList: parsed.watchList === true,
239
+ };
240
+ }
241
+ /**
242
+ * Newest mtime (ms) among recorded inputs, or 0 for an empty set.
243
+ *
244
+ * A recorded input that has since VANISHED counts as CHANGED, exactly as the
245
+ * package walk counts one that vanished mid-walk: an input we cannot read is
246
+ * not evidence that the artifact is current. A workspace-package source deleted
247
+ * while the bundle still imports it is precisely that case, and skipping it
248
+ * would rerun a bundle built from a file that no longer exists.
249
+ *
250
+ * Safe only because the toolchain's own scratch is never RECORDED
251
+ * ({@link isToolchainScratch}): the globals-inject stub and the cached plugin
252
+ * bundles are written and deleted between builds, and treating their absence as
253
+ * a change would rebuild on every run, forever.
254
+ */
255
+ export function newestBundleInputMtimeMs(inputs) {
256
+ let newest = 0;
257
+ for (const abs of inputs) {
258
+ try {
259
+ const { mtimeMs } = statSync(abs);
260
+ if (mtimeMs > newest)
261
+ newest = mtimeMs;
262
+ }
263
+ catch {
264
+ return Number.POSITIVE_INFINITY;
265
+ }
266
+ }
267
+ return newest;
268
+ }
@@ -1,3 +1,4 @@
1
+ import { type Win32ResolveContext } from './win32-command.js';
1
2
  export type DepSeverity = 'required' | 'optional';
2
3
  export interface DepCheck {
3
4
  /** Stable key used for install command lookup, e.g. "gjs" */
@@ -15,7 +16,8 @@ export interface DepCheck {
15
16
  export type PackageManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | 'brew' | 'winget' | 'unknown';
16
17
  /**
17
18
  * True when `cmd` is on PATH. Walks `process.env.PATH` with `existsSync`
18
- * instead of shelling out to `which`(1).
19
+ * instead of shelling out to `which`(1), honouring `PATHEXT` on win32 — see
20
+ * `findOnPath` in `win32-command.ts`, which owns the walk.
19
21
  *
20
22
  * `which` is NOT a probe you can rely on: the Fedora 43/44 minimal containers
21
23
  * CI runs in ship without it (only the `which-2.x` rpm provides it), so a
@@ -31,7 +33,7 @@ export type PackageManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | 'brew
31
33
  * reason. It stays a second copy on purpose: it is `.mjs` under `tests/` and
32
34
  * cannot be imported from the shipped CLI.
33
35
  */
34
- export declare function isOnPath(cmd: string): boolean;
36
+ export declare function isOnPath(cmd: string, ctx?: Win32ResolveContext): boolean;
35
37
  /**
36
38
  * The package manager whose install command we may suggest.
37
39
  *
@@ -1,5 +1,5 @@
1
1
  // System dependency checker for gjsify CLI.
2
- // Uses execFileSync with explicit argument arrays — no shell injection possible.
2
+ // Uses spawnSync with explicit argument arrays — no shell injection possible.
3
3
  // All binary names are hardcoded constants, never derived from user input.
4
4
  //
5
5
  // Severity model:
@@ -10,25 +10,31 @@
10
10
  // optional system deps are only checked if the corresponding @gjsify/* package
11
11
  // is in the project's dependency tree. A user with only @gjsify/fs in their
12
12
  // project never sees a warning about libmanette.
13
- import { execFileSync } from 'node:child_process';
13
+ import { spawnSync } from 'node:child_process';
14
14
  import { dirname, join, resolve } from 'node:path';
15
15
  import { createRequire } from 'node:module';
16
16
  import { pathToFileURL } from 'node:url';
17
17
  import { readFileSync, existsSync, readdirSync } from 'node:fs';
18
18
  import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
19
19
  import { findSystemTypelib } from './gi-typelib.js';
20
+ import { toSpawnable } from './spawn.js';
21
+ import { findOnPath } from './win32-command.js';
20
22
  /** Run a binary and return its stdout trimmed, or null if it fails. */
21
23
  function tryExecFile(binary, args) {
22
- try {
23
- return execFileSync(binary, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
24
- }
25
- catch {
26
- return null;
27
- }
24
+ // `spawnSync`, not `execFileSync`: only the former takes the verbatim-argv
25
+ // flag the cmd.exe route of `toSpawnable` needs.
26
+ const inv = toSpawnable(binary, args);
27
+ const r = spawnSync(inv.cmd, inv.args, {
28
+ encoding: 'utf-8',
29
+ stdio: ['ignore', 'pipe', 'ignore'],
30
+ windowsVerbatimArguments: inv.windowsVerbatimArguments,
31
+ });
32
+ return r.error || r.status !== 0 ? null : r.stdout.trim();
28
33
  }
29
34
  /**
30
35
  * True when `cmd` is on PATH. Walks `process.env.PATH` with `existsSync`
31
- * instead of shelling out to `which`(1).
36
+ * instead of shelling out to `which`(1), honouring `PATHEXT` on win32 — see
37
+ * `findOnPath` in `win32-command.ts`, which owns the walk.
32
38
  *
33
39
  * `which` is NOT a probe you can rely on: the Fedora 43/44 minimal containers
34
40
  * CI runs in ship without it (only the `which-2.x` rpm provides it), so a
@@ -44,23 +50,12 @@ function tryExecFile(binary, args) {
44
50
  * reason. It stays a second copy on purpose: it is `.mjs` under `tests/` and
45
51
  * cannot be imported from the shipped CLI.
46
52
  */
47
- export function isOnPath(cmd) {
48
- const pathVar = process.env.PATH;
49
- if (!pathVar)
50
- return false;
51
- const sep = process.platform === 'win32' ? ';' : ':';
52
- for (const dir of pathVar.split(sep)) {
53
- if (!dir)
54
- continue;
55
- try {
56
- if (existsSync(join(dir, cmd)))
57
- return true;
58
- }
59
- catch {
60
- // An inaccessible PATH entry is not an answer about `cmd`.
61
- }
62
- }
63
- return false;
53
+ export function isOnPath(cmd, ctx = hostPathContext()) {
54
+ return findOnPath(cmd, ctx) !== undefined;
55
+ }
56
+ /** This process's PATH, filesystem and platform — what {@link isOnPath} asks by default. */
57
+ function hostPathContext() {
58
+ return { platform: process.platform, env: process.env, exists: existsSync, join };
64
59
  }
65
60
  /** Check if a binary exists and optionally capture its version output. */
66
61
  function checkBinary(id, name, binary, versionArgs, severity, parseVersion, requiredBy) {
@@ -184,11 +184,14 @@ export declare function assertDevLinksBuilt(links: readonly DevLink[]): void;
184
184
  /**
185
185
  * Make `.gjsify-link.json` invisible to git WITHOUT editing a tracked file.
186
186
  *
187
- * `.git/info/exclude` is git's own per-clone ignore list: it is not committed and
188
- * not shared, which is exactly the lifetime of the thing it hides. Writing to
189
- * `.gitignore` instead would put a developer's local override into everyone
190
- * else's diff — the manifest edit this whole feature exists to avoid, one file
191
- * over.
187
+ * `info/exclude` is git's own per-clone ignore list: it is not committed and
188
+ * not shared between clones, which is exactly the lifetime of the thing it
189
+ * hides. Writing to `.gitignore` instead would put a developer's local override
190
+ * into everyone else's diff — the manifest edit this whole feature exists to
191
+ * avoid, one file over. A WORKTREE is the case to get right: the file is
192
+ * written under the repository's COMMON dir, because `info/exclude` is shared
193
+ * across a repository's worktrees and the worktree's own gitdir is not read
194
+ * (see {@link resolveGitDir}).
192
195
  *
193
196
  * Returns what it did, so the caller can say so. A consumer that is not a git
194
197
  * repository is not an error: nothing can commit the file there either. A
@@ -199,7 +202,7 @@ export declare function ensureLocallyIgnored(consumerRoot: string): 'added' | 'a
199
202
  /**
200
203
  * Undo {@link ensureLocallyIgnored} — the other half of `unlink`.
201
204
  *
202
- * Measured: the `.git/info/exclude` block outlived `gjsify unlink` (idempotently,
205
+ * Measured: the `info/exclude` block outlived `gjsify unlink` (idempotently,
203
206
  * so it never grew — it just never left). A command that promises to undo itself
204
207
  * and leaves a line behind is not an undo, and the leftover is a rule about a file
205
208
  * that no longer exists, which the next reader has to research to delete.
@@ -484,11 +484,14 @@ const IGNORE_COMMENT = '# gjsify link — local development override, never comm
484
484
  /**
485
485
  * Make `.gjsify-link.json` invisible to git WITHOUT editing a tracked file.
486
486
  *
487
- * `.git/info/exclude` is git's own per-clone ignore list: it is not committed and
488
- * not shared, which is exactly the lifetime of the thing it hides. Writing to
489
- * `.gitignore` instead would put a developer's local override into everyone
490
- * else's diff — the manifest edit this whole feature exists to avoid, one file
491
- * over.
487
+ * `info/exclude` is git's own per-clone ignore list: it is not committed and
488
+ * not shared between clones, which is exactly the lifetime of the thing it
489
+ * hides. Writing to `.gitignore` instead would put a developer's local override
490
+ * into everyone else's diff — the manifest edit this whole feature exists to
491
+ * avoid, one file over. A WORKTREE is the case to get right: the file is
492
+ * written under the repository's COMMON dir, because `info/exclude` is shared
493
+ * across a repository's worktrees and the worktree's own gitdir is not read
494
+ * (see {@link resolveGitDir}).
492
495
  *
493
496
  * Returns what it did, so the caller can say so. A consumer that is not a git
494
497
  * repository is not an error: nothing can commit the file there either. A
@@ -497,12 +500,12 @@ const IGNORE_COMMENT = '# gjsify link — local development override, never comm
497
500
  */
498
501
  export function ensureLocallyIgnored(consumerRoot) {
499
502
  const found = resolveGitDir(consumerRoot);
500
- if (!found.gitDir)
503
+ if (!found.commonDir)
501
504
  return found.why;
502
505
  const gitignore = join(consumerRoot, '.gitignore');
503
506
  if (existsSync(gitignore) && fileMentionsPattern(gitignore, DEV_LINK_FILE))
504
507
  return 'already-ignored';
505
- const excludePath = join(found.gitDir, 'info', 'exclude');
508
+ const excludePath = join(found.commonDir, 'info', 'exclude');
506
509
  if (existsSync(excludePath) && fileMentionsPattern(excludePath, DEV_LINK_FILE))
507
510
  return 'already-ignored';
508
511
  mkdirSync(dirname(excludePath), { recursive: true });
@@ -514,7 +517,7 @@ export function ensureLocallyIgnored(consumerRoot) {
514
517
  /**
515
518
  * Undo {@link ensureLocallyIgnored} — the other half of `unlink`.
516
519
  *
517
- * Measured: the `.git/info/exclude` block outlived `gjsify unlink` (idempotently,
520
+ * Measured: the `info/exclude` block outlived `gjsify unlink` (idempotently,
518
521
  * so it never grew — it just never left). A command that promises to undo itself
519
522
  * and leaves a line behind is not an undo, and the leftover is a rule about a file
520
523
  * that no longer exists, which the next reader has to research to delete.
@@ -525,9 +528,9 @@ export function ensureLocallyIgnored(consumerRoot) {
525
528
  */
526
529
  export function removeLocalIgnore(consumerRoot) {
527
530
  const found = resolveGitDir(consumerRoot);
528
- if (!found.gitDir)
531
+ if (!found.commonDir)
529
532
  return found.why;
530
- const excludePath = join(found.gitDir, 'info', 'exclude');
533
+ const excludePath = join(found.commonDir, 'info', 'exclude');
531
534
  let text;
532
535
  try {
533
536
  text = readFileSync(excludePath, 'utf-8');
@@ -568,10 +571,21 @@ function fileMentionsPattern(file, pattern) {
568
571
  });
569
572
  }
570
573
  /**
571
- * The `.git` directory for `root` — following the `gitdir:` pointer file a
572
- * worktree or submodule has in place of a directory. Skipping that indirection
573
- * would write an `info/exclude` inside a plain file's parent and silently ignore
574
- * nothing at all.
574
+ * The COMMON dir of `root`'s repository — the one whose `info/exclude` git
575
+ * actually reads. Two indirections get there, and in a worktree neither the
576
+ * repository root nor the gitdir it points at IS that directory.
577
+ *
578
+ * The first is `.git`: a directory in a clone, a `gitdir:` pointer file in a
579
+ * submodule, and a `gitdir:` pointer to `<common>/worktrees/<name>` in a worktree.
580
+ * Skipping that indirection would write an `info/exclude` inside a plain file's
581
+ * parent and silently ignore nothing at all.
582
+ *
583
+ * The second is `commondir`, and it is the one that was missed: `info/exclude` is
584
+ * SHARED across a repository's worktrees (git-worktree(1)), so in a worktree the
585
+ * pointer leads AWAY from the file git reads. Measured: with the entry written
586
+ * under `<common>/worktrees/<name>/info/exclude`, `git status` in the worktree
587
+ * still printed `?? .gjsify-link.json` and `git check-ignore` named no rule — one
588
+ * `git add -A` from committing the override this feature exists to keep out.
575
589
  *
576
590
  * TWO outcomes, not one. "There is no repository here" and "there is one and its
577
591
  * pointer does not parse" call for opposite reactions — the first is fine, the
@@ -585,22 +599,60 @@ function resolveGitDir(root) {
585
599
  stat = statSync(dotGit);
586
600
  }
587
601
  catch {
588
- return { gitDir: null, why: 'no-git' };
602
+ return { commonDir: null, why: 'no-git' };
589
603
  }
590
604
  if (stat.isDirectory())
591
- return { gitDir: dotGit, why: 'no-git' };
605
+ return { commonDir: dotGit, why: 'no-git' };
592
606
  let pointer;
593
607
  try {
594
608
  pointer = readFileSync(dotGit, 'utf-8').trim();
595
609
  }
596
610
  catch {
597
- return { gitDir: null, why: 'unreadable-git' };
611
+ return { commonDir: null, why: 'unreadable-git' };
598
612
  }
599
613
  const match = /^gitdir:\s*(.+)$/.exec(pointer);
600
614
  const target = match?.[1]?.trim();
601
615
  if (!target)
602
- return { gitDir: null, why: 'unreadable-git' };
603
- return { gitDir: isAbsolute(target) ? target : resolve(root, target), why: 'no-git' };
616
+ return { commonDir: null, why: 'unreadable-git' };
617
+ return followCommondir(isAbsolute(target) ? target : resolve(root, target));
618
+ }
619
+ /**
620
+ * The shared directory of the gitdir a `gitdir:` pointer named.
621
+ *
622
+ * A plain clone and a submodule have no `commondir` file: there the gitdir IS the
623
+ * common dir, and nothing changes. A worktree has one — `../..`, relative to the
624
+ * worktree gitdir, is what `git worktree add` writes — and an absolute path is
625
+ * honored rather than resolved against the gitdir, where it would name a
626
+ * directory that does not exist.
627
+ *
628
+ * A `commondir` that names NOTHING is not read as absent, which is the decision
629
+ * this shape exists to force. Measured against real git, all three such shapes
630
+ * exit 128: blank → "error reading …/commondir", a directory → the same, a path
631
+ * that is not there → "not a git repository". So there is no answer to fall back
632
+ * to, and `no-git` would announce "nothing here can commit the file" about a
633
+ * repository that just refused to be one — the same "I could not tell" that is not
634
+ * a "yes", as everywhere else in this file.
635
+ */
636
+ function followCommondir(gitDir) {
637
+ const file = join(gitDir, 'commondir');
638
+ if (!existsSync(file))
639
+ return { commonDir: gitDir, why: 'no-git' };
640
+ let raw;
641
+ try {
642
+ raw = readFileSync(file, 'utf-8');
643
+ }
644
+ catch {
645
+ return { commonDir: null, why: 'unreadable-git' };
646
+ }
647
+ const target = raw.trim();
648
+ if (!target)
649
+ return { commonDir: null, why: 'unreadable-git' };
650
+ const commonDir = isAbsolute(target) ? target : resolve(gitDir, target);
651
+ // The third shape git refuses: writing an `info/exclude` under a directory that
652
+ // is not there would create a tree that nothing ever reads.
653
+ if (!existsSync(commonDir))
654
+ return { commonDir: null, why: 'unreadable-git' };
655
+ return { commonDir, why: 'no-git' };
604
656
  }
605
657
  /**
606
658
  * `--immutable` must never accept an active override — FAIL CLOSED.