From 869fbe718bb3e8594d534aac5cd778d369b9084f Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 14 Sep 2026 16:22:40 -0500 Subject: [PATCH 1/5] Share local cache across git worktrees without touching GitHub Actions caching. Local cache directory names now hash a package-relative fingerprint, linked worktrees write into the main tree's .wireit cache folders, and a concurrent set of an existing entry is a hit instead of a throw. --- CHANGELOG.md | 5 + README.md | 12 +++ package.json | 2 +- src/caching/local-cache.ts | 38 +++++-- src/cli.ts | 5 +- src/test/cache-root.test.ts | 107 ++++++++++++++++++ src/test/local-cache.test.ts | 57 ++++++++++ src/test/portable-fingerprint.test.ts | 73 +++++++++++++ src/util/cache-root.ts | 149 ++++++++++++++++++++++++++ src/util/portable-fingerprint.ts | 102 ++++++++++++++++++ 10 files changed, 538 insertions(+), 12 deletions(-) create mode 100644 src/test/cache-root.test.ts create mode 100644 src/test/portable-fingerprint.test.ts create mode 100644 src/util/cache-root.ts create mode 100644 src/util/portable-fingerprint.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dd9b356fb..21543946a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ Versioning](https://semver.org/spec/v2.0.0.html). version, shrinks by one entry per write, and Wireit reminds you once a day while it holds more than twice the limit. To free it at once, run `rm -rf .wireit/*/cache`. See [#71](https://github.com/google/wireit/issues/71). +- Linked Git worktrees share the main worktree's local cache. Set + `WIREIT_CACHE_DIR` to store local cache entries under a different root. + Local cache directory names ignore the absolute checkout path. GitHub Actions + caching is unchanged. A concurrent write of an existing local cache entry is + now treated as a hit instead of throwing. ### Fixed diff --git a/README.md b/README.md index 005b49f45..957635e2c 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,17 @@ Note the limit is applied per script, so a package with many cached scripts will still use a multiple of this space. To free all of it at once, use `rm -rf .wireit/*/cache .wireit/*/temp .wireit/trash`. +Linked Git worktrees (`git worktree add`) store local cache entries in the main +worktree's corresponding `.wireit` folders, so checkouts of the same files +reuse cache hits. Lock, fingerprint, and output-manifest files stay in the +current worktree. Set `WIREIT_CACHE_DIR` to use a different shared root, +still keyed by path relative to the current worktree. GitHub Actions caching is +not affected. + +Local cache keys ignore the absolute checkout path. Restored files are copied +verbatim; Wireit does not rewrite output contents that embed another checkout's +absolute path. + ### GitHub Actions caching In _[GitHub Actions](https://github.com/features/actions)_ mode, Wireit caches @@ -895,6 +906,7 @@ The following environment variables affect the behavior of Wireit: | `WIREIT_FAILURES` | [How to handle script failures](#failures-and-errors).

Options:
| | `WIREIT_LOGGER` | How to present progress and results on the command line.

Options:
| | `WIREIT_CACHE_MAX_ENTRIES` | Maximum number of [local cache](#local-caching) entries to retain per script.

Defaults to `10`.

Must be a positive integer or `infinity`. | +| `WIREIT_CACHE_DIR` | Root directory for [local cache](#local-caching) entries, keyed by each package's path relative to the Git worktree root. Unset: linked worktrees share the main worktree's `.wireit` cache folders; other checkouts keep a per-package `.wireit`. Does not affect GitHub Actions caching. | | `WIREIT_DEBUG_LOG_FILE` | Path to a file which will receive detailed event logging. | | `WIREIT_MAX_OPEN_FILES` | Limits the number of file descriptors Wireit will have open concurrently. Prevents resource exhaustion when checking large numbers of cached files. Set to a lower number if you hit file descriptor limits.

Defaults to `200`.

Must be a positive integer. | | `WIREIT_PARALLEL` | [Maximum number of scripts to run at one time](#parallelism).

Defaults to 2×logical CPU cores.

Must be a positive integer or `infinity`. | diff --git a/package.json b/package.json index 9f7935f16..ed6a1d306 100644 --- a/package.json +++ b/package.json @@ -183,7 +183,7 @@ "output": [] }, "test:local-cache": { - "command": "node --test --test-reporter=dot lib/test/local-cache.test.js", + "command": "node --test --test-reporter=dot lib/test/local-cache.test.js lib/test/portable-fingerprint.test.js lib/test/cache-root.test.js", "env": { "NODE_OPTIONS": "--enable-source-maps" }, diff --git a/src/caching/local-cache.ts b/src/caching/local-cache.ts index ef543be84..2d50fda54 100644 --- a/src/caching/local-cache.ts +++ b/src/caching/local-cache.ts @@ -6,10 +6,12 @@ import * as fs from '../util/fs.js'; import * as pathlib from 'path'; -import {createHash, randomBytes} from 'crypto'; -import {getPackageDataDir, getScriptDataDir} from '../util/script-data-dir.js'; +import {randomBytes} from 'crypto'; +import {getPackageDataDir} from '../util/script-data-dir.js'; import {copyEntries} from '../util/copy.js'; import {glob} from '../util/glob.js'; +import {resolveCachePackageDir} from '../util/cache-root.js'; +import {hashPortableFingerprint} from '../util/portable-fingerprint.js'; import type {Cache, CacheHit} from './cache.js'; import type {ScriptReference} from '../config.js'; @@ -66,8 +68,9 @@ const REMIND_OVER_LIMIT_EVERY_MS = 24 * 60 * 60 * 1000; */ export class LocalCache implements Cache { readonly #maxEntries: number; + readonly #cacheDir: string | undefined; - /** Packages used this run, whose trash {@link sweepTrash} empties. */ + /** Cache package dirs used this run, whose trash {@link sweepTrash} empties. */ readonly #packageDirs = new Set(); /** Messages for the user, which the next {@link sweepTrash} returns. */ @@ -79,9 +82,14 @@ export class LocalCache implements Cache { */ readonly #remindedPackages = new Set(); - /** @param maxEntries Entries to retain per script, or Infinity for all. */ - constructor(maxEntries: number) { + /** + * @param maxEntries Entries to retain per script, or Infinity for all. + * @param cacheDir Optional WIREIT_CACHE_DIR. Linked git worktrees still + * share via the main worktree when this is unset. + */ + constructor(maxEntries: number, cacheDir?: string) { this.#maxEntries = maxEntries; + this.#cacheDir = cacheDir === '' ? undefined : cacheDir; } async get( @@ -105,7 +113,7 @@ export class LocalCache implements Cache { script: ScriptReference, fingerprint: Fingerprint, ): Promise { - this.#packageDirs.add(script.packageDir); + this.#packageDirs.add(this.#cachePackageDir(script)); // Recency lives in the mtime, so there is no index file to maintain. atime // won't do, because filesystems are commonly mounted noatime or relatime. const now = new Date(); @@ -122,7 +130,7 @@ export class LocalCache implements Cache { fingerprint: Fingerprint, absoluteFiles: AbsoluteEntry[], ): Promise { - this.#packageDirs.add(script.packageDir); + this.#packageDirs.add(this.#cachePackageDir(script)); const absCacheDir = this.#getCacheDir(script, fingerprint); if (absoluteFiles.length === 0) { // No temp folder, because an empty "output" runs without the lock. @@ -243,7 +251,9 @@ export class LocalCache implements Cache { // allSettled, so one entry we can't move (EPERM on Windows, while // something holds it open) doesn't block evicting the rest. await Promise.allSettled( - doomed.map(({path}) => this.#moveToTrash(script.packageDir, path)), + doomed.map(({path}) => + this.#moveToTrash(this.#cachePackageDir(script), path), + ), ); const numLeft = entries.length - doomed.length; if (numLeft > REMIND_OVER_LIMIT_FACTOR * this.#maxEntries) { @@ -350,8 +360,16 @@ export class LocalCache implements Cache { return pathlib.join(getPackageDataDir(packageDir), 'trash'); } + #cachePackageDir(script: ScriptReference): string { + return resolveCachePackageDir(script.packageDir, this.#cacheDir); + } + #getScriptCacheDir(script: ScriptReference): string { - return pathlib.join(getScriptDataDir(script), 'cache'); + return pathlib.join( + getPackageDataDir(this.#cachePackageDir(script)), + Buffer.from(script.name).toString('hex'), + 'cache', + ); } #getScriptTempDir(script: ScriptReference): string { @@ -361,7 +379,7 @@ export class LocalCache implements Cache { #getCacheDir(script: ScriptReference, fingerprint: Fingerprint): string { return pathlib.join( this.#getScriptCacheDir(script), - createHash('sha256').update(fingerprint.string).digest('hex'), + hashPortableFingerprint(fingerprint, script.packageDir), ); } } diff --git a/src/cli.ts b/src/cli.ts index 58c133ef0..afa544daf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -62,7 +62,10 @@ const run = async (options: Options): Promise> => { case 'local': { // Import dynamically so that we import fewer unnecessary modules. const {LocalCache} = await import('./caching/local-cache.js'); - cache = new LocalCache(options.cacheMaxEntries); + cache = new LocalCache( + options.cacheMaxEntries, + process.env['WIREIT_CACHE_DIR'], + ); break; } case 'github': { diff --git a/src/test/cache-root.test.ts b/src/test/cache-root.test.ts new file mode 100644 index 000000000..0206141b4 --- /dev/null +++ b/src/test/cache-root.test.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {test} from 'node:test'; +import * as assert from 'node:assert'; +import * as fs from 'fs'; +import * as pathlib from 'path'; +import {execFileSync} from 'child_process'; +import {detectWorktree, resolveCachePackageDir} from '../util/cache-root.js'; +import {FilesystemTestRig} from './util/filesystem-test-rig.js'; + +const git = (cwd: string, args: string[]) => { + execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +}; + +const initRepo = (dir: string) => { + git(dir, ['init']); + git(dir, ['config', 'user.email', 'wireit@example.com']); + git(dir, ['config', 'user.name', 'Wireit Test']); + git(dir, ['config', 'commit.gpgsign', 'false']); +}; + +const withRig = async ( + fn: (rig: FilesystemTestRig) => Promise, +): Promise => { + const rig = new FilesystemTestRig(); + await rig.setup(); + try { + await fn(rig); + } finally { + await rig.cleanup(); + } +}; + +void test('main worktree uses its own package dir', async () => { + await withRig(async (rig) => { + initRepo(rig.temp); + await rig.write('README.md', 'x'); + git(rig.temp, ['add', '.']); + git(rig.temp, ['commit', '-m', 'init']); + const pkg = rig.resolve('packages/foo'); + await rig.mkdir('packages/foo'); + const info = detectWorktree(pkg); + assert.equal( + info && fs.realpathSync(info.worktreeRoot), + fs.realpathSync(rig.temp), + ); + assert.equal( + info && fs.realpathSync(info.mainWorktreeRoot), + fs.realpathSync(rig.temp), + ); + assert.equal(resolveCachePackageDir(pkg), pkg); + }); +}); + +void test('linked worktree cache package dir maps onto the main tree', async () => { + await withRig(async (rig) => { + const main = rig.resolve('main'); + const linked = rig.resolve('linked'); + await rig.mkdir('main'); + initRepo(main); + await rig.write(pathlib.join('main', 'README.md'), 'x'); + git(main, ['add', '.']); + git(main, ['commit', '-m', 'init']); + git(main, ['worktree', 'add', linked, '-b', 'other']); + const mainPkg = pathlib.join(main, 'packages', 'foo'); + const linkedPkg = pathlib.join(linked, 'packages', 'foo'); + await rig.mkdir(pathlib.join('main', 'packages', 'foo')); + await rig.mkdir(pathlib.join('linked', 'packages', 'foo')); + const info = detectWorktree(linkedPkg); + assert.equal( + info && fs.realpathSync(info.worktreeRoot), + fs.realpathSync(linked), + ); + assert.equal( + info && fs.realpathSync(info.mainWorktreeRoot), + fs.realpathSync(main), + ); + assert.equal( + fs.realpathSync(resolveCachePackageDir(linkedPkg)), + fs.realpathSync(mainPkg), + ); + }); +}); + +void test('WIREIT_CACHE_DIR keys packages relative to the worktree root', async () => { + await withRig(async (rig) => { + initRepo(rig.temp); + await rig.write('README.md', 'x'); + git(rig.temp, ['add', '.']); + git(rig.temp, ['commit', '-m', 'init']); + const pkg = rig.resolve('packages/foo'); + await rig.mkdir('packages/foo'); + const cacheDir = rig.resolve('shared-cache'); + assert.equal( + resolveCachePackageDir(pkg, cacheDir), + pathlib.join(cacheDir, 'packages', 'foo'), + ); + }); +}); diff --git a/src/test/local-cache.test.ts b/src/test/local-cache.test.ts index 1a7f0279b..e880d2ad9 100644 --- a/src/test/local-cache.test.ts +++ b/src/test/local-cache.test.ts @@ -9,6 +9,7 @@ import * as assert from 'node:assert'; import * as fs from 'fs/promises'; import * as pathlib from 'path'; import {createHash} from 'crypto'; +import {execFileSync} from 'child_process'; import {LocalCache} from '../caching/local-cache.js'; import {Fingerprint} from '../fingerprint.js'; import {getScriptDataDir} from '../util/script-data-dir.js'; @@ -485,3 +486,59 @@ void test('a write that fails leaves no entry, and deletes its temp copy', async await ctx.cacheOutput('v0'); assert.deepEqual(await ctx.entryHashes(), [hashOf('v0')]); }); + +const git = (cwd: string, args: string[]) => { + execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +}; + +void test('linked worktree restores from the main worktree local cache', async () => { + const rig = new FilesystemTestRig(); + await rig.setup(); + try { + const main = rig.resolve('main'); + const linked = rig.resolve('linked'); + await rig.mkdir('main'); + git(main, ['init']); + git(main, ['config', 'user.email', 'wireit@example.com']); + git(main, ['config', 'user.name', 'Wireit Test']); + git(main, ['config', 'commit.gpgsign', 'false']); + await rig.write(pathlib.join('main', 'README.md'), 'x'); + git(main, ['add', '.']); + git(main, ['commit', '-m', 'init']); + git(main, ['worktree', 'add', linked, '-b', 'other']); + + const mainScript = {packageDir: main, name: SCRIPT_NAME}; + const linkedScript = {packageDir: linked, name: SCRIPT_NAME}; + const cache = new LocalCache(10); + const outputEntry = { + path: pathlib.join(main, 'output'), + dirent: { + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false, + }, + } as AbsoluteEntry; + await rig.write(pathlib.join('main', 'output'), 'from-main'); + assert.equal( + await cache.set(mainScript, fingerprint('v0'), [outputEntry]), + true, + ); + + const linkedCacheDir = pathlib.join( + getScriptDataDir(linkedScript), + 'cache', + ); + await assert.rejects(fs.readdir(linkedCacheDir), {code: 'ENOENT'}); + + const hit = await cache.get(linkedScript, fingerprint('v0')); + assert.notEqual(hit, undefined); + await hit!.apply(); + assert.equal(await rig.read(pathlib.join('linked', 'output')), 'from-main'); + } finally { + await rig.cleanup(); + } +}); diff --git a/src/test/portable-fingerprint.test.ts b/src/test/portable-fingerprint.test.ts new file mode 100644 index 000000000..68eb72e99 --- /dev/null +++ b/src/test/portable-fingerprint.test.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {test} from 'node:test'; +import * as assert from 'node:assert'; +import * as pathlib from 'path'; +import {Fingerprint} from '../fingerprint.js'; +import {scriptReferenceToString} from '../config.js'; +import {portableFingerprintString} from '../util/portable-fingerprint.js'; + +import type {FingerprintString} from '../fingerprint.js'; + +const fingerprintJson = (packageDir: string) => + JSON.stringify({ + fullyTracked: true, + platform: 'darwin', + arch: 'arm64', + nodeVersion: 'v22.0.0', + command: 'tsc', + extraArgs: [], + clean: true, + files: { + [pathlib.join(packageDir, 'src', 'a.ts')]: 'hash-a', + [pathlib.join(packageDir, 'input.txt')]: 'hash-input', + }, + output: ['lib/**'], + dependencies: { + [scriptReferenceToString({ + packageDir: pathlib.join(packageDir, '..', 'dep'), + name: 'compile', + })]: 'dep-hash', + }, + service: undefined, + env: {}, + }); + +void test('portable fingerprint is identical for two absolute checkouts', () => { + const a = '/tmp/checkout-a/packages/foo'; + const b = '/Users/me/wt/packages/foo'; + const portableA = portableFingerprintString( + Fingerprint.fromString(fingerprintJson(a) as FingerprintString), + a, + ); + const portableB = portableFingerprintString( + Fingerprint.fromString(fingerprintJson(b) as FingerprintString), + b, + ); + assert.equal(portableA, portableB); + const parsed = JSON.parse(portableA) as { + files: Record; + dependencies: Record; + }; + assert.deepEqual( + Object.keys(parsed.files).sort(), + [pathlib.join('src', 'a.ts'), 'input.txt'].sort(), + ); + assert.deepEqual(Object.keys(parsed.dependencies), [ + JSON.stringify([pathlib.join('..', 'dep'), 'compile']), + ]); +}); + +void test('non-JSON fingerprints pass through for unit-test fakes', () => { + assert.equal( + portableFingerprintString( + Fingerprint.fromString('v0' as FingerprintString), + '/tmp/pkg', + ), + 'v0', + ); +}); diff --git a/src/util/cache-root.ts b/src/util/cache-root.ts new file mode 100644 index 000000000..b2dd9ba5e --- /dev/null +++ b/src/util/cache-root.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {createHash} from 'crypto'; +import * as fs from 'fs'; +import * as pathlib from 'path'; + +export type WorktreeInfo = { + worktreeRoot: string; + mainWorktreeRoot: string; +}; + +/** + * Directory whose `.wireit/` folder should hold this package's local cache + * (not lock/fingerprint/manifest). + * + * - `cacheDir` set (WIREIT_CACHE_DIR): `{cacheDir}/{path-from-worktree-root}`. + * Without git, isolated by a hash of the absolute package path (no sharing). + * - Linked git worktree: the same relative package under the main worktree. + * - Otherwise: `packageDir` (Wireit's historical layout). + */ +export const resolveCachePackageDir = ( + packageDir: string, + cacheDir?: string, +): string => { + const absPackageDir = pathlib.resolve(packageDir); + const worktree = detectWorktree(absPackageDir); + const relPackage = + worktree === undefined + ? undefined + : pathlib.relative(worktree.worktreeRoot, absPackageDir); + + if (cacheDir !== undefined && cacheDir !== '') { + const root = pathlib.resolve(cacheDir); + return relPackage === undefined + ? pathlib.join(root, hashPath(absPackageDir)) + : pathlib.join(root, relPackage); + } + + if ( + worktree !== undefined && + relPackage !== undefined && + worktree.worktreeRoot !== worktree.mainWorktreeRoot + ) { + return pathlib.join(worktree.mainWorktreeRoot, relPackage); + } + return absPackageDir; +}; + +export const detectWorktree = (startDir: string): WorktreeInfo | undefined => { + const worktreeRoot = findGitAncestor(pathlib.resolve(startDir)); + if (worktreeRoot === undefined) { + return undefined; + } + const dotGit = pathlib.join(worktreeRoot, '.git'); + const stat = lstatOrUndefined(dotGit); + if (stat === undefined) { + return undefined; + } + if (stat.isDirectory()) { + return {worktreeRoot, mainWorktreeRoot: worktreeRoot}; + } + if (!stat.isFile()) { + return undefined; + } + const gitDir = readGitdirPointer(dotGit, worktreeRoot); + if (gitDir === undefined) { + return undefined; + } + const mainWorktreeRoot = readMainWorktreeRoot(gitDir); + if (mainWorktreeRoot === undefined) { + return undefined; + } + return {worktreeRoot, mainWorktreeRoot}; +}; + +const findGitAncestor = (dir: string): string | undefined => { + const stat = lstatOrUndefined(pathlib.join(dir, '.git')); + if (stat !== undefined) { + return dir; + } + const parent = pathlib.dirname(dir); + return parent === dir ? undefined : findGitAncestor(parent); +}; + +const lstatOrUndefined = (path: string): fs.Stats | undefined => { + try { + return fs.lstatSync(path); + } catch { + return undefined; + } +}; + +const readFileOrUndefined = (path: string): string | undefined => { + try { + return fs.readFileSync(path, 'utf8'); + } catch { + return undefined; + } +}; + +const readGitdirPointer = ( + dotGitFile: string, + worktreeRoot: string, +): string | undefined => { + const content = readFileOrUndefined(dotGitFile); + if (content === undefined) { + return undefined; + } + const prefix = 'gitdir: '; + const line = content.split('\n')[0] ?? ''; + if (!line.startsWith(prefix)) { + return undefined; + } + const pointed = line.slice(prefix.length).trim(); + const resolved = pathlib.isAbsolute(pointed) + ? pointed + : pathlib.resolve(worktreeRoot, pointed); + return realpathOrSelf(resolved); +}; + +const readMainWorktreeRoot = (gitDir: string): string | undefined => { + const commondir = readFileOrUndefined( + pathlib.join(gitDir, 'commondir'), + )?.trim(); + if (commondir === undefined || commondir === '') { + return undefined; + } + const gitCommon = pathlib.isAbsolute(commondir) + ? commondir + : pathlib.resolve(gitDir, commondir); + const resolved = realpathOrSelf(gitCommon); + const main = pathlib.dirname(resolved); + return main === resolved ? undefined : main; +}; + +const realpathOrSelf = (path: string): string => { + try { + return fs.realpathSync(path); + } catch { + return path; + } +}; + +const hashPath = (path: string): string => + createHash('sha256').update(path).digest('hex').slice(0, 16); diff --git a/src/util/portable-fingerprint.ts b/src/util/portable-fingerprint.ts new file mode 100644 index 000000000..e26eb5132 --- /dev/null +++ b/src/util/portable-fingerprint.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {createHash} from 'crypto'; +import * as pathlib from 'path'; +import {stringToScriptReference} from '../config.js'; +import type {ScriptReferenceString} from '../config.js'; + +import type {Fingerprint} from '../fingerprint.js'; + +/** + * SHA-256 of {@link portableFingerprintString}. Used as the local cache + * directory name so two checkouts of the same tree share entries. + * + * GitHub Actions caching still uses {@link Fingerprint.string} and is + * unchanged. + */ +export const hashPortableFingerprint = ( + fingerprint: Fingerprint, + packageDir: string, +): string => + createHash('sha256') + .update(portableFingerprintString(fingerprint, packageDir)) + .digest('hex'); + +/** + * Rewrite fingerprint JSON so file and dependency keys are relative to + * {@link packageDir}. Non-JSON fingerprints (unit-test fakes) pass through. + */ +export const portableFingerprintString = ( + fingerprint: Fingerprint, + packageDir: string, +): string => { + const parsed: unknown = parseJson(fingerprint.string); + if (!isFingerprintLike(parsed)) { + return fingerprint.string; + } + const absPackageDir = pathlib.resolve(packageDir); + const files = Object.fromEntries( + Object.entries(parsed.files) + .map(([path, hash]) => [relativize(absPackageDir, path), hash] as const) + .sort(([a], [b]) => a.localeCompare(b)), + ); + const dependencies = Object.fromEntries( + Object.entries(parsed.dependencies) + .map( + ([key, hash]) => + [relativizeDependencyKey(absPackageDir, key), hash] as const, + ) + .sort(([a], [b]) => a.localeCompare(b)), + ); + return JSON.stringify({...parsed, files, dependencies}); +}; + +const parseJson = (value: string): unknown => { + try { + return JSON.parse(value) as unknown; + } catch { + return undefined; + } +}; + +const isFingerprintLike = ( + value: unknown, +): value is { + files: Record; + dependencies: Record; +} => + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + isStringRecord((value as {files?: unknown}).files) && + isStringRecord((value as {dependencies?: unknown}).dependencies); + +const isStringRecord = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + return Object.values(value).every((item) => typeof item === 'string'); +}; + +const relativize = (packageDir: string, path: string): string => { + if (!pathlib.isAbsolute(path)) { + return path; + } + const relative = pathlib.relative(packageDir, path); + return relative === '' ? '.' : relative; +}; + +const relativizeDependencyKey = (packageDir: string, key: string): string => { + try { + const {packageDir: depPackageDir, name} = stringToScriptReference( + key as ScriptReferenceString, + ); + return JSON.stringify([relativize(packageDir, depPackageDir), name]); + } catch { + return key; + } +}; From 8365190770902f663b97dcea61dbf8363a38678f Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 14 Sep 2026 16:25:16 -0500 Subject: [PATCH 2/5] Drop redundant empty-string normalization in LocalCache. resolveCachePackageDir already treats '' as unset so path.resolve does not use cwd. --- src/caching/local-cache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/caching/local-cache.ts b/src/caching/local-cache.ts index 2d50fda54..882cc050c 100644 --- a/src/caching/local-cache.ts +++ b/src/caching/local-cache.ts @@ -89,7 +89,7 @@ export class LocalCache implements Cache { */ constructor(maxEntries: number, cacheDir?: string) { this.#maxEntries = maxEntries; - this.#cacheDir = cacheDir === '' ? undefined : cacheDir; + this.#cacheDir = cacheDir; } async get( From 5603e6bfdc9eb3b33eeea9f478bd17e56b84ec7f Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 21 Sep 2026 15:27:57 -0500 Subject: [PATCH 3/5] Align worktree cache sharing with Wireit's existing conventions. Share a linked worktree's local cache only when WIREIT_CACHE_WORKTREES=true, and keep the filesystem, env parsing, and docs consistent with upstream. --- CHANGELOG.md | 10 +- README.md | 20 ++-- package.json | 2 +- src/caching/local-cache.ts | 99 ++++++++++++------ src/cli-options.ts | 26 +++++ src/cli.ts | 7 +- src/fingerprint.ts | 20 +++- src/test/cache-root.test.ts | 140 ++++++++++---------------- src/test/cli-options.test.ts | 22 ++++ src/test/errors-usage.test.ts | 26 +++++ src/test/fingerprint.test.ts | 96 ++++++++++++++++++ src/test/local-cache.test.ts | 87 +++++++--------- src/test/portable-fingerprint.test.ts | 73 -------------- src/test/util/filesystem-test-rig.ts | 10 ++ src/test/util/git.ts | 22 ++++ src/test/util/test-rig.ts | 4 +- src/util/cache-root.ts | 121 +++++++++++----------- src/util/fs.ts | 9 ++ src/util/portable-fingerprint.ts | 102 ------------------- 19 files changed, 465 insertions(+), 431 deletions(-) create mode 100644 src/test/fingerprint.test.ts delete mode 100644 src/test/portable-fingerprint.test.ts create mode 100644 src/test/util/git.ts delete mode 100644 src/util/portable-fingerprint.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 21543946a..eabca20d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,11 @@ Versioning](https://semver.org/spec/v2.0.0.html). version, shrinks by one entry per write, and Wireit reminds you once a day while it holds more than twice the limit. To free it at once, run `rm -rf .wireit/*/cache`. See [#71](https://github.com/google/wireit/issues/71). -- Linked Git worktrees share the main worktree's local cache. Set - `WIREIT_CACHE_DIR` to store local cache entries under a different root. - Local cache directory names ignore the absolute checkout path. GitHub Actions - caching is unchanged. A concurrent write of an existing local cache entry is - now treated as a hit instead of throwing. +- Fingerprint file and dependency paths are relative to the package, so local + and GitHub Actions cache keys match across checkouts. Set + `WIREIT_CACHE_WORKTREES=true` to store a linked Git worktree's local cache in + the main worktree's corresponding `.wireit` folders. A concurrent write of an + existing local cache entry is now treated as a hit instead of throwing. ### Fixed diff --git a/README.md b/README.md index 957635e2c..612ec165e 100644 --- a/README.md +++ b/README.md @@ -372,16 +372,14 @@ Note the limit is applied per script, so a package with many cached scripts will still use a multiple of this space. To free all of it at once, use `rm -rf .wireit/*/cache .wireit/*/temp .wireit/trash`. -Linked Git worktrees (`git worktree add`) store local cache entries in the main -worktree's corresponding `.wireit` folders, so checkouts of the same files -reuse cache hits. Lock, fingerprint, and output-manifest files stay in the -current worktree. Set `WIREIT_CACHE_DIR` to use a different shared root, -still keyed by path relative to the current worktree. GitHub Actions caching is -not affected. +Linked Git worktrees (`git worktree add`) keep their own `.wireit` cache +folders. Set `WIREIT_CACHE_WORKTREES=true` to store a linked worktree's local +cache in the main worktree's corresponding `.wireit` folders, so checkouts of +the same files reuse cache hits. Lock, fingerprint, and output-manifest files +stay in the current worktree. GitHub Actions caching is not affected. -Local cache keys ignore the absolute checkout path. Restored files are copied -verbatim; Wireit does not rewrite output contents that embed another checkout's -absolute path. +Restored files are copied verbatim. Wireit does not rewrite output contents +that embed another checkout's absolute path. ### GitHub Actions caching @@ -906,7 +904,7 @@ The following environment variables affect the behavior of Wireit: | `WIREIT_FAILURES` | [How to handle script failures](#failures-and-errors).

Options:
  • [`no-new`](#failures-and-errors) (default): Allow running scripts to finish, but don't start new ones.
  • [`continue`](#continue): Allow running scripts to continue, and start new ones unless any of their dependencies failed.
  • [`kill`](#kill): Immediately kill running scripts, and don't start new ones.
| | `WIREIT_LOGGER` | How to present progress and results on the command line.

Options:
  • `quiet` (default for normal execution): Writes a single dynamically updating line summarizing progress. Only passes along stdout and stderr from commands if there's a failure, or if the command is a service.
  • `quiet-ci` (default when `env.CI` or `!stdout.isTTY`): like `quiet` but optimized for non-interactive environments, like GitHub Actions runners.
  • `simple`: A verbose logger that presents clear information about the work that Wireit is doing.
  • `metrics`: Like `simple`, but also presents a summary table of results once a command is finished.
| | `WIREIT_CACHE_MAX_ENTRIES` | Maximum number of [local cache](#local-caching) entries to retain per script.

Defaults to `10`.

Must be a positive integer or `infinity`. | -| `WIREIT_CACHE_DIR` | Root directory for [local cache](#local-caching) entries, keyed by each package's path relative to the Git worktree root. Unset: linked worktrees share the main worktree's `.wireit` cache folders; other checkouts keep a per-package `.wireit`. Does not affect GitHub Actions caching. | +| `WIREIT_CACHE_WORKTREES` | Set to `true` to store a linked Git worktree's [local cache](#local-caching) in the main worktree's corresponding `.wireit` folders. Unset keeps each worktree's own cache.

Must be exactly `true`. Does not affect GitHub Actions caching. | | `WIREIT_DEBUG_LOG_FILE` | Path to a file which will receive detailed event logging. | | `WIREIT_MAX_OPEN_FILES` | Limits the number of file descriptors Wireit will have open concurrently. Prevents resource exhaustion when checking large numbers of cached files. Set to a lower number if you hit file descriptor limits.

Defaults to `200`.

Must be a positive integer. | | `WIREIT_PARALLEL` | [Maximum number of scripts to run at one time](#parallelism).

Defaults to 2×logical CPU cores.

Must be a positive integer or `infinity`. | @@ -956,6 +954,8 @@ cache](#caching). - The `clean` setting. - The `output` glob patterns. - The SHA256 content hashes of all files matching `files`. +- File paths and dependency package paths are relative to the current package, + so two checkouts of the same files produce the same fingerprint. - The SHA256 content hashes of all files matching `packageLocks` in the current package and all parent directories. - The environment variable values configured in `env`. diff --git a/package.json b/package.json index ed6a1d306..9f5113d97 100644 --- a/package.json +++ b/package.json @@ -183,7 +183,7 @@ "output": [] }, "test:local-cache": { - "command": "node --test --test-reporter=dot lib/test/local-cache.test.js lib/test/portable-fingerprint.test.js lib/test/cache-root.test.js", + "command": "node --test --test-reporter=dot lib/test/local-cache.test.js lib/test/fingerprint.test.js lib/test/cache-root.test.js", "env": { "NODE_OPTIONS": "--enable-source-maps" }, diff --git a/src/caching/local-cache.ts b/src/caching/local-cache.ts index 882cc050c..4f0133987 100644 --- a/src/caching/local-cache.ts +++ b/src/caching/local-cache.ts @@ -7,11 +7,10 @@ import * as fs from '../util/fs.js'; import * as pathlib from 'path'; import {randomBytes} from 'crypto'; -import {getPackageDataDir} from '../util/script-data-dir.js'; +import {getPackageDataDir, getScriptDataDir} from '../util/script-data-dir.js'; import {copyEntries} from '../util/copy.js'; import {glob} from '../util/glob.js'; import {resolveCachePackageDir} from '../util/cache-root.js'; -import {hashPortableFingerprint} from '../util/portable-fingerprint.js'; import type {Cache, CacheHit} from './cache.js'; import type {ScriptReference} from '../config.js'; @@ -68,7 +67,7 @@ const REMIND_OVER_LIMIT_EVERY_MS = 24 * 60 * 60 * 1000; */ export class LocalCache implements Cache { readonly #maxEntries: number; - readonly #cacheDir: string | undefined; + readonly #shareWorktrees: boolean; /** Cache package dirs used this run, whose trash {@link sweepTrash} empties. */ readonly #packageDirs = new Set(); @@ -84,19 +83,23 @@ export class LocalCache implements Cache { /** * @param maxEntries Entries to retain per script, or Infinity for all. - * @param cacheDir Optional WIREIT_CACHE_DIR. Linked git worktrees still - * share via the main worktree when this is unset. + * @param options When `shareWorktrees` is true, linked git worktrees write + * cache into the main worktree's matching `.wireit`. */ - constructor(maxEntries: number, cacheDir?: string) { + constructor(maxEntries: number, options?: {shareWorktrees?: boolean}) { this.#maxEntries = maxEntries; - this.#cacheDir = cacheDir; + this.#shareWorktrees = options?.shareWorktrees === true; } async get( script: ScriptReference, fingerprint: Fingerprint, ): Promise { - const cacheDir = this.#getCacheDir(script, fingerprint); + const cacheDir = this.#getCacheDir( + await this.#cachePackageDir(script), + script, + fingerprint, + ); try { await fs.access(cacheDir); } catch (error) { @@ -113,12 +116,17 @@ export class LocalCache implements Cache { script: ScriptReference, fingerprint: Fingerprint, ): Promise { - this.#packageDirs.add(this.#cachePackageDir(script)); + const cachePackageDir = await this.#cachePackageDir(script); + this.#packageDirs.add(cachePackageDir); // Recency lives in the mtime, so there is no index file to maintain. atime // won't do, because filesystems are commonly mounted noatime or relatime. const now = new Date(); try { - await fs.utimes(this.#getCacheDir(script, fingerprint), now, now); + await fs.utimes( + this.#getCacheDir(cachePackageDir, script, fingerprint), + now, + now, + ); } catch { // No entry, or one we can't stamp (read-only mount, foreign owner). A hit // is still a hit; the entry just ages as though only ever written. @@ -130,8 +138,9 @@ export class LocalCache implements Cache { fingerprint: Fingerprint, absoluteFiles: AbsoluteEntry[], ): Promise { - this.#packageDirs.add(this.#cachePackageDir(script)); - const absCacheDir = this.#getCacheDir(script, fingerprint); + const cachePackageDir = await this.#cachePackageDir(script); + this.#packageDirs.add(cachePackageDir); + const absCacheDir = this.#getCacheDir(cachePackageDir, script, fingerprint); if (absoluteFiles.length === 0) { // No temp folder, because an empty "output" runs without the lock. // @@ -147,25 +156,39 @@ export class LocalCache implements Cache { await this.#evictLeastRecentlyUsed(script, pathlib.basename(absCacheDir)); return true; } - await this.#writeThroughTemp(script, absoluteFiles, absCacheDir); + await this.#writeThroughTemp( + cachePackageDir, + script, + absoluteFiles, + absCacheDir, + ); await Promise.all([ this.#evictLeastRecentlyUsed(script, pathlib.basename(absCacheDir)), - this.#trashLeftoverTemp(script), + // The script lock covers this temp folder only when the cache stays in + // this worktree. A shared cache can have another worktree's write in it. + ...(this.#shareWorktrees + ? [] + : [this.#trashLeftoverTemp(cachePackageDir, script)]), ]); return true; } async #writeThroughTemp( + cachePackageDir: string, script: ScriptReference, absoluteFiles: AbsoluteEntry[], absCacheDir: string, ): Promise { // Short, so a path that fits the Windows limit in the cache fits here. + // Next to the cache entry, so the rename stays on one filesystem when a + // linked worktree's cache lives in the main worktree. const tempDir = pathlib.join( - this.#getScriptTempDir(script), + this.#getScriptTempDir(cachePackageDir, script), randomBytes(8).toString('hex'), ); - await fs.mkdir(this.#getScriptCacheDir(script), {recursive: true}); + await fs.mkdir(this.#getScriptCacheDir(cachePackageDir, script), { + recursive: true, + }); try { await copyEntries(absoluteFiles, script.packageDir, tempDir); await fs.rename(tempDir, absCacheDir); @@ -178,8 +201,11 @@ export class LocalCache implements Cache { } /** Needs the script's lock, and must run after this write's rename. */ - async #trashLeftoverTemp(script: ScriptReference): Promise { - const tempDir = this.#getScriptTempDir(script); + async #trashLeftoverTemp( + cachePackageDir: string, + script: ScriptReference, + ): Promise { + const tempDir = this.#getScriptTempDir(cachePackageDir, script); let leftovers; try { leftovers = await fs.readdir(tempDir, {withFileTypes: true}); @@ -188,7 +214,7 @@ export class LocalCache implements Cache { } await Promise.allSettled( leftovers.map((entry) => - this.#moveToTrash(script.packageDir, pathlib.join(tempDir, entry.name)), + this.#moveToTrash(cachePackageDir, pathlib.join(tempDir, entry.name)), ), ); } @@ -227,7 +253,8 @@ export class LocalCache implements Cache { return; } try { - const cacheDir = this.#getScriptCacheDir(script); + const cachePackageDir = await this.#cachePackageDir(script); + const cacheDir = this.#getScriptCacheDir(cachePackageDir, script); const entries = await fs.readdir(cacheDir, {withFileTypes: true}); if (entries.length <= this.#maxEntries) { return; @@ -251,9 +278,7 @@ export class LocalCache implements Cache { // allSettled, so one entry we can't move (EPERM on Windows, while // something holds it open) doesn't block evicting the rest. await Promise.allSettled( - doomed.map(({path}) => - this.#moveToTrash(this.#cachePackageDir(script), path), - ), + doomed.map(({path}) => this.#moveToTrash(cachePackageDir, path)), ); const numLeft = entries.length - doomed.length; if (numLeft > REMIND_OVER_LIMIT_FACTOR * this.#maxEntries) { @@ -360,26 +385,34 @@ export class LocalCache implements Cache { return pathlib.join(getPackageDataDir(packageDir), 'trash'); } - #cachePackageDir(script: ScriptReference): string { - return resolveCachePackageDir(script.packageDir, this.#cacheDir); + async #cachePackageDir(script: ScriptReference): Promise { + return resolveCachePackageDir(script.packageDir, { + shareWorktrees: this.#shareWorktrees, + }); } - #getScriptCacheDir(script: ScriptReference): string { + #getScriptCacheDir(cachePackageDir: string, script: ScriptReference): string { return pathlib.join( - getPackageDataDir(this.#cachePackageDir(script)), - Buffer.from(script.name).toString('hex'), + getScriptDataDir({packageDir: cachePackageDir, name: script.name}), 'cache', ); } - #getScriptTempDir(script: ScriptReference): string { - return pathlib.join(getScriptDataDir(script), 'temp'); + #getScriptTempDir(cachePackageDir: string, script: ScriptReference): string { + return pathlib.join( + getScriptDataDir({packageDir: cachePackageDir, name: script.name}), + 'temp', + ); } - #getCacheDir(script: ScriptReference, fingerprint: Fingerprint): string { + #getCacheDir( + cachePackageDir: string, + script: ScriptReference, + fingerprint: Fingerprint, + ): string { return pathlib.join( - this.#getScriptCacheDir(script), - hashPortableFingerprint(fingerprint, script.packageDir), + this.#getScriptCacheDir(cachePackageDir, script), + fingerprint.hash, ); } } diff --git a/src/cli-options.ts b/src/cli-options.ts index 282ee3efb..9e13950a6 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -64,6 +64,7 @@ export interface Options { numWorkers: number; cache: 'local' | 'github' | 'none'; cacheMaxEntries: number; + cacheWorktrees: boolean; failureMode: FailureMode; agent: Agent; logger: Logger; @@ -220,6 +221,30 @@ export const getOptions = async (): Promise> => { } } + const cacheWorktreesResult = ((): Result => { + const str = process.env['WIREIT_CACHE_WORKTREES']; + if (str === undefined) { + return {ok: true, value: false}; + } + if (str === 'true') { + return {ok: true, value: true}; + } + return { + ok: false, + error: { + reason: 'invalid-usage', + message: + `Expected the WIREIT_CACHE_WORKTREES env variable to be ` + + `"true", got ${JSON.stringify(str)}`, + script, + type: 'failure', + }, + }; + })(); + if (!cacheWorktreesResult.ok) { + return cacheWorktreesResult; + } + const failureModeResult = ((): Result => { const str = process.env['WIREIT_FAILURES']; if (!str) { @@ -307,6 +332,7 @@ export const getOptions = async (): Promise> => { numWorkers: numWorkersResult.value, cache: cacheResult.value, cacheMaxEntries: cacheMaxEntriesResult.value, + cacheWorktrees: cacheWorktreesResult.value, failureMode: failureModeResult.value, agent, logger, diff --git a/src/cli.ts b/src/cli.ts index afa544daf..dbb2860ab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -62,10 +62,9 @@ const run = async (options: Options): Promise> => { case 'local': { // Import dynamically so that we import fewer unnecessary modules. const {LocalCache} = await import('./caching/local-cache.js'); - cache = new LocalCache( - options.cacheMaxEntries, - process.env['WIREIT_CACHE_DIR'], - ); + cache = new LocalCache(options.cacheMaxEntries, { + shareWorktrees: options.cacheWorktrees, + }); break; } case 'github': { diff --git a/src/fingerprint.ts b/src/fingerprint.ts index b18ca0e2e..d1ceea61c 100644 --- a/src/fingerprint.ts +++ b/src/fingerprint.ts @@ -5,6 +5,7 @@ */ import {createHash} from 'crypto'; +import * as pathlib from 'path'; import {createReadStream} from './util/fs.js'; import {glob} from './util/glob.js'; import {scriptReferenceToString} from './config.js'; @@ -148,7 +149,13 @@ export class Fingerprint { allDependenciesAreFullyTracked = false; } filteredDependencyFingerprints.push([ - scriptReferenceToString(dep.config), + scriptReferenceToString({ + packageDir: pathRelativeToPackage( + script.packageDir, + dep.config.packageDir, + ), + name: dep.config.name, + }), depFingerprint.hash, ]); } @@ -193,7 +200,10 @@ export class Fingerprint { } erroredFilePaths.push(absolutePath); } - return [file.path, hash.digest('hex') as FileSha256HexDigest]; + return [ + pathRelativeToPackage(script.packageDir, absolutePath), + hash.digest('hex') as FileSha256HexDigest, + ]; }), ); @@ -292,3 +302,9 @@ export class Fingerprint { return this.string === other.string; } } + +/** Package-relative so fingerprints match across checkouts. */ +const pathRelativeToPackage = (packageDir: string, path: string): string => { + const relative = pathlib.relative(packageDir, path); + return relative === '' ? '.' : relative; +}; diff --git a/src/test/cache-root.test.ts b/src/test/cache-root.test.ts index 0206141b4..a8d1e99b8 100644 --- a/src/test/cache-root.test.ts +++ b/src/test/cache-root.test.ts @@ -6,102 +6,64 @@ import {test} from 'node:test'; import * as assert from 'node:assert'; -import * as fs from 'fs'; import * as pathlib from 'path'; -import {execFileSync} from 'child_process'; +import {realpath} from '../util/fs.js'; import {detectWorktree, resolveCachePackageDir} from '../util/cache-root.js'; import {FilesystemTestRig} from './util/filesystem-test-rig.js'; - -const git = (cwd: string, args: string[]) => { - execFileSync('git', args, { - cwd, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); -}; - -const initRepo = (dir: string) => { - git(dir, ['init']); - git(dir, ['config', 'user.email', 'wireit@example.com']); - git(dir, ['config', 'user.name', 'Wireit Test']); - git(dir, ['config', 'commit.gpgsign', 'false']); -}; - -const withRig = async ( - fn: (rig: FilesystemTestRig) => Promise, -): Promise => { - const rig = new FilesystemTestRig(); - await rig.setup(); - try { - await fn(rig); - } finally { - await rig.cleanup(); - } -}; +import {git, initRepo} from './util/git.js'; void test('main worktree uses its own package dir', async () => { - await withRig(async (rig) => { - initRepo(rig.temp); - await rig.write('README.md', 'x'); - git(rig.temp, ['add', '.']); - git(rig.temp, ['commit', '-m', 'init']); - const pkg = rig.resolve('packages/foo'); - await rig.mkdir('packages/foo'); - const info = detectWorktree(pkg); - assert.equal( - info && fs.realpathSync(info.worktreeRoot), - fs.realpathSync(rig.temp), - ); - assert.equal( - info && fs.realpathSync(info.mainWorktreeRoot), - fs.realpathSync(rig.temp), - ); - assert.equal(resolveCachePackageDir(pkg), pkg); - }); + await using rig = await FilesystemTestRig.setup(); + initRepo(rig.temp); + await rig.write('README.md', 'x'); + git(rig.temp, ['add', '.']); + git(rig.temp, ['commit', '-m', 'init']); + const pkg = rig.resolve('packages/foo'); + await rig.mkdir('packages/foo'); + const info = await detectWorktree(pkg); + assert.ok(info); + assert.equal(await realpath(info.worktreeRoot), await realpath(rig.temp)); + assert.equal(await realpath(info.mainWorktreeRoot), await realpath(rig.temp)); + assert.equal(await resolveCachePackageDir(pkg, {shareWorktrees: true}), pkg); }); -void test('linked worktree cache package dir maps onto the main tree', async () => { - await withRig(async (rig) => { - const main = rig.resolve('main'); - const linked = rig.resolve('linked'); - await rig.mkdir('main'); - initRepo(main); - await rig.write(pathlib.join('main', 'README.md'), 'x'); - git(main, ['add', '.']); - git(main, ['commit', '-m', 'init']); - git(main, ['worktree', 'add', linked, '-b', 'other']); - const mainPkg = pathlib.join(main, 'packages', 'foo'); - const linkedPkg = pathlib.join(linked, 'packages', 'foo'); - await rig.mkdir(pathlib.join('main', 'packages', 'foo')); - await rig.mkdir(pathlib.join('linked', 'packages', 'foo')); - const info = detectWorktree(linkedPkg); - assert.equal( - info && fs.realpathSync(info.worktreeRoot), - fs.realpathSync(linked), - ); - assert.equal( - info && fs.realpathSync(info.mainWorktreeRoot), - fs.realpathSync(main), - ); - assert.equal( - fs.realpathSync(resolveCachePackageDir(linkedPkg)), - fs.realpathSync(mainPkg), - ); - }); +void test('linked worktree keeps its own cache package dir unless sharing', async () => { + await using rig = await FilesystemTestRig.setup(); + const main = rig.resolve('main'); + const linked = rig.resolve('linked'); + await rig.mkdir('main'); + initRepo(main); + await rig.write(pathlib.join('main', 'README.md'), 'x'); + git(main, ['add', '.']); + git(main, ['commit', '-m', 'init']); + git(main, ['worktree', 'add', linked, '-b', 'other']); + const linkedPkg = pathlib.join(linked, 'packages', 'foo'); + await rig.mkdir(pathlib.join('linked', 'packages', 'foo')); + assert.equal(await resolveCachePackageDir(linkedPkg), linkedPkg); }); -void test('WIREIT_CACHE_DIR keys packages relative to the worktree root', async () => { - await withRig(async (rig) => { - initRepo(rig.temp); - await rig.write('README.md', 'x'); - git(rig.temp, ['add', '.']); - git(rig.temp, ['commit', '-m', 'init']); - const pkg = rig.resolve('packages/foo'); - await rig.mkdir('packages/foo'); - const cacheDir = rig.resolve('shared-cache'); - assert.equal( - resolveCachePackageDir(pkg, cacheDir), - pathlib.join(cacheDir, 'packages', 'foo'), - ); - }); +void test('linked worktree cache package dir maps onto the main tree when sharing', async () => { + await using rig = await FilesystemTestRig.setup(); + const main = rig.resolve('main'); + const linked = rig.resolve('linked'); + await rig.mkdir('main'); + initRepo(main); + await rig.write(pathlib.join('main', 'README.md'), 'x'); + git(main, ['add', '.']); + git(main, ['commit', '-m', 'init']); + git(main, ['worktree', 'add', linked, '-b', 'other']); + const mainPkg = pathlib.join(main, 'packages', 'foo'); + const linkedPkg = pathlib.join(linked, 'packages', 'foo'); + await rig.mkdir(pathlib.join('main', 'packages', 'foo')); + await rig.mkdir(pathlib.join('linked', 'packages', 'foo')); + const info = await detectWorktree(linkedPkg); + assert.ok(info); + assert.equal(await realpath(info.worktreeRoot), await realpath(linked)); + assert.equal(await realpath(info.mainWorktreeRoot), await realpath(main)); + assert.equal( + await realpath( + await resolveCachePackageDir(linkedPkg, {shareWorktrees: true}), + ), + await realpath(mainPkg), + ); }); diff --git a/src/test/cli-options.test.ts b/src/test/cli-options.test.ts index ea81c9b94..676e31f88 100644 --- a/src/test/cli-options.test.ts +++ b/src/test/cli-options.test.ts @@ -60,6 +60,7 @@ async function assertOptions( watch: false, cache: 'local', cacheMaxEntries: 10, + cacheWorktrees: false, numWorkers: 10, failureMode: 'no-new', logger: 'QuietLogger', @@ -607,4 +608,25 @@ for (const {agent, runCmd, testCmd, startCmd, needsExtraDashes} of commands) { }), ); } + + void test( + `${agent} WIREIT_CACHE_WORKTREES=true`, + rigTest(async ({rig}) => { + await assertOptions( + rig, + `${runCmd} main ${extraDashes}`, + { + agent, + script: { + packageDir: rig.temp, + name: 'main', + }, + cacheWorktrees: true, + }, + { + WIREIT_CACHE_WORKTREES: 'true', + }, + ); + }), + ); } diff --git a/src/test/errors-usage.test.ts b/src/test/errors-usage.test.ts index 4e1b811f3..c99dbfde3 100644 --- a/src/test/errors-usage.test.ts +++ b/src/test/errors-usage.test.ts @@ -251,6 +251,32 @@ void test( }), ); +void test( + 'nonsense WIREIT_CACHE_WORKTREES', + rigTest(async ({rig}) => { + await rig.write({ + 'package.json': { + scripts: { + main: 'wireit', + }, + wireit: { + main: {command: (await rig.newCommand()).command}, + }, + }, + }); + const result = rig.exec('npm run main', { + env: {WIREIT_CACHE_WORKTREES: 'yes'}, + }); + const done = await result.exit; + assert.equal(done.code, 1); + assert.ok( + done.stderr.includes( + `❌ [main] Invalid usage: Expected the WIREIT_CACHE_WORKTREES env variable to be "true", got "yes"`, + ), + ); + }), +); + void test( 'nonsense WIREIT_FAILURES', rigTest(async ({rig}) => { diff --git a/src/test/fingerprint.test.ts b/src/test/fingerprint.test.ts new file mode 100644 index 000000000..749a1efe9 --- /dev/null +++ b/src/test/fingerprint.test.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {test} from 'node:test'; +import * as assert from 'node:assert'; +import * as pathlib from 'path'; +import {Fingerprint} from '../fingerprint.js'; +import {scriptReferenceToString} from '../config.js'; +import {FilesystemTestRig} from './util/filesystem-test-rig.js'; + +import type {ArrayNode, JsonAstNode} from '../util/ast.js'; +import type {Dependency, StandardScriptConfig} from '../config.js'; +import type {FingerprintString} from '../fingerprint.js'; + +const stringNode = (value: string): JsonAstNode => ({ + type: 'string', + offset: 0, + length: value.length, + value, +}); + +const arrayNode = (values: string[]): ArrayNode => ({ + node: {type: 'array', offset: 0, length: 0, value: undefined}, + values, +}); + +const scriptAt = (packageDir: string): StandardScriptConfig => ({ + packageDir, + name: 'compile', + state: 'valid', + command: stringNode('tsc'), + extraArgs: [], + clean: true, + files: arrayNode(['src/a.ts', 'input.txt']), + output: arrayNode(['lib/**']), + service: undefined, + env: {}, + dependencies: [], + services: [], + scriptAstNode: undefined, + configAstNode: undefined, + declaringFile: { + path: pathlib.join(packageDir, 'package.json'), + contents: '{}', + }, + failures: [], +}); + +const depAt = ( + packageDir: string, + fingerprint: Fingerprint, +): [Dependency, Fingerprint] => [ + { + cascade: true, + config: scriptAt(packageDir), + specifier: stringNode('compile'), + }, + fingerprint, +]; + +void test('fingerprint is identical for two absolute checkouts', async () => { + await using rig = await FilesystemTestRig.setup(); + await rig.write({ + 'a/packages/foo/src/a.ts': 'export const a = 1;', + 'a/packages/foo/input.txt': 'v0', + 'b/packages/foo/src/a.ts': 'export const a = 1;', + 'b/packages/foo/input.txt': 'v0', + }); + const depFingerprint = Fingerprint.fromString( + '{"fullyTracked":true}' as FingerprintString, + ); + const fooA = rig.resolve('a/packages/foo'); + const fooB = rig.resolve('b/packages/foo'); + const resultA = await Fingerprint.compute(scriptAt(fooA), [ + depAt(rig.resolve('a/packages/dep'), depFingerprint), + ]); + const resultB = await Fingerprint.compute(scriptAt(fooB), [ + depAt(rig.resolve('b/packages/dep'), depFingerprint), + ]); + assert.ok(resultA.ok); + assert.ok(resultB.ok); + assert.equal(resultA.value.string, resultB.value.string); + assert.deepEqual( + Object.keys(resultA.value.data.files).sort(), + [pathlib.join('src', 'a.ts'), 'input.txt'].sort(), + ); + assert.deepEqual(Object.keys(resultA.value.data.dependencies), [ + scriptReferenceToString({ + packageDir: pathlib.join('..', 'dep'), + name: 'compile', + }), + ]); +}); diff --git a/src/test/local-cache.test.ts b/src/test/local-cache.test.ts index e880d2ad9..356b55440 100644 --- a/src/test/local-cache.test.ts +++ b/src/test/local-cache.test.ts @@ -9,12 +9,12 @@ import * as assert from 'node:assert'; import * as fs from 'fs/promises'; import * as pathlib from 'path'; import {createHash} from 'crypto'; -import {execFileSync} from 'child_process'; import {LocalCache} from '../caching/local-cache.js'; import {Fingerprint} from '../fingerprint.js'; import {getScriptDataDir} from '../util/script-data-dir.js'; import {FilesystemTestRig} from './util/filesystem-test-rig.js'; import {FsGate} from './util/fs-gate.js'; +import {git, initRepo} from './util/git.js'; import type {AbsoluteEntry} from '../util/glob.js'; import type {FingerprintString} from '../fingerprint.js'; @@ -487,58 +487,39 @@ void test('a write that fails leaves no entry, and deletes its temp copy', async assert.deepEqual(await ctx.entryHashes(), [hashOf('v0')]); }); -const git = (cwd: string, args: string[]) => { - execFileSync('git', args, { - cwd, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); -}; - void test('linked worktree restores from the main worktree local cache', async () => { - const rig = new FilesystemTestRig(); - await rig.setup(); - try { - const main = rig.resolve('main'); - const linked = rig.resolve('linked'); - await rig.mkdir('main'); - git(main, ['init']); - git(main, ['config', 'user.email', 'wireit@example.com']); - git(main, ['config', 'user.name', 'Wireit Test']); - git(main, ['config', 'commit.gpgsign', 'false']); - await rig.write(pathlib.join('main', 'README.md'), 'x'); - git(main, ['add', '.']); - git(main, ['commit', '-m', 'init']); - git(main, ['worktree', 'add', linked, '-b', 'other']); - - const mainScript = {packageDir: main, name: SCRIPT_NAME}; - const linkedScript = {packageDir: linked, name: SCRIPT_NAME}; - const cache = new LocalCache(10); - const outputEntry = { - path: pathlib.join(main, 'output'), - dirent: { - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false, - }, - } as AbsoluteEntry; - await rig.write(pathlib.join('main', 'output'), 'from-main'); - assert.equal( - await cache.set(mainScript, fingerprint('v0'), [outputEntry]), - true, - ); + await using rig = await FilesystemTestRig.setup(); + const main = rig.resolve('main'); + const linked = rig.resolve('linked'); + await rig.mkdir('main'); + initRepo(main); + await rig.write(pathlib.join('main', 'README.md'), 'x'); + git(main, ['add', '.']); + git(main, ['commit', '-m', 'init']); + git(main, ['worktree', 'add', linked, '-b', 'other']); + + const mainScript = {packageDir: main, name: SCRIPT_NAME}; + const linkedScript = {packageDir: linked, name: SCRIPT_NAME}; + const cache = new LocalCache(10, {shareWorktrees: true}); + const outputEntry = { + path: pathlib.join(main, 'output'), + dirent: { + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false, + }, + } as AbsoluteEntry; + await rig.write(pathlib.join('main', 'output'), 'from-main'); + assert.equal( + await cache.set(mainScript, fingerprint('v0'), [outputEntry]), + true, + ); - const linkedCacheDir = pathlib.join( - getScriptDataDir(linkedScript), - 'cache', - ); - await assert.rejects(fs.readdir(linkedCacheDir), {code: 'ENOENT'}); - - const hit = await cache.get(linkedScript, fingerprint('v0')); - assert.notEqual(hit, undefined); - await hit!.apply(); - assert.equal(await rig.read(pathlib.join('linked', 'output')), 'from-main'); - } finally { - await rig.cleanup(); - } + const linkedCacheDir = pathlib.join(getScriptDataDir(linkedScript), 'cache'); + await assert.rejects(fs.readdir(linkedCacheDir), {code: 'ENOENT'}); + + const hit = await cache.get(linkedScript, fingerprint('v0')); + assert.ok(hit); + await hit.apply(); + assert.equal(await rig.read(pathlib.join('linked', 'output')), 'from-main'); }); diff --git a/src/test/portable-fingerprint.test.ts b/src/test/portable-fingerprint.test.ts deleted file mode 100644 index 68eb72e99..000000000 --- a/src/test/portable-fingerprint.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {test} from 'node:test'; -import * as assert from 'node:assert'; -import * as pathlib from 'path'; -import {Fingerprint} from '../fingerprint.js'; -import {scriptReferenceToString} from '../config.js'; -import {portableFingerprintString} from '../util/portable-fingerprint.js'; - -import type {FingerprintString} from '../fingerprint.js'; - -const fingerprintJson = (packageDir: string) => - JSON.stringify({ - fullyTracked: true, - platform: 'darwin', - arch: 'arm64', - nodeVersion: 'v22.0.0', - command: 'tsc', - extraArgs: [], - clean: true, - files: { - [pathlib.join(packageDir, 'src', 'a.ts')]: 'hash-a', - [pathlib.join(packageDir, 'input.txt')]: 'hash-input', - }, - output: ['lib/**'], - dependencies: { - [scriptReferenceToString({ - packageDir: pathlib.join(packageDir, '..', 'dep'), - name: 'compile', - })]: 'dep-hash', - }, - service: undefined, - env: {}, - }); - -void test('portable fingerprint is identical for two absolute checkouts', () => { - const a = '/tmp/checkout-a/packages/foo'; - const b = '/Users/me/wt/packages/foo'; - const portableA = portableFingerprintString( - Fingerprint.fromString(fingerprintJson(a) as FingerprintString), - a, - ); - const portableB = portableFingerprintString( - Fingerprint.fromString(fingerprintJson(b) as FingerprintString), - b, - ); - assert.equal(portableA, portableB); - const parsed = JSON.parse(portableA) as { - files: Record; - dependencies: Record; - }; - assert.deepEqual( - Object.keys(parsed.files).sort(), - [pathlib.join('src', 'a.ts'), 'input.txt'].sort(), - ); - assert.deepEqual(Object.keys(parsed.dependencies), [ - JSON.stringify([pathlib.join('..', 'dep'), 'compile']), - ]); -}); - -void test('non-JSON fingerprints pass through for unit-test fakes', () => { - assert.equal( - portableFingerprintString( - Fingerprint.fromString('v0' as FingerprintString), - '/tmp/pkg', - ), - 'v0', - ); -}); diff --git a/src/test/util/filesystem-test-rig.ts b/src/test/util/filesystem-test-rig.ts index 48059bcae..072c44409 100644 --- a/src/test/util/filesystem-test-rig.ts +++ b/src/test/util/filesystem-test-rig.ts @@ -31,6 +31,12 @@ export class FilesystemTestRig { } } + static async setup(): Promise { + const rig = new FilesystemTestRig(); + await rig.setup(); + return rig; + } + /** * Initialize the temporary filesystem. */ @@ -40,6 +46,10 @@ export class FilesystemTestRig { await this.mkdir('.'); } + async [Symbol.asyncDispose](): Promise { + await this.cleanup(); + } + /** * Delete the temporary filesystem. */ diff --git a/src/test/util/git.ts b/src/test/util/git.ts new file mode 100644 index 000000000..b345b49bb --- /dev/null +++ b/src/test/util/git.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {execFileSync} from 'child_process'; + +export const git = (cwd: string, args: string[]) => { + execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +}; + +export const initRepo = (dir: string) => { + git(dir, ['init']); + git(dir, ['config', 'user.email', 'wireit@example.com']); + git(dir, ['config', 'user.name', 'Wireit Test']); + git(dir, ['config', 'commit.gpgsign', 'false']); +}; diff --git a/src/test/util/test-rig.ts b/src/test/util/test-rig.ts index 2372766cc..bec5402fc 100644 --- a/src/test/util/test-rig.ts +++ b/src/test/util/test-rig.ts @@ -44,7 +44,7 @@ export class WireitTestRig extends FilesystemTestRig implements AsyncDisposable { - static async setup(): Promise { + static override async setup(): Promise { const rig = new WireitTestRig(); await rig.setup(); return rig; @@ -273,7 +273,7 @@ export class WireitTestRig return command; } - async [Symbol.asyncDispose]() { + override async [Symbol.asyncDispose]() { await this.cleanup(); } diff --git a/src/util/cache-root.ts b/src/util/cache-root.ts index b2dd9ba5e..da44c3d95 100644 --- a/src/util/cache-root.ts +++ b/src/util/cache-root.ts @@ -4,59 +4,51 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {createHash} from 'crypto'; -import * as fs from 'fs'; +import * as fs from './fs.js'; import * as pathlib from 'path'; -export type WorktreeInfo = { +export interface WorktreeInfo { worktreeRoot: string; mainWorktreeRoot: string; -}; +} /** * Directory whose `.wireit/` folder should hold this package's local cache * (not lock/fingerprint/manifest). * - * - `cacheDir` set (WIREIT_CACHE_DIR): `{cacheDir}/{path-from-worktree-root}`. - * Without git, isolated by a hash of the absolute package path (no sharing). - * - Linked git worktree: the same relative package under the main worktree. - * - Otherwise: `packageDir` (Wireit's historical layout). + * Linked git worktrees share the main worktree only when `shareWorktrees` is + * true. Otherwise `packageDir` (Wireit's historical layout). */ -export const resolveCachePackageDir = ( +export const resolveCachePackageDir = async ( packageDir: string, - cacheDir?: string, -): string => { + options?: {shareWorktrees?: boolean}, +): Promise => { const absPackageDir = pathlib.resolve(packageDir); - const worktree = detectWorktree(absPackageDir); - const relPackage = - worktree === undefined - ? undefined - : pathlib.relative(worktree.worktreeRoot, absPackageDir); - - if (cacheDir !== undefined && cacheDir !== '') { - const root = pathlib.resolve(cacheDir); - return relPackage === undefined - ? pathlib.join(root, hashPath(absPackageDir)) - : pathlib.join(root, relPackage); + if (options?.shareWorktrees !== true) { + return absPackageDir; } - + const worktree = await detectWorktree(absPackageDir); if ( - worktree !== undefined && - relPackage !== undefined && - worktree.worktreeRoot !== worktree.mainWorktreeRoot + worktree === undefined || + worktree.worktreeRoot === worktree.mainWorktreeRoot ) { - return pathlib.join(worktree.mainWorktreeRoot, relPackage); + return absPackageDir; } - return absPackageDir; + return pathlib.join( + worktree.mainWorktreeRoot, + pathlib.relative(worktree.worktreeRoot, absPackageDir), + ); }; -export const detectWorktree = (startDir: string): WorktreeInfo | undefined => { - const worktreeRoot = findGitAncestor(pathlib.resolve(startDir)); +export const detectWorktree = async ( + startDir: string, +): Promise => { + const worktreeRoot = await findGitAncestor(pathlib.resolve(startDir)); if (worktreeRoot === undefined) { return undefined; } const dotGit = pathlib.join(worktreeRoot, '.git'); - const stat = lstatOrUndefined(dotGit); + const stat = await lstatOrUndefined(dotGit); if (stat === undefined) { return undefined; } @@ -66,19 +58,19 @@ export const detectWorktree = (startDir: string): WorktreeInfo | undefined => { if (!stat.isFile()) { return undefined; } - const gitDir = readGitdirPointer(dotGit, worktreeRoot); + const gitDir = await readGitdirPointer(dotGit, worktreeRoot); if (gitDir === undefined) { return undefined; } - const mainWorktreeRoot = readMainWorktreeRoot(gitDir); + const mainWorktreeRoot = await readMainWorktreeRoot(gitDir); if (mainWorktreeRoot === undefined) { return undefined; } return {worktreeRoot, mainWorktreeRoot}; }; -const findGitAncestor = (dir: string): string | undefined => { - const stat = lstatOrUndefined(pathlib.join(dir, '.git')); +const findGitAncestor = async (dir: string): Promise => { + const stat = await lstatOrUndefined(pathlib.join(dir, '.git')); if (stat !== undefined) { return dir; } @@ -86,27 +78,39 @@ const findGitAncestor = (dir: string): string | undefined => { return parent === dir ? undefined : findGitAncestor(parent); }; -const lstatOrUndefined = (path: string): fs.Stats | undefined => { +const lstatOrUndefined = async ( + path: string, +): Promise> | undefined> => { try { - return fs.lstatSync(path); - } catch { - return undefined; + return await fs.lstat(path); + } catch (error) { + const {code} = error as {code: string}; + if (code === /* does not exist */ 'ENOENT') { + return undefined; + } + throw error; } }; -const readFileOrUndefined = (path: string): string | undefined => { +const readFileOrUndefined = async ( + path: string, +): Promise => { try { - return fs.readFileSync(path, 'utf8'); - } catch { - return undefined; + return await fs.readFile(path, 'utf8'); + } catch (error) { + const {code} = error as {code: string}; + if (code === /* does not exist */ 'ENOENT') { + return undefined; + } + throw error; } }; -const readGitdirPointer = ( +const readGitdirPointer = async ( dotGitFile: string, worktreeRoot: string, -): string | undefined => { - const content = readFileOrUndefined(dotGitFile); +): Promise => { + const content = await readFileOrUndefined(dotGitFile); if (content === undefined) { return undefined; } @@ -122,9 +126,11 @@ const readGitdirPointer = ( return realpathOrSelf(resolved); }; -const readMainWorktreeRoot = (gitDir: string): string | undefined => { - const commondir = readFileOrUndefined( - pathlib.join(gitDir, 'commondir'), +const readMainWorktreeRoot = async ( + gitDir: string, +): Promise => { + const commondir = ( + await readFileOrUndefined(pathlib.join(gitDir, 'commondir')) )?.trim(); if (commondir === undefined || commondir === '') { return undefined; @@ -132,18 +138,19 @@ const readMainWorktreeRoot = (gitDir: string): string | undefined => { const gitCommon = pathlib.isAbsolute(commondir) ? commondir : pathlib.resolve(gitDir, commondir); - const resolved = realpathOrSelf(gitCommon); + const resolved = await realpathOrSelf(gitCommon); const main = pathlib.dirname(resolved); return main === resolved ? undefined : main; }; -const realpathOrSelf = (path: string): string => { +const realpathOrSelf = async (path: string): Promise => { try { - return fs.realpathSync(path); - } catch { - return path; + return await fs.realpath(path); + } catch (error) { + const {code} = error as {code: string}; + if (code === /* does not exist */ 'ENOENT') { + return path; + } + throw error; } }; - -const hashPath = (path: string): string => - createHash('sha256').update(path).digest('hex').slice(0, 16); diff --git a/src/util/fs.ts b/src/util/fs.ts index e1dd2f8f8..299a2a4ae 100644 --- a/src/util/fs.ts +++ b/src/util/fs.ts @@ -141,6 +141,15 @@ export async function lstat(path: string): Promise { } } +export async function realpath(path: string): Promise { + const reservation = await fileBudget.reserve(); + try { + return await fs.realpath(path); + } finally { + reservation[Symbol.dispose](); + } +} + export async function stat(path: string): Promise { const reservation = await fileBudget.reserve(); try { diff --git a/src/util/portable-fingerprint.ts b/src/util/portable-fingerprint.ts deleted file mode 100644 index e26eb5132..000000000 --- a/src/util/portable-fingerprint.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {createHash} from 'crypto'; -import * as pathlib from 'path'; -import {stringToScriptReference} from '../config.js'; -import type {ScriptReferenceString} from '../config.js'; - -import type {Fingerprint} from '../fingerprint.js'; - -/** - * SHA-256 of {@link portableFingerprintString}. Used as the local cache - * directory name so two checkouts of the same tree share entries. - * - * GitHub Actions caching still uses {@link Fingerprint.string} and is - * unchanged. - */ -export const hashPortableFingerprint = ( - fingerprint: Fingerprint, - packageDir: string, -): string => - createHash('sha256') - .update(portableFingerprintString(fingerprint, packageDir)) - .digest('hex'); - -/** - * Rewrite fingerprint JSON so file and dependency keys are relative to - * {@link packageDir}. Non-JSON fingerprints (unit-test fakes) pass through. - */ -export const portableFingerprintString = ( - fingerprint: Fingerprint, - packageDir: string, -): string => { - const parsed: unknown = parseJson(fingerprint.string); - if (!isFingerprintLike(parsed)) { - return fingerprint.string; - } - const absPackageDir = pathlib.resolve(packageDir); - const files = Object.fromEntries( - Object.entries(parsed.files) - .map(([path, hash]) => [relativize(absPackageDir, path), hash] as const) - .sort(([a], [b]) => a.localeCompare(b)), - ); - const dependencies = Object.fromEntries( - Object.entries(parsed.dependencies) - .map( - ([key, hash]) => - [relativizeDependencyKey(absPackageDir, key), hash] as const, - ) - .sort(([a], [b]) => a.localeCompare(b)), - ); - return JSON.stringify({...parsed, files, dependencies}); -}; - -const parseJson = (value: string): unknown => { - try { - return JSON.parse(value) as unknown; - } catch { - return undefined; - } -}; - -const isFingerprintLike = ( - value: unknown, -): value is { - files: Record; - dependencies: Record; -} => - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - isStringRecord((value as {files?: unknown}).files) && - isStringRecord((value as {dependencies?: unknown}).dependencies); - -const isStringRecord = (value: unknown): value is Record => { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - return Object.values(value).every((item) => typeof item === 'string'); -}; - -const relativize = (packageDir: string, path: string): string => { - if (!pathlib.isAbsolute(path)) { - return path; - } - const relative = pathlib.relative(packageDir, path); - return relative === '' ? '.' : relative; -}; - -const relativizeDependencyKey = (packageDir: string, key: string): string => { - try { - const {packageDir: depPackageDir, name} = stringToScriptReference( - key as ScriptReferenceString, - ); - return JSON.stringify([relativize(packageDir, depPackageDir), name]); - } catch { - return key; - } -}; From 79e370807360316f3dd0c52692be346b29cbff5e Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 21 Sep 2026 16:13:32 -0500 Subject: [PATCH 4/5] Use package-relative local-cache entry names only when worktree sharing is on. Any other WIREIT_CACHE_WORKTREES value keeps the historical cache layout, absolute fingerprints, and GitHub Actions caching. --- CHANGELOG.md | 9 ++-- README.md | 12 ++--- src/caching/local-cache.ts | 84 ++++++++++++++++++++++++++++------- src/cli-options.ts | 27 ++--------- src/fingerprint.ts | 61 ++++++++++++++++++++++--- src/test/cli-options.test.ts | 21 +++++++++ src/test/errors-usage.test.ts | 26 ----------- src/test/fingerprint.test.ts | 17 +++++-- src/test/local-cache.test.ts | 23 +++++++++- src/util/cache-root.ts | 10 ++++- 10 files changed, 200 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eabca20d6..489d6808e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,10 @@ Versioning](https://semver.org/spec/v2.0.0.html). version, shrinks by one entry per write, and Wireit reminds you once a day while it holds more than twice the limit. To free it at once, run `rm -rf .wireit/*/cache`. See [#71](https://github.com/google/wireit/issues/71). -- Fingerprint file and dependency paths are relative to the package, so local - and GitHub Actions cache keys match across checkouts. Set - `WIREIT_CACHE_WORKTREES=true` to store a linked Git worktree's local cache in - the main worktree's corresponding `.wireit` folders. A concurrent write of an - existing local cache entry is now treated as a hit instead of throwing. +- Set `WIREIT_CACHE_WORKTREES=true` to store a linked Git worktree's local + cache in the main worktree's corresponding `.wireit` folders. Any other value + keeps each worktree's own cache. Fingerprints and GitHub Actions caching are + unchanged. ### Fixed diff --git a/README.md b/README.md index 612ec165e..aaaef7b43 100644 --- a/README.md +++ b/README.md @@ -374,9 +374,11 @@ still use a multiple of this space. To free all of it at once, use Linked Git worktrees (`git worktree add`) keep their own `.wireit` cache folders. Set `WIREIT_CACHE_WORKTREES=true` to store a linked worktree's local -cache in the main worktree's corresponding `.wireit` folders, so checkouts of -the same files reuse cache hits. Lock, fingerprint, and output-manifest files -stay in the current worktree. GitHub Actions caching is not affected. +cache in the main worktree's corresponding `.wireit` folders. Shared entries +are named from package-relative paths, so both worktrees hit the same entry. +Any other value keeps each worktree's own cache and the same entry names as +before. Lock, fingerprint, and output-manifest files stay in the current +worktree. Fingerprints and GitHub Actions caching are unchanged. Restored files are copied verbatim. Wireit does not rewrite output contents that embed another checkout's absolute path. @@ -904,7 +906,7 @@ The following environment variables affect the behavior of Wireit: | `WIREIT_FAILURES` | [How to handle script failures](#failures-and-errors).

Options:
  • [`no-new`](#failures-and-errors) (default): Allow running scripts to finish, but don't start new ones.
  • [`continue`](#continue): Allow running scripts to continue, and start new ones unless any of their dependencies failed.
  • [`kill`](#kill): Immediately kill running scripts, and don't start new ones.
| | `WIREIT_LOGGER` | How to present progress and results on the command line.

Options:
  • `quiet` (default for normal execution): Writes a single dynamically updating line summarizing progress. Only passes along stdout and stderr from commands if there's a failure, or if the command is a service.
  • `quiet-ci` (default when `env.CI` or `!stdout.isTTY`): like `quiet` but optimized for non-interactive environments, like GitHub Actions runners.
  • `simple`: A verbose logger that presents clear information about the work that Wireit is doing.
  • `metrics`: Like `simple`, but also presents a summary table of results once a command is finished.
| | `WIREIT_CACHE_MAX_ENTRIES` | Maximum number of [local cache](#local-caching) entries to retain per script.

Defaults to `10`.

Must be a positive integer or `infinity`. | -| `WIREIT_CACHE_WORKTREES` | Set to `true` to store a linked Git worktree's [local cache](#local-caching) in the main worktree's corresponding `.wireit` folders. Unset keeps each worktree's own cache.

Must be exactly `true`. Does not affect GitHub Actions caching. | +| `WIREIT_CACHE_WORKTREES` | Set to `true` to store a linked Git worktree's [local cache](#local-caching) in the main worktree's corresponding `.wireit` folders.

Must be exactly `true`. If unset or any other value, interpreted as `false`. Does not affect fingerprints or GitHub Actions caching. | | `WIREIT_DEBUG_LOG_FILE` | Path to a file which will receive detailed event logging. | | `WIREIT_MAX_OPEN_FILES` | Limits the number of file descriptors Wireit will have open concurrently. Prevents resource exhaustion when checking large numbers of cached files. Set to a lower number if you hit file descriptor limits.

Defaults to `200`.

Must be a positive integer. | | `WIREIT_PARALLEL` | [Maximum number of scripts to run at one time](#parallelism).

Defaults to 2×logical CPU cores.

Must be a positive integer or `infinity`. | @@ -954,8 +956,6 @@ cache](#caching). - The `clean` setting. - The `output` glob patterns. - The SHA256 content hashes of all files matching `files`. -- File paths and dependency package paths are relative to the current package, - so two checkouts of the same files produce the same fingerprint. - The SHA256 content hashes of all files matching `packageLocks` in the current package and all parent directories. - The environment variable values configured in `env`. diff --git a/src/caching/local-cache.ts b/src/caching/local-cache.ts index 4f0133987..12382b9e1 100644 --- a/src/caching/local-cache.ts +++ b/src/caching/local-cache.ts @@ -141,6 +141,29 @@ export class LocalCache implements Cache { const cachePackageDir = await this.#cachePackageDir(script); this.#packageDirs.add(cachePackageDir); const absCacheDir = this.#getCacheDir(cachePackageDir, script, fingerprint); + await (this.#shareWorktrees + ? this.#writeShared(script, cachePackageDir, absCacheDir, absoluteFiles) + : this.#writeExclusive( + cachePackageDir, + script, + absCacheDir, + absoluteFiles, + )); + await this.#evictLeastRecentlyUsed(script, pathlib.basename(absCacheDir)); + return true; + } + + /** + * Copy into a temp folder and rename into place, so a killed Wireit can't + * leave a partial entry. An entry that already exists is an error: the + * Executor checks for a hit before it runs the script. + */ + async #writeExclusive( + cachePackageDir: string, + script: ScriptReference, + absCacheDir: string, + absoluteFiles: AbsoluteEntry[], + ): Promise { if (absoluteFiles.length === 0) { // No temp folder, because an empty "output" runs without the lock. // @@ -153,8 +176,7 @@ export class LocalCache implements Cache { // checked for an existing cache hit. throw new Error(`Did not expect ${absCacheDir} to already exist.`); } - await this.#evictLeastRecentlyUsed(script, pathlib.basename(absCacheDir)); - return true; + return; } await this.#writeThroughTemp( cachePackageDir, @@ -162,15 +184,47 @@ export class LocalCache implements Cache { absoluteFiles, absCacheDir, ); - await Promise.all([ - this.#evictLeastRecentlyUsed(script, pathlib.basename(absCacheDir)), - // The script lock covers this temp folder only when the cache stays in - // this worktree. A shared cache can have another worktree's write in it. - ...(this.#shareWorktrees - ? [] - : [this.#trashLeftoverTemp(cachePackageDir, script)]), - ]); - return true; + // The script lock covers this temp folder. Anything still in it was left + // by a killed or failed write. + await this.#trashLeftoverTemp(cachePackageDir, script); + } + + /** + * Temp dir plus rename, so two worktrees racing on one shared entry leave a + * single complete directory. If the destination already exists, that write + * won and this one is a hit. Does not sweep other temps: the script lock + * stays in this worktree, so another worktree may be writing into the same + * shared temp folder. + */ + async #writeShared( + script: ScriptReference, + cachePackageDir: string, + absCacheDir: string, + absoluteFiles: AbsoluteEntry[], + ): Promise { + const tempDir = pathlib.join( + this.#getScriptTempDir(cachePackageDir, script), + randomBytes(8).toString('hex'), + ); + await fs.mkdir(this.#getScriptCacheDir(cachePackageDir, script), { + recursive: true, + }); + try { + await copyEntries(absoluteFiles, script.packageDir, tempDir); + await fs.rename(tempDir, absCacheDir); + } catch (error) { + await fs.rmTree(tempDir).catch(() => {}); + const {code} = error as {code?: string}; + if (code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'EPERM') { + try { + await fs.access(absCacheDir); + return; + } catch { + throw error; + } + } + throw error; + } } async #writeThroughTemp( @@ -386,9 +440,9 @@ export class LocalCache implements Cache { } async #cachePackageDir(script: ScriptReference): Promise { - return resolveCachePackageDir(script.packageDir, { - shareWorktrees: this.#shareWorktrees, - }); + return this.#shareWorktrees + ? resolveCachePackageDir(script.packageDir, {shareWorktrees: true}) + : script.packageDir; } #getScriptCacheDir(cachePackageDir: string, script: ScriptReference): string { @@ -412,7 +466,7 @@ export class LocalCache implements Cache { ): string { return pathlib.join( this.#getScriptCacheDir(cachePackageDir, script), - fingerprint.hash, + fingerprint.localCacheEntryName(this.#shareWorktrees), ); } } diff --git a/src/cli-options.ts b/src/cli-options.ts index 9e13950a6..6f1ebe76d 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -221,29 +221,8 @@ export const getOptions = async (): Promise> => { } } - const cacheWorktreesResult = ((): Result => { - const str = process.env['WIREIT_CACHE_WORKTREES']; - if (str === undefined) { - return {ok: true, value: false}; - } - if (str === 'true') { - return {ok: true, value: true}; - } - return { - ok: false, - error: { - reason: 'invalid-usage', - message: - `Expected the WIREIT_CACHE_WORKTREES env variable to be ` + - `"true", got ${JSON.stringify(str)}`, - script, - type: 'failure', - }, - }; - })(); - if (!cacheWorktreesResult.ok) { - return cacheWorktreesResult; - } + // Same rule as CI: only the exact string "true" enables it. + const cacheWorktrees = process.env['WIREIT_CACHE_WORKTREES'] === 'true'; const failureModeResult = ((): Result => { const str = process.env['WIREIT_FAILURES']; @@ -332,7 +311,7 @@ export const getOptions = async (): Promise> => { numWorkers: numWorkersResult.value, cache: cacheResult.value, cacheMaxEntries: cacheMaxEntriesResult.value, - cacheWorktrees: cacheWorktreesResult.value, + cacheWorktrees, failureMode: failureModeResult.value, agent, logger, diff --git a/src/fingerprint.ts b/src/fingerprint.ts index d1ceea61c..0660d86ed 100644 --- a/src/fingerprint.ts +++ b/src/fingerprint.ts @@ -139,6 +139,9 @@ export class Fingerprint { const filteredDependencyFingerprints: Array< [ScriptReferenceString, FingerprintSha256HexDigest] > = []; + const portableDependencyFingerprints: Array< + [ScriptReferenceString, string] + > = []; for (const [dep, depFingerprint] of dependencyFingerprints) { if (!dep.cascade) { // cascade: false means the fingerprint of the dependency isn't @@ -149,6 +152,13 @@ export class Fingerprint { allDependenciesAreFullyTracked = false; } filteredDependencyFingerprints.push([ + scriptReferenceToString(dep.config), + depFingerprint.hash, + ]); + // Package-relative, and hashed from the dependency's portable form, so + // two worktrees of one repo share a local-cache entry. This is not part + // of {@link Fingerprint.string}. + portableDependencyFingerprints.push([ scriptReferenceToString({ packageDir: pathRelativeToPackage( script.packageDir, @@ -156,7 +166,7 @@ export class Fingerprint { ), name: dep.config.name, }), - depFingerprint.hash, + depFingerprint.localCacheEntryName(true), ]); } @@ -200,10 +210,7 @@ export class Fingerprint { } erroredFilePaths.push(absolutePath); } - return [ - pathRelativeToPackage(script.packageDir, absolutePath), - hash.digest('hex') as FileSha256HexDigest, - ]; + return [absolutePath, hash.digest('hex') as FileSha256HexDigest]; }), ); @@ -268,12 +275,36 @@ export class Fingerprint { env: script.env, }; fingerprint.#data = data as FingerprintData; + fingerprint.#portableString = JSON.stringify({ + ...data, + files: Object.fromEntries( + fileHashes + .map(([filePath, hash]): [string, FileSha256HexDigest] => [ + pathRelativeToPackage(script.packageDir, filePath), + hash, + ]) + .sort(([aFile], [bFile]) => aFile.localeCompare(bFile)), + ), + dependencies: Object.fromEntries( + portableDependencyFingerprints.sort(([aRef], [bRef]) => + aRef.localeCompare(bRef), + ), + ), + }); return {ok: true, value: fingerprint}; } #str?: FingerprintString; #data?: FingerprintData; #hash?: FingerprintSha256HexDigest; + /** + * Same fields as {@link string}, but file paths and dependency package dirs + * are package-relative and dependency hashes come from each dependency's + * portable form. Set only by {@link Fingerprint.compute}. Local-cache entry + * names use this when worktree sharing is on. Freshness and GitHub Actions + * caching keep using {@link string}. + */ + #portableString?: string; get string(): FingerprintString { if (this.#str === undefined) { @@ -298,12 +329,30 @@ export class Fingerprint { return this.#hash; } + /** + * Directory name of a local-cache entry. + * + * With worktree sharing off, this is {@link hash}, so existing cache folders + * stay valid. With sharing on, it is the hash of {@link #portableString}. + * A fingerprint built with {@link Fingerprint.fromString} has no portable + * form and falls back to {@link hash}. + */ + localCacheEntryName(shareWorktrees: boolean): string { + const source = + shareWorktrees && this.#portableString !== undefined + ? this.#portableString + : this.string; + return source === this.string + ? this.hash + : createHash('sha256').update(source).digest('hex'); + } + equal(other: Fingerprint): boolean { return this.string === other.string; } } -/** Package-relative so fingerprints match across checkouts. */ +/** Package-relative so a shared local-cache entry matches across worktrees. */ const pathRelativeToPackage = (packageDir: string, path: string): string => { const relative = pathlib.relative(packageDir, path); return relative === '' ? '.' : relative; diff --git a/src/test/cli-options.test.ts b/src/test/cli-options.test.ts index 676e31f88..6c5e6ad2b 100644 --- a/src/test/cli-options.test.ts +++ b/src/test/cli-options.test.ts @@ -629,4 +629,25 @@ for (const {agent, runCmd, testCmd, startCmd, needsExtraDashes} of commands) { ); }), ); + + void test( + `${agent} WIREIT_CACHE_WORKTREES=yes is false`, + rigTest(async ({rig}) => { + await assertOptions( + rig, + `${runCmd} main ${extraDashes}`, + { + agent, + script: { + packageDir: rig.temp, + name: 'main', + }, + cacheWorktrees: false, + }, + { + WIREIT_CACHE_WORKTREES: 'yes', + }, + ); + }), + ); } diff --git a/src/test/errors-usage.test.ts b/src/test/errors-usage.test.ts index c99dbfde3..4e1b811f3 100644 --- a/src/test/errors-usage.test.ts +++ b/src/test/errors-usage.test.ts @@ -251,32 +251,6 @@ void test( }), ); -void test( - 'nonsense WIREIT_CACHE_WORKTREES', - rigTest(async ({rig}) => { - await rig.write({ - 'package.json': { - scripts: { - main: 'wireit', - }, - wireit: { - main: {command: (await rig.newCommand()).command}, - }, - }, - }); - const result = rig.exec('npm run main', { - env: {WIREIT_CACHE_WORKTREES: 'yes'}, - }); - const done = await result.exit; - assert.equal(done.code, 1); - assert.ok( - done.stderr.includes( - `❌ [main] Invalid usage: Expected the WIREIT_CACHE_WORKTREES env variable to be "true", got "yes"`, - ), - ); - }), -); - void test( 'nonsense WIREIT_FAILURES', rigTest(async ({rig}) => { diff --git a/src/test/fingerprint.test.ts b/src/test/fingerprint.test.ts index 749a1efe9..d7ff0d04c 100644 --- a/src/test/fingerprint.test.ts +++ b/src/test/fingerprint.test.ts @@ -61,7 +61,7 @@ const depAt = ( fingerprint, ]; -void test('fingerprint is identical for two absolute checkouts', async () => { +void test('fingerprint stays absolute; shared cache entry name matches', async () => { await using rig = await FilesystemTestRig.setup(); await rig.write({ 'a/packages/foo/src/a.ts': 'export const a = 1;', @@ -82,15 +82,24 @@ void test('fingerprint is identical for two absolute checkouts', async () => { ]); assert.ok(resultA.ok); assert.ok(resultB.ok); - assert.equal(resultA.value.string, resultB.value.string); + assert.notEqual(resultA.value.string, resultB.value.string); assert.deepEqual( Object.keys(resultA.value.data.files).sort(), - [pathlib.join('src', 'a.ts'), 'input.txt'].sort(), + [pathlib.join(fooA, 'src', 'a.ts'), pathlib.join(fooA, 'input.txt')].sort(), ); assert.deepEqual(Object.keys(resultA.value.data.dependencies), [ scriptReferenceToString({ - packageDir: pathlib.join('..', 'dep'), + packageDir: rig.resolve('a/packages/dep'), name: 'compile', }), ]); + assert.equal(resultA.value.localCacheEntryName(false), resultA.value.hash); + assert.notEqual( + resultA.value.localCacheEntryName(false), + resultB.value.localCacheEntryName(false), + ); + assert.equal( + resultA.value.localCacheEntryName(true), + resultB.value.localCacheEntryName(true), + ); }); diff --git a/src/test/local-cache.test.ts b/src/test/local-cache.test.ts index 356b55440..fd85e6e5b 100644 --- a/src/test/local-cache.test.ts +++ b/src/test/local-cache.test.ts @@ -25,7 +25,10 @@ import type {ScriptReference} from '../config.js'; const SCRIPT_NAME = 'a'; -async function setup(maxEntries: number): Promise< +async function setup( + maxEntries: number, + shareWorktrees = false, +): Promise< { rig: FilesystemTestRig; cache: LocalCache; @@ -62,7 +65,7 @@ async function setup(maxEntries: number): Promise< packageDir: rig.resolve('.'), name: SCRIPT_NAME, }; - const cache = new LocalCache(maxEntries); + const cache = new LocalCache(maxEntries, {shareWorktrees}); const cacheDir = pathlib.join(getScriptDataDir(script), 'cache'); const trashDir = rig.resolve(pathlib.join('.wireit', 'trash')); @@ -487,6 +490,22 @@ void test('a write that fails leaves no entry, and deletes its temp copy', async assert.deepEqual(await ctx.entryHashes(), [hashOf('v0')]); }); +void test('a second set of the same entry throws', async () => { + await using ctx = await setup(10); + await ctx.cacheOutput('v0'); + await assert.rejects(() => ctx.cacheOutput('v0')); +}); + +void test('a second set of a shared entry is a hit, not an error', async () => { + await using ctx = await setup(10, true); + // Stop the worktree walk at this temp dir, including when the checkout + // running the test is itself a linked worktree. + await ctx.rig.mkdir('.git'); + await ctx.cacheOutput('v0'); + await ctx.cacheOutput('v0'); + assert.deepEqual(await ctx.entryHashes(), [hashOf('v0')]); +}); + void test('linked worktree restores from the main worktree local cache', async () => { await using rig = await FilesystemTestRig.setup(); const main = rig.resolve('main'); diff --git a/src/util/cache-root.ts b/src/util/cache-root.ts index da44c3d95..9a3dd391c 100644 --- a/src/util/cache-root.ts +++ b/src/util/cache-root.ts @@ -23,10 +23,10 @@ export const resolveCachePackageDir = async ( packageDir: string, options?: {shareWorktrees?: boolean}, ): Promise => { - const absPackageDir = pathlib.resolve(packageDir); if (options?.shareWorktrees !== true) { - return absPackageDir; + return packageDir; } + const absPackageDir = pathlib.resolve(packageDir); const worktree = await detectWorktree(absPackageDir); if ( worktree === undefined || @@ -70,6 +70,9 @@ export const detectWorktree = async ( }; const findGitAncestor = async (dir: string): Promise => { + // Nearest `.git`. A submodule working tree has its own `.git` file, so a + // package inside one does not walk up into the superproject. + // https://git-scm.com/docs/gitsubmodules const stat = await lstatOrUndefined(pathlib.join(dir, '.git')); if (stat !== undefined) { return dir; @@ -129,6 +132,9 @@ const readGitdirPointer = async ( const readMainWorktreeRoot = async ( gitDir: string, ): Promise => { + // Linked worktrees point `commondir` at the main git dir. A submodule git + // dir has none, so this returns undefined and the cache stays put. + // https://git-scm.com/docs/gitrepository-layout const commondir = ( await readFileOrUndefined(pathlib.join(gitDir, 'commondir')) )?.trim(); From 8b93545da8a1fa5a6307556359994e7e7b807fb2 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Mon, 28 Sep 2026 10:28:56 -0500 Subject: [PATCH 5/5] Treat an empty shared cache write as a hit. An empty output creates no temp directory, so renaming one failed when WIREIT_CACHE_WORKTREES=true. --- src/caching/local-cache.ts | 7 +++++++ src/test/local-cache.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/caching/local-cache.ts b/src/caching/local-cache.ts index 12382b9e1..993359e2f 100644 --- a/src/caching/local-cache.ts +++ b/src/caching/local-cache.ts @@ -202,6 +202,13 @@ export class LocalCache implements Cache { absCacheDir: string, absoluteFiles: AbsoluteEntry[], ): Promise { + if (absoluteFiles.length === 0) { + // No temp folder, because an empty "output" runs without the lock. + // mkdir is a hit when the directory already exists, so two worktrees + // caching the same empty output don't throw. + await fs.mkdir(absCacheDir, {recursive: true}); + return; + } const tempDir = pathlib.join( this.#getScriptTempDir(cachePackageDir, script), randomBytes(8).toString('hex'), diff --git a/src/test/local-cache.test.ts b/src/test/local-cache.test.ts index fd85e6e5b..7ab3065d0 100644 --- a/src/test/local-cache.test.ts +++ b/src/test/local-cache.test.ts @@ -496,6 +496,16 @@ void test('a second set of the same entry throws', async () => { await assert.rejects(() => ctx.cacheOutput('v0')); }); +void test('a second shared set of an empty output is a hit', async () => { + await using ctx = await setup(10, true); + // Stop the worktree walk at this temp dir, including when the checkout + // running the test is itself a linked worktree. + await ctx.rig.mkdir('.git'); + assert.equal(await ctx.cache.set(ctx.script, fingerprint('v0'), []), true); + assert.equal(await ctx.cache.set(ctx.script, fingerprint('v0'), []), true); + assert.deepEqual(await ctx.entryHashes(), [hashOf('v0')]); +}); + void test('a second set of a shared entry is a hit, not an error', async () => { await using ctx = await setup(10, true); // Stop the worktree walk at this temp dir, including when the checkout