@vzn/vx 0.0.521 → 0.0.523

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.521",
3
+ "version": "0.0.523",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.521",
25
- "@vzn/vx-linux-arm64": "0.0.521",
26
- "@vzn/vx-darwin-x64": "0.0.521",
27
- "@vzn/vx-darwin-arm64": "0.0.521"
24
+ "@vzn/vx-linux-x64": "0.0.523",
25
+ "@vzn/vx-linux-arm64": "0.0.523",
26
+ "@vzn/vx-darwin-x64": "0.0.523",
27
+ "@vzn/vx-darwin-arm64": "0.0.523"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -34,6 +34,7 @@
34
34
  import { Database } from 'bun:sqlite'
35
35
  import {
36
36
  accessSync,
37
+ chmodSync,
37
38
  closeSync,
38
39
  constants,
39
40
  existsSync,
@@ -169,38 +170,6 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
169
170
  }
170
171
  }
171
172
 
172
- /** Say once, on the channel the opener has, that an upgrade emptied the index. */
173
- export function noteSchemaReset(cache: Cache, warn: (message: string) => void): void {
174
- if (cache.storeFallback !== null) {
175
- warn(
176
- `[vx] shared cache store ${cache.storeFallback} is not usable; entries stay in ${cache.storeDir ?? 'this workspace'}, where no other workspace hits them`,
177
- )
178
- }
179
- if (cache.storeMoved !== null) {
180
- const { from, to } = cache.storeMoved
181
- warn(
182
- `[vx] cache entries now live in the shared store ${to}; the ones in ${from} miss once, and \`vx cache prune\` reclaims their artifacts`,
183
- )
184
- }
185
- if (cache.storeReset !== null) {
186
- const { from, to } = cache.storeReset
187
- warn(
188
- `[vx] shared cache store ${cache.storeDir} re-indexed: schema ${from} → ${to} (vx version changed); its artifacts stay and are indexed again as tasks hit them`,
189
- )
190
- }
191
- if (cache.formatChange !== null) {
192
- const { from, to } = cache.formatChange
193
- warn(
194
- `[vx] cache format changed: ${from} → ${to} (vx version changed); every cached task misses once and re-saves, and the old entries, never read again, age out under \`vx cache prune --older-than\` or \`cacheRetention\``,
195
- )
196
- return
197
- }
198
- if (cache.schemaReset === null) return
199
- const { from, to } = cache.schemaReset
200
- warn(
201
- `[vx] cache index reset: schema ${from} → ${to} (vx version changed); run history starts over, and each artifact is indexed again when its task next hits`,
202
- )
203
- }
204
173
  // SCHEMA history (drop+recreate on mismatch; pre-alpha, no migrations):
205
174
  // v20: file_hashes.content_hash (git blob OIDs).
206
175
  // v21: dropped the unused outputs_hash column (pure-input hashing).
@@ -459,7 +428,16 @@ function ensurePrivateDir(dir: string): string | null {
459
428
  if (!st.isDirectory()) return `${dir} is not a directory`
460
429
  const uid = process.getuid?.()
461
430
  if (uid !== undefined && st.uid !== uid) return `${dir} belongs to another user`
462
- if ((st.mode & 0o077) !== 0) return `${dir} is open to other users (chmod 700 it)`
431
+ // The user's own directory open to others (a `~/.vx` made at the
432
+ // default umask, 0755) is vx's to close: refusing it left the store
433
+ // unused on a stock macOS home (owner, 2026-10-06: "you should own it").
434
+ if ((st.mode & 0o077) !== 0) {
435
+ try {
436
+ chmodSync(dir, 0o700)
437
+ } catch (err) {
438
+ return `${dir} is open to other users and chmod 700 failed (${errorText(err)})`
439
+ }
440
+ }
463
441
  return null
464
442
  }
465
443
 
@@ -550,8 +528,7 @@ export class Cache implements CacheLayer {
550
528
 
551
529
  /**
552
530
  * The store this open was asked for and could not use, with why: the
553
- * entries went to a store inside `cacheDir` instead. Said once by
554
- * `noteSchemaReset`.
531
+ * entries went to a store inside `cacheDir` instead.
555
532
  */
556
533
  readonly storeFallback: string | null = null
557
534
 
@@ -561,24 +538,22 @@ export class Cache implements CacheLayer {
561
538
  /**
562
539
  * Set when THIS open found an index written by another `SCHEMA_VERSION`
563
540
  * and dropped every table. The next open sees the current version and
564
- * reports null, so whoever opened first is the one that can say so —
565
- * `noteSchemaReset` is that one line, at every opener that has a
566
- * channel. Without it an upgrade empties the cache and the history in
567
- * silence, and the all-miss run that follows looks like a bug.
541
+ * reports null. vx prints nothing for it: the cache is vx's to keep
542
+ * (owner, 2026-10-06).
568
543
  */
569
544
  readonly schemaReset: SchemaReset | null = null
570
545
 
571
546
  /**
572
547
  * Set when THIS open found entries written under another
573
548
  * `CACHE_VERSION`: the index survives, but no old key is derived again,
574
- * so every cached task misses once. Reported by `noteSchemaReset`.
549
+ * so every cached task misses once. Not printed, like `schemaReset`.
575
550
  */
576
551
  readonly formatChange: SchemaReset | null = null
577
552
 
578
553
  /**
579
554
  * Set when THIS open found the shared store written under another
580
555
  * `SCHEMA_VERSION` and dropped its inventory; the artifacts stay and are
581
- * indexed again as they hit. Reported by `noteSchemaReset`.
556
+ * indexed again as they hit. Never printed: the cache is vx's to keep.
582
557
  */
583
558
  readonly storeReset: SchemaReset | null = null
584
559
 
@@ -214,6 +214,22 @@ export class GitFilesCache extends Map<string, readonly string[]> {
214
214
  this.dirty = dirty
215
215
  }
216
216
 
217
+ /** The populate's `git var -l`, for `configValue`. */
218
+ private vars: Map<string, string> | null = null
219
+
220
+ setGitVars(listing: string | null): void {
221
+ this.vars = listing === null ? null : gitVarList(listing)
222
+ }
223
+
224
+ /**
225
+ * A config key's value as `git config --get` answers it (the last one),
226
+ * from the populate's `git var -l`: null when the config has none,
227
+ * undefined when no listing was read.
228
+ */
229
+ configValue(key: string): string | null | undefined {
230
+ return this.vars === null ? undefined : (this.vars.get(key) ?? null)
231
+ }
232
+
217
233
  markOutputsChanged(projectDir: string, relPaths: readonly string[]): void {
218
234
  this.recordChanged(projectDir, relPaths)
219
235
  // The workspace-wide partition sees the same files under
@@ -774,6 +790,12 @@ async function dropFilteredOids(
774
790
  pathspecs: readonly string[]
775
791
  gitVars: string
776
792
  spawnGit: (a: string[], stdin?: string) => Promise<GitRun | null>
793
+ /**
794
+ * The same `check-attr`, already started over every listed index entry
795
+ * (a superset of `trusted`) because `attributesInIndex` fired before
796
+ * `status` was back.
797
+ */
798
+ early?: Promise<GitRun | null> | undefined
777
799
  },
778
800
  ): Promise<void> {
779
801
  if (trusted.size === 0) return
@@ -823,14 +845,48 @@ async function dropFilteredOids(
823
845
  // blob's OID keyed an edit git calls clean, and the run replayed the old
824
846
  // output (item 978). Git LFS is a `filter` as well: its files are hashed
825
847
  // from disk now, the one honest identity of what a task reads.
826
- const res = await args.spawnGit(
827
- ['check-attr', '--stdin', '-z', 'text', 'eol', 'ident', 'filter', 'working-tree-encoding'],
828
- [...trusted.keys()].join('\0'),
829
- )
848
+ const res = await (args.early ?? args.spawnGit(CHECK_ATTR, [...trusted.keys()].join('\0')))
830
849
  if (res === null || res.exitCode !== 0) return
831
850
  for (const rel of parseCheckAttrOutput(res.stdout)) trusted.delete(rel)
832
851
  }
833
852
 
853
+ const CHECK_ATTR = [
854
+ 'check-attr',
855
+ '--stdin',
856
+ '-z',
857
+ 'text',
858
+ 'eol',
859
+ 'ident',
860
+ 'filter',
861
+ 'working-tree-encoding',
862
+ ]
863
+
864
+ /**
865
+ * The filter gate's half that needs only the index listing: an attributes
866
+ * file tracked in it, in `$GIT_DIR/info/attributes`, or above the scanned
867
+ * dirs. When it fires, `dropFilteredOids` is sure to ask `check-attr`, so the
868
+ * enumeration starts it beside `status` (~8 ms on this repo, U-3)
869
+ * instead of after it. When it does not, the whole gate decides later.
870
+ */
871
+ function attributesInIndex(args: {
872
+ tracked: readonly string[]
873
+ workspaceRoot: string
874
+ gitDir: string
875
+ gitPrefix: string
876
+ pathspecs: readonly string[]
877
+ }): boolean {
878
+ if (
879
+ args.gitDir !== '' &&
880
+ existsSync(path.resolve(args.workspaceRoot, args.gitDir, 'info', 'attributes'))
881
+ ) {
882
+ return true
883
+ }
884
+ for (const rel of args.tracked) {
885
+ if (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) return true
886
+ }
887
+ return attributesAbove(args)
888
+ }
889
+
834
890
  /**
835
891
  * The repository root, derived from the workspace root and git's own
836
892
  * `--show-prefix` (the repo→workspace path). Exact and free: the prefix
@@ -1095,6 +1151,8 @@ export interface GitEnumeration {
1095
1151
  trusted: Map<string, string>
1096
1152
  /** Whether the worktree had uncommitted changes; null when `git status` failed. */
1097
1153
  dirty: boolean | null
1154
+ /** `git var -l`'s listing (merged config, then git's variables); null when it failed. */
1155
+ vars: string | null
1098
1156
  /**
1099
1157
  * What `git status` listed, workspace-relative: modified, staged or deleted
1100
1158
  * paths (both sides of a rename) and untracked files. Null when it failed.
@@ -1259,7 +1317,20 @@ export async function startGitEnumeration(
1259
1317
  }
1260
1318
  }
1261
1319
  const indexKeyRead = readIndexKey()
1262
- const [ls, status, vars] = await running
1320
+ const gitPrefix = facts?.prefix ?? ''
1321
+ const gitDir = facts?.commonDir ?? ''
1322
+ const ls = await listing
1323
+ const parsedLs =
1324
+ ls !== null && ls.exitCode === 0
1325
+ ? parseLsFilesOutput(ls.stdout.length === 0 ? [] : ls.stdout.split('\0'), ls.undecodable)
1326
+ : undefined
1327
+ const early =
1328
+ parsedLs !== undefined &&
1329
+ parsedLs.oids.size > 0 &&
1330
+ attributesInIndex({ tracked: parsedLs.files, workspaceRoot, gitDir, gitPrefix, pathspecs })
1331
+ ? spawnGit(CHECK_ATTR, [...parsedLs.oids.keys()].join('\0'))
1332
+ : undefined
1333
+ const [, status, vars] = await running
1263
1334
  // The key stands for the index `ls-files` listed only when that index
1264
1335
  // was written before the enumeration began (git writes it by rename, so
1265
1336
  // the stamp moves with any write) and is still in place after it: a
@@ -1281,20 +1352,14 @@ export async function startGitEnumeration(
1281
1352
  if (ls === null) {
1282
1353
  throw gitSpawnRefusal(workspaceRoot)
1283
1354
  }
1284
- if (ls.exitCode !== 0) {
1355
+ if (parsedLs === undefined) {
1285
1356
  const stderr = ls.stderr.trim()
1286
1357
  throw new UserError(
1287
1358
  `vx requires git: ${workspaceRoot} is not inside a git work tree. ` +
1288
1359
  `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
1289
1360
  )
1290
1361
  }
1291
- const {
1292
- files: tracked,
1293
- oids,
1294
- flagged,
1295
- gitlinks,
1296
- undecodable,
1297
- } = parseLsFilesOutput(ls.stdout.length === 0 ? [] : ls.stdout.split('\0'), ls.undecodable)
1362
+ const { files: tracked, oids, flagged, gitlinks, undecodable } = parsedLs
1298
1363
  // Normalize `status`'s repo-root-relative paths to workspace-relative (strip
1299
1364
  // the `--show-prefix`) so the dirty set is keyed identically to the trusted
1300
1365
  // OID map. Without this, when the workspace root is a git subdir, a modified
@@ -1302,8 +1367,6 @@ export async function startGitEnumeration(
1302
1367
  // a STALE cache hit serving old outputs. Empty prefix (workspace == git root,
1303
1368
  // the common case) is a zero-cost no-op. Paths above the workspace can't be
1304
1369
  // inputs, so they drop out of the set.
1305
- const gitPrefix = facts?.prefix ?? ''
1306
- const gitDir = facts?.commonDir ?? ''
1307
1370
  const parsedStatus =
1308
1371
  status !== null && status.exitCode === 0
1309
1372
  ? parseStatusOutput(status.stdout, status.undecodable)
@@ -1357,6 +1420,7 @@ export async function startGitEnumeration(
1357
1420
  pathspecs,
1358
1421
  gitVars: vars !== null && vars.exitCode === 0 ? vars.stdout : '',
1359
1422
  spawnGit,
1423
+ early,
1360
1424
  })
1361
1425
  if (vars !== null && vars.exitCode === 0 && fileModeIgnored(vars.stdout)) {
1362
1426
  restampModes(trusted, workspaceRoot)
@@ -1368,6 +1432,7 @@ export async function startGitEnumeration(
1368
1432
  all,
1369
1433
  trusted,
1370
1434
  dirty: worktreeDirty,
1435
+ vars: vars !== null && vars.exitCode === 0 ? vars.stdout : null,
1371
1436
  changed: dirty === null ? null : [...dirty, ...untracked],
1372
1437
  untracked: dirty === null ? null : untracked,
1373
1438
  undecodable,
@@ -1436,6 +1501,7 @@ export async function applyGitEnumeration(
1436
1501
  await dropResizedOids(enumeration, memo)
1437
1502
  const { all, trusted } = enumeration
1438
1503
  cache.setWorktreeDirty(enumeration.dirty)
1504
+ cache.setGitVars(enumeration.vars)
1439
1505
  cache.enumeratedAtMs = enumeration.startedAtMs
1440
1506
  cache.markUndecodable(enumeration.undecodable.map((rel) => path.join(workspaceRoot, rel)))
1441
1507
  // Sort once, then each project's files are a contiguous range found
@@ -25,7 +25,6 @@ export {
25
25
  FILE_HASH_RACY_MS,
26
26
  OUTPUT_DIRS_RACY_MS,
27
27
  racyWindowMs,
28
- noteSchemaReset,
29
28
  type SchemaReset,
30
29
  SCHEMA_VERSION,
31
30
  } from './cache.js'
@@ -8,6 +8,12 @@
8
8
 
9
9
  /** `mtime_ms` of a recorded output prefix that did not exist when the snapshot was taken. */
10
10
  const ABSENT_DIR_MTIME = -1
11
+ /**
12
+ * `mtime_ms` of a recorded output prefix that was a regular file: a literal
13
+ * output naming one (`public/app.js`). The set under it is the file alone
14
+ * while it stays a regular file; its bytes are the per-file check's.
15
+ */
16
+ const FILE_PREFIX_MTIME = -2
11
17
 
12
18
  import type { Database } from 'bun:sqlite'
13
19
  import { inHashes, lazyStatement } from './schema.js'
@@ -244,7 +250,14 @@ export class OutputIndex {
244
250
  }
245
251
  return false
246
252
  }
247
- if (!st.isDirectory()) return false
253
+ if (!st.isDirectory()) {
254
+ // A literal output naming a file walked its glob on every hit: two
255
+ // of this repo's site tasks, ~6 ms of each warm run (U-5).
256
+ if (!isPrefix || !st.isFile()) return false
257
+ rows.push([rel, FILE_PREFIX_MTIME])
258
+ files.push(rel)
259
+ return true
260
+ }
248
261
  rows.push([rel, st.mtimeMs])
249
262
  if (rows.length > OUTPUT_DIRS_CAP) return false
250
263
  let entries
@@ -379,6 +392,7 @@ export class OutputIndex {
379
392
  return r.mtimeMs === ABSENT_DIR_MTIME
380
393
  }
381
394
  if (r.mtimeMs === ABSENT_DIR_MTIME) return false
395
+ if (r.mtimeMs === FILE_PREFIX_MTIME) return st.isFile()
382
396
  return st.isDirectory() && Math.abs(st.mtimeMs - r.mtimeMs) < 1
383
397
  })
384
398
  return results.every(Boolean)
package/src/cli/cache.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { Cache, noteSchemaReset } from '../cache/index.js'
2
+ import { Cache } from '../cache/index.js'
3
3
  import { flagHint, refusedWord, seeHelp } from './help.js'
4
4
  import { nearest, parseDuration, parseSize } from '../util/index.js'
5
5
  import { acquireRunLock } from '../orchestrator/index.js'
@@ -189,7 +189,6 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
189
189
  // stack (an unprivileged user on a root-owned `.vx`, 2026-09-16). A dry
190
190
  // run only reads, and reads a read-only cache fine.
191
191
  if (!parsed.dryRun) cache.assertWritable()
192
- noteSchemaReset(cache, warnToStderr)
193
192
  // A prune beside a run on this workspace evicts what the run has just
194
193
  // probed as hits, whose `accessed_at` bumps it has not flushed yet; the
195
194
  // run then re-runs those tasks (upstream survey, nx#36688). So a prune
package/src/cli/last.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // Read-only — no config evaluation, no re-hash, no cache probe.
6
6
 
7
7
  import type { Database } from 'bun:sqlite'
8
- import { Cache, noteSchemaReset } from '../cache/index.js'
8
+ import { Cache } from '../cache/index.js'
9
9
  import { formatBytes } from './format.js'
10
10
  import { flagHint, seeHelp } from './help.js'
11
11
  import {
@@ -21,7 +21,7 @@ import {
21
21
  } from '../orchestrator/index.js'
22
22
  import { formatElapsed, UserError } from '../util/index.js'
23
23
  import { findWorkspaceRoot } from '../workspace/index.js'
24
- import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
24
+ import { cliCacheDir, parseCacheDirFlag } from './workspace-config.js'
25
25
 
26
26
  interface LastArgs {
27
27
  runId?: string
@@ -232,7 +232,6 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
232
232
 
233
233
  const root = await findWorkspaceRoot(process.cwd())
234
234
  const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
235
- noteSchemaReset(cache, warnToStderr)
236
235
  try {
237
236
  const db = cache.dbHandle()
238
237
  // Nothing recorded at all is its own answer: `--failed` said "no
package/src/cli/why.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // no config evaluation, no re-hash.
7
7
 
8
8
  import path from 'node:path'
9
- import { Cache, noteSchemaReset } from '../cache/index.js'
9
+ import { Cache } from '../cache/index.js'
10
10
  import { flagHint, seeHelp } from './help.js'
11
11
  import { splitTaskId } from '../graph/index.js'
12
12
  import {
@@ -18,7 +18,7 @@ import {
18
18
  } from '../orchestrator/index.js'
19
19
  import { MASKED, nearMatches, printable, UserError } from '../util/index.js'
20
20
  import { findWorkspaceRoot } from '../workspace/index.js'
21
- import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
21
+ import { cliCacheDir, parseCacheDirFlag } from './workspace-config.js'
22
22
 
23
23
  interface WhyArgs {
24
24
  target?: string
@@ -185,7 +185,6 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
185
185
 
186
186
  const root = await findWorkspaceRoot(process.cwd())
187
187
  const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
188
- noteSchemaReset(cache, warnToStderr)
189
188
  try {
190
189
  const db = cache.dbHandle()
191
190
  const taskId = resolveTarget(cache, await rootSpelled(root, parsed.target))
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs'
8
8
  import path from 'node:path'
9
9
  import type { WorkspaceConfig } from '../config.js'
10
10
  import { UserError } from '../util/index.js'
11
- import { Cache, noteSchemaReset } from '../cache/index.js'
11
+ import { Cache } from '../cache/index.js'
12
12
  import { discoverProjects, loadProjects, loadWorkspacePlugins } from '../orchestrator/index.js'
13
13
  import type { VxPlugin } from '../orchestrator/index.js'
14
14
  import {
@@ -147,7 +147,6 @@ export async function loadCliProjects(
147
147
  'open',
148
148
  opts.cacheDir === undefined ? ws.storeRoot : null,
149
149
  )
150
- if (cache !== null) noteSchemaReset(cache, warnToStderr)
151
150
  try {
152
151
  const loaded = await loadProjects({
153
152
  workspaceRoot,
@@ -7,7 +7,7 @@
7
7
 
8
8
  import os from 'node:os'
9
9
  import path from 'node:path'
10
- import { Cache, CACHE_VERSION, noteSchemaReset, SCHEMA_VERSION } from '../cache/index.js'
10
+ import { Cache, CACHE_VERSION, SCHEMA_VERSION } from '../cache/index.js'
11
11
  import { PLUGIN_HOOKS } from '../config.js'
12
12
  import {
13
13
  cgroupCpuQuota,
@@ -154,7 +154,6 @@ async function collectWorkspaceInfo(
154
154
  const metas = await discoverProjects(await loadWorkspace(root, reads), plugins, cacheDir, warn)
155
155
  const storeRoot = opts.cacheDir === undefined ? resolveStoreRoot(root, workspaceConfig) : null
156
156
  const cache = Cache.inspect(cacheDir)
157
- noteSchemaReset(cache, warn)
158
157
  let stats
159
158
  let orphans
160
159
  let flaky: FlakyTask[]
@@ -12,7 +12,6 @@ import type { WorkspaceConfig } from '../config.js'
12
12
  import { beginRun, mark, nearest, UserError } from '../util/index.js'
13
13
  import {
14
14
  Cache,
15
- noteSchemaReset,
16
15
  type CacheLayer,
17
16
  type CachePolicy,
18
17
  FULL_CACHE_POLICY,
@@ -287,7 +286,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
287
286
  storeRoot,
288
287
  )
289
288
  localCache.assertWritable()
290
- noteSchemaReset(localCache, (m) => log.status(m))
291
289
  // Two digests from one read: the config-evaluation cache keys on every
292
290
  // file (a config may import a dependency), the task keys on the files no
293
291
  // plugin claims (`VxPlugin.fingerprint`).
@@ -4,9 +4,11 @@
4
4
  // unset env degrades each field to null and NEVER throws. Telemetry must
5
5
  // not be able to fail a build.
6
6
  //
7
- // Cost: ONE git spawn (`captureGitContext`) on every run, plus two more
8
- // (`captureWorkspaceIdentity`, `captureDefaultBranch`) only when a plugin
9
- // declares the `telemetry` capability — nobody else needs those fields.
7
+ // Cost: HEAD read from `.git` on every run (`captureGitContext`), plus
8
+ // `refs/remotes/origin/HEAD` and the enumeration's `remote.origin.url`
9
+ // only when a plugin declares the `telemetry` capability — nobody else
10
+ // needs those fields. Each falls back to a `git` spawn where the files or
11
+ // the enumeration cannot answer.
10
12
 
11
13
  import fs from 'node:fs'
12
14
  import os from 'node:os'
@@ -115,28 +117,9 @@ function readHeadDirect(
115
117
  workspaceRoot: string,
116
118
  ): { commitSha: string; branch: string | null } | null {
117
119
  try {
118
- let gitDir = path.join(workspaceRoot, '.git')
119
- const st = fs.statSync(gitDir)
120
- if (st.isFile()) {
121
- const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, 'utf8'))
122
- if (m === null) return null
123
- gitDir = path.resolve(workspaceRoot, m[1]!.trim())
124
- }
125
- let commonDir = gitDir
126
- const commonFile = path.join(gitDir, 'commondir')
127
- if (fs.existsSync(commonFile)) {
128
- commonDir = path.resolve(gitDir, fs.readFileSync(commonFile, 'utf8').trim())
129
- }
130
- // Reftable storage (`git init --ref-format=reftable`, git 2.45): HEAD
131
- // names `refs/heads/.invalid` and the refs live in `reftable/`, so the
132
- // files say nothing true about HEAD. A leftover `packed-refs` there was
133
- // read as the answer; git is asked instead.
134
- if (
135
- fs.existsSync(path.join(gitDir, 'reftable')) ||
136
- fs.existsSync(path.join(commonDir, 'reftable'))
137
- ) {
138
- return null
139
- }
120
+ const dirs = refDirsOf(workspaceRoot)
121
+ if (dirs === null) return null
122
+ const { gitDir, commonDir } = dirs
140
123
  const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
141
124
  const SHA = /^[0-9a-f]{40,64}$/
142
125
  if (SHA.test(head)) return { commitSha: head, branch: null }
@@ -166,6 +149,57 @@ function readHeadDirect(
166
149
  }
167
150
  }
168
151
 
152
+ /**
153
+ * The git dir and common dir behind `workspaceRoot` when its refs are files
154
+ * this code reads: a `.git` directory or a `.git` FILE (`gitdir: …`, a linked
155
+ * worktree, whose shared refs live under `commondir`). Null for a reftable
156
+ * repository (`git init --ref-format=reftable`, git 2.45): HEAD names
157
+ * `refs/heads/.invalid` and the refs live in `reftable/`, so the files say
158
+ * nothing true. A leftover `packed-refs` there was read as the answer.
159
+ * Throws when there is no `.git` at the root (a workspace in a subdirectory).
160
+ */
161
+ function refDirsOf(workspaceRoot: string): { gitDir: string; commonDir: string } | null {
162
+ let gitDir = path.join(workspaceRoot, '.git')
163
+ const st = fs.statSync(gitDir)
164
+ if (st.isFile()) {
165
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, 'utf8'))
166
+ if (m === null) return null
167
+ gitDir = path.resolve(workspaceRoot, m[1]!.trim())
168
+ }
169
+ let commonDir = gitDir
170
+ const commonFile = path.join(gitDir, 'commondir')
171
+ if (fs.existsSync(commonFile)) {
172
+ commonDir = path.resolve(gitDir, fs.readFileSync(commonFile, 'utf8').trim())
173
+ }
174
+ if (
175
+ fs.existsSync(path.join(gitDir, 'reftable')) ||
176
+ fs.existsSync(path.join(commonDir, 'reftable'))
177
+ ) {
178
+ return null
179
+ }
180
+ return { gitDir, commonDir }
181
+ }
182
+
183
+ /**
184
+ * `refs/remotes/origin/HEAD`'s target from the files, as `git symbolic-ref
185
+ * --short` names it (`origin/main`): `undefined` when the files cannot say
186
+ * (no `.git` at the root, reftable), null when there is no such symbolic
187
+ * ref. A symbolic ref is never packed, so a missing file is git's answer
188
+ * too.
189
+ */
190
+ function readOriginHeadDirect(workspaceRoot: string): string | null | undefined {
191
+ try {
192
+ const dirs = refDirsOf(workspaceRoot)
193
+ if (dirs === null) return undefined
194
+ const file = path.join(dirs.commonDir, 'refs', 'remotes', 'origin', 'HEAD')
195
+ if (!fs.existsSync(file)) return null
196
+ const ref = /^ref:\s*refs\/remotes\/(.+)$/.exec(fs.readFileSync(file, 'utf8').trim())
197
+ return ref === null ? undefined : ref[1]!
198
+ } catch {
199
+ return undefined
200
+ }
201
+ }
202
+
169
203
  /**
170
204
  * The branch a CI provider says this checkout came from, or null.
171
205
  *
@@ -221,30 +255,34 @@ export function captureDefaultBranch(
221
255
  }
222
256
  }
223
257
 
224
- try {
225
- const proc = Bun.spawnSync({
226
- cmd: [
227
- executablePath('git'),
228
- '-C',
229
- workspaceRoot,
230
- 'symbolic-ref',
231
- '--short',
232
- 'refs/remotes/origin/HEAD',
233
- ],
234
- stdout: 'pipe',
235
- stderr: 'ignore',
236
- })
237
- if (proc.exitCode === 0) {
238
- const ref = new TextDecoder().decode(proc.stdout).trim()
239
- // `refs/remotes/origin/HEAD` → `origin/main`; strip the remote prefix.
240
- const slash = ref.indexOf('/')
241
- const name = slash >= 0 ? ref.slice(slash + 1) : ref
242
- if (name.length > 0) return name
258
+ // The file first, as `captureGitContext` reads HEAD: a spawn here was
259
+ // ~8 ms of every run a telemetry plugin is declared in.
260
+ let ref = readOriginHeadDirect(workspaceRoot)
261
+ if (ref === undefined) {
262
+ ref = null
263
+ try {
264
+ const proc = Bun.spawnSync({
265
+ cmd: [
266
+ executablePath('git'),
267
+ '-C',
268
+ workspaceRoot,
269
+ 'symbolic-ref',
270
+ '--short',
271
+ 'refs/remotes/origin/HEAD',
272
+ ],
273
+ stdout: 'pipe',
274
+ stderr: 'ignore',
275
+ })
276
+ if (proc.exitCode === 0) ref = new TextDecoder().decode(proc.stdout).trim()
277
+ } catch {
278
+ // git unavailable / no remote HEAD ref: null.
243
279
  }
244
- } catch {
245
- // git unavailable / no remote HEAD ref: null.
246
280
  }
247
- return null
281
+ if (ref === null) return null
282
+ // `refs/remotes/origin/HEAD` → `origin/main`; strip the remote prefix.
283
+ const slash = ref.indexOf('/')
284
+ const name = slash >= 0 ? ref.slice(slash + 1) : ref
285
+ return name.length > 0 ? name : null
248
286
  }
249
287
 
250
288
  // Recognized CI env vars in match-priority order. The first whose value
@@ -324,28 +362,38 @@ export function normalizeRemoteUrl(raw: string): string {
324
362
  * checkout keeps a stable identity across runs; `.vx/` is already
325
363
  * gitignored infrastructure);
326
364
  * 3. unwritable `.vx/` → the root path itself (stable per machine).
327
- * One `git` spawn behind try/catch; call sites gate on telemetry being
328
- * active so a plain run never pays it. Never throws.
365
+ * The URL comes from the run's own `git var -l` when it has one, else one
366
+ * `git` spawn behind try/catch; call sites gate on telemetry being active
367
+ * so a plain run never pays it. Never throws.
329
368
  */
330
- export function captureWorkspaceIdentity(workspaceRoot: string): WorkspaceIdentity {
369
+ export function captureWorkspaceIdentity(
370
+ workspaceRoot: string,
371
+ /**
372
+ * `remote.origin.url` as the run's enumeration read it (`git var -l`, the
373
+ * same merged config): null when it has none. Undefined asks git.
374
+ */
375
+ originUrl?: string | null,
376
+ ): WorkspaceIdentity {
331
377
  const base = workspaceRoot.replace(/\/+$/, '').split('/').pop() || 'workspace'
332
378
  try {
333
- const proc = Bun.spawnSync({
334
- // `config --get`, NOT `remote get-url`: get-url applies insteadOf
335
- // rewrites, so two developers mirroring the same repo through
336
- // different proxies would derive different workspace ids. The raw
337
- // configured URL is the identity.
338
- cmd: [executablePath('git'), '-C', workspaceRoot, 'config', '--get', 'remote.origin.url'],
339
- stdout: 'pipe',
340
- stderr: 'ignore',
341
- })
342
- if (proc.exitCode === 0) {
343
- const url = new TextDecoder().decode(proc.stdout).trim()
344
- if (url.length > 0) {
345
- const normalized = normalizeRemoteUrl(url)
346
- const name = normalized.split('/').pop() || base
347
- return { id: xxh3hex(normalized), name }
348
- }
379
+ let url = originUrl ?? ''
380
+ if (originUrl === undefined) {
381
+ const proc = Bun.spawnSync({
382
+ // `config --get`, NOT `remote get-url`: get-url applies insteadOf
383
+ // rewrites, so two developers mirroring the same repo through
384
+ // different proxies would derive different workspace ids. The raw
385
+ // configured URL is the identity.
386
+ cmd: [executablePath('git'), '-C', workspaceRoot, 'config', '--get', 'remote.origin.url'],
387
+ stdout: 'pipe',
388
+ stderr: 'ignore',
389
+ })
390
+ if (proc.exitCode === 0) url = new TextDecoder().decode(proc.stdout)
391
+ }
392
+ url = url.trim()
393
+ if (url.length > 0) {
394
+ const normalized = normalizeRemoteUrl(url)
395
+ const name = normalized.split('/').pop() || base
396
+ return { id: xxh3hex(normalized), name }
349
397
  }
350
398
  } catch {
351
399
  // git unavailable — fall through to the salt.
@@ -720,9 +720,13 @@ async function runOnBus(
720
720
  // but declines still pays — its answer is only knowable by asking.
721
721
  const hasTelemetryPlugin = prepared.plugins.some((p) => p.telemetry !== undefined)
722
722
  if (hasTelemetryPlugin || options.telemetrySinks !== undefined) {
723
- // Workspace identity (telemetry v2): one git spawn, paid only when a
724
- // telemetry consumer can exist — a plain run never reaches here.
725
- const wsIdentity = captureWorkspaceIdentity(workspaceRoot)
723
+ // Workspace identity (telemetry v2), paid only when a telemetry
724
+ // consumer can exist — a plain run never reaches here. The remote URL
725
+ // is the enumeration's `git var -l`: a spawn here cost ~4 ms.
726
+ const wsIdentity = captureWorkspaceIdentity(
727
+ workspaceRoot,
728
+ gitFilesCache.configValue('remote.origin.url'),
729
+ )
726
730
  runContextRecord = {
727
731
  runId,
728
732
  vxVersion: VERSION,
@@ -165,6 +165,10 @@ const IMPURE_LITERAL_RE = /constructor|__proto__|prototype/
165
165
  const IMPORT_RE =
166
166
  /\b(?:import|export)\b(?:(?!\b(?:import|export)\b)[^;])*?\bfrom\s*\0(\d+)\0|\bimport\s*\0(\d+)\0/g
167
167
 
168
+ // `import` as a keyword. Followed by `:` it is a property key (a task named
169
+ // `import`): no import form, static or dynamic, puts a colon after the word.
170
+ const IMPORT_WORD_RE = /\bimport\b(?!\s*:)/g
171
+
168
172
  /**
169
173
  * Every static import in `source` as its specifier and statement, or `null`
170
174
  * when the source cannot be lexed or holds an `import` no form above
@@ -181,9 +185,9 @@ function staticImports(
181
185
  let seen = 0
182
186
  for (const m of code.matchAll(IMPORT_RE)) {
183
187
  imports.push({ spec: strings[Number(m[1] ?? m[2])]!, statement: m[0] })
184
- seen += m[0].match(/\bimport\b/g)?.length ?? 0
188
+ seen += m[0].match(IMPORT_WORD_RE)?.length ?? 0
185
189
  }
186
- if (seen !== (code.match(/\bimport\b/g)?.length ?? 0)) return null
190
+ if (seen !== (code.match(IMPORT_WORD_RE)?.length ?? 0)) return null
187
191
  return { code, strings, imports }
188
192
  }
189
193
 
@@ -289,7 +289,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
289
289
  files.push({
290
290
  relPath: relPosix(root, abs),
291
291
  abs,
292
- contents: renderConfigFile(source, p, verb, format),
292
+ contents: renderConfigFile(p, format),
293
293
  })
294
294
  }
295
295
  for (const f of plan.extraFiles) {
@@ -302,7 +302,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
302
302
  const hasWorkspaceFile = (
303
303
  await Promise.all(WORKSPACE_CONFIG_FILENAMES.map((n) => Bun.file(path.join(root, n)).exists()))
304
304
  ).some(Boolean)
305
- if (!hasWorkspaceFile) {
305
+ // A plan may bring its own (vx-migrate declares the plugins it detects).
306
+ if (!hasWorkspaceFile && !plan.extraFiles.some((f) => f.relPath === workspaceName)) {
306
307
  const abs = path.join(root, workspaceName)
307
308
  files.push({ relPath: relPosix(root, abs), abs, contents: workspaceFile(format) })
308
309
  }
@@ -550,11 +551,8 @@ function exampleConfig(format: MigrationFormat): string {
550
551
  // `import { defineWorkspace } from '@vzn/vx'` loads a SECOND copy of core
551
552
  // into every run — measured 2026-09-09 at ~17 ms on a two-package
552
553
  // workspace, a fifth of the whole run — for an identity function.
553
- const WORKSPACE_BODY = `// Plugins are consulted in this order; running here and caching in
554
- // .vx/cache are the floor under all of them, so an empty list is a
555
- // complete workspace. Add a remote cache, a remote executor or telemetry
556
- // as one entry each — those imports are the runtime ones.
557
- export default { plugins: [] }`
554
+ // No explanatory comment: the file is the user's (owner, 2026-10-06).
555
+ const WORKSPACE_BODY = `export default { plugins: [] }`
558
556
 
559
557
  function workspaceFile(format: MigrationFormat): string {
560
558
  return format === 'ts'
@@ -586,22 +584,18 @@ function renderValue(v: unknown, indent: string): string {
586
584
  return `{\n${body.join('\n')}\n${indent}}`
587
585
  }
588
586
 
589
- function renderConfigFile(
590
- source: string,
591
- p: GeneratedProject,
592
- verb: string,
593
- format: MigrationFormat,
594
- ): string {
587
+ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string {
595
588
  // A type-only import: the editor type-checks against the installed
596
589
  // package, and Bun erases it, so the config loads in a workspace that
597
590
  // runs the vx binary without the package installed. `mjs` is the same
598
591
  // object with nothing to erase.
599
- const lines: string[] = [
600
- `// Generated by \`${verb}\` from ${source}.${p.tasks.some((t) => t.todos.length > 0) ? ' Review the TODO(vx-migrate) comments.' : ''}`,
601
- ]
592
+ // No "generated by" header: the file is the user's from here (owner,
593
+ // 2026-10-06), and each TODO(vx-migrate) sits on its own task.
594
+ const lines: string[] = []
602
595
  if (format === 'ts') lines.push("import type { ProjectConfig } from '@vzn/vx/config'")
603
596
  if (p.importLines.length > 0) lines.push(...p.importLines)
604
- lines.push('', 'export default {')
597
+ if (lines.length > 0) lines.push('')
598
+ lines.push('export default {')
605
599
  if (p.tags !== undefined && p.tags.length > 0) lines.push(` tags: ${renderValue(p.tags, ' ')},`)
606
600
  lines.push(' tasks: {')
607
601
  for (const t of p.tasks) {