install: return immediately when nothing that determines node_modules has changed

MA
3mo ago
+1302-16

What does this PR do?

Makes a repeat bun install with nothing to do close to free, without giving up correctness.

Today a no-op install still parses the lockfile, re-hoists, and walks node_modules opening and parsing every installed package.json to confirm name/version — O(packages) syscalls, which on large workspaces (and especially on Windows) is where almost all of the "no changes" time goes.

With this change, after a successful install bun records a fingerprint of the inputs that determine what ends up in node_modules, in <cache>/.install-state/<hash of project root>:

  • content hashes of bun.lock / bun.lockb, the root and every workspace package.json, ./bunfig.toml, ./.npmrc, ~/.npmrc, ~/.bunfig.toml, $XDG_CONFIG_HOME/{.bunfig.toml,.npmrc}, and every referenced patch file (absence is recorded too);
  • a hash of the install-relevant environment (BUN_INSTALL*, BUN_CONFIG_*, NPM_CONFIG_*), the exact argv, and bun's version (only the hash is stored);
  • lstat stamps of each node_modules directory (root + workspaces), of its top-level entries and of each entry's package.json (through symlinks, so the isolated linker's store is covered);
  • stamps of the parent directories of the workspaces globs — present or absent, and for nested globs the existing levels below the literal prefix — so a workspace added on a branch switch is noticed;
  • for file: / local-tarball dependencies (projects using bun link-ed packages always take the normal path), recursive stamps of the source (bounded; a project with enormous local sources just doesn't get a state file).

The next plain bun install re-hashes those few files, checks the stamps and, if everything matches, prints the usual Checked N installs across M packages (no changes) and returns. Anything else — bun add/remove/update, --force, --global, dry runs, positional filters, install.stateFile = false, or any changed input — takes the normal path. The state file is deleted before an install that does real work, so a crash mid-install can't leave a "clean" marker behind. As with other package managers' equivalent optimisation, root lifecycle scripts don't re-run on a no-op install.

The state lives in the cache dir (not node_modules) so directory listings are unchanged; a new readdirCacheSorted() test helper hides the bookkeeping dir from the few tests that snapshot the cache directory.

Docs: install.stateFile in docs/runtime/bunfig.mdx, a short "Repeat installs" note in docs/pm/cli/install.mdx.

How did you verify your code works?

  • New test/cli/install/bun-install-state.test.ts (dummy registry, both hoisted and isolated): repeat install is a no-op with zero requests; then each of these invalidates it and is acted on — editing a workspace package.json, adding a workspace under the glob, removing a package dir from node_modules, deleting a file inside an installed package, running with different flags, --force, stateFile = false; plus a file: dependency whose untouched source is a no-op and whose edited source is re-installed.
  • Ran bun-install, bun-add, bun-install-registry, bun-workspaces, isolated-install, bun-link, bun-lock, bun-remove, bun-update, bun-pm and bun-install-lifecycle-scripts suites locally.

Supersedes #39901 (same change, moved from a fork branch to a branch in this repository; the review discussion so far is on that PR and has been addressed here).