Skip to content

bun lint, bun fmt: --staged and --changed select a file behind a symlink to a directory - #46857

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/fc81b172/lint-changed-directory-links
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/fc81b172/lint-changed-directory-links

Conversation

@robobun

@robobun robobun commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

Related to #46583

Problem

  • bun lint --staged prints No staged files to lint and exits 0 for a changed file behind a directory symlink. --changed (No changed files to lint) and bun fmt do the same.
  • Git names a file by its real path. Files::as_listed (src/lint/driver/git.rs:366) used the path as the run spells it.

Fix

  • The file search reports the directory symlinks it went through. Only then does as_listed resolve a directory.
  • --staged gives its index texts to the import rules and the type checker under those paths too.
  • --fix writes a file that has two paths from the path git has.
  • Verified: test/cli/lint/git.test.ts (17 new tests, 14 fail on main). Also lint.test.ts, config-files.test.ts, format.test.ts. Self-reviewed: 39 concerns raised, 36 addressed. Not done: a Disk::read fallback and two test rows (Notes).

Background

Downsides

  • At the root, a file behind a followed link is reported under each path.
  • A symlink, a file argument, or a bun fmt argument other than . adds 0 to 199 realpath calls for 2,000 files. Release text: +7,936 bytes.
  • Not fixed: a link to a file, a deleted staged file behind a link, an import through another symlink.
Notes

Who hits this. On main, three kinds of run miss the file:

  • A run from a package directory that links a shared directory: No staged files to lint, exit 0.
  • A link, or a path through one, as an argument, for bun lint and bun fmt.
  • A run at the repository root where a config applies only on the path through the link: No problems in 1 file.

At the root without such a config, main reports the file under git's path and exits 1. Git has no entry for a path beyond a symlink (git add packages/a/tests/shared/limits.ts fails with is beyond a symbolic link), so main's answer is git's answer. With this PR the flags narrow the run and do not change which paths it lints.

Repro.

git init -q -b main . && mkdir -p shared-tests packages/a/tests
echo '{"name":"root","type":"module"}' > package.json
echo '{"name":"a","type":"module"}' > packages/a/package.json
echo 'export const limit = 1;' > shared-tests/limits.ts
ln -s ../../../shared-tests packages/a/tests/shared
git add -A && git -c user.name=t -c user.email=t@example.com commit -q -m init
printf 'debugger;\n' >> shared-tests/limits.ts
cd packages/a
bun lint                 # exit 1: no-debugger at tests/shared/limits.ts:2:1
bun lint --changed=HEAD  # main: "No changed files to lint", exit 0
git add -A && bun lint --staged  # main: "No staged files to lint", exit 0

Mechanism.

  • Loader::links and Loader::named_a_file are filled by the file search, like searched_from. The search already resolves each starting directory and each symlink it follows, so this costs no system call. Files::searched takes them before the run filters its files.
  • Files::resolves starts as true. A search that went through no symlink and named no file turns it off. Then as_listed returns every path as it is, as main does for a path in the work tree.
  • If it is on, as_listed first takes a path that is listed or is an index entry as it is. For any other path it resolves the directory, once for each directory. has only asks for a file that is named like a listed one.
  • already_read has the index texts for code that follows imports. It now also has each text under the path through each symlink that the run went through, if that path leads to the same directory. The module graph gets the texts after the search (Graph::provide). Store::provide asserts in debug builds that no rule has read the store yet.
  • bun fmt follows no symlink in a search and resolves no argument, so any argument other than the working directory turns resolves on.
  • --fix first writes the results whose path is the one git has. A result with another path to the same file then finds the file written and is skipped, as in a run without the flag. There, the threads decide which path writes.
  • A staged file whose directory became a symlink is found twice, on disk through the link and as a file that is not on disk. It is now linted once.

What does not change.

Measurements. Debug (ASan) builds for the counts, release builds for instructions and size. Merge base 6292c65 against this PR, x86_64 Linux. Counts are for the bun process without its git children: an LD_PRELOAD counter for libc realpath, a ptrace counter for system calls, perf_event_open for instructions:u, ASan's exit statistics for allocations. strace, perf, valgrind and bloaty are not installed in the build container.

2,000 files in 200 directories, --list-files --threads=1. "Shared" means that every directory has a file with the staged file's name.

Run main PR
bun lint, bun fmt, no flag newfstatat 2,186 and 2,018 the same, all counts
lint --staged, fmt --staged, fmt --staged ., lint --staged src, one staged file, shared newfstatat 2,217 / 2,049 / 2,049 / 2,238, realpath 1 / 0 / 0 / 1 the same, all counts
2,200 files: a staged file with a unique name, and an unstaged change in an index.js that every directory has newfstatat 2,428, realpath 1 the same
fmt --staged src, shared realpath 0, readlink 10 realpath 199, readlink 1,005
fmt --staged src, the staged name is unique realpath 0 realpath 0
the 10 staged files as arguments realpath 0 realpath 0
50 file arguments in 5 directories, 10 staged, shared realpath 0 realpath 5
the tree named through a link above it, shared realpath 2,001, readlink 16,016 realpath 201, readlink 1,616
the repro, from packages/a realpath 2, newfstatat 304, nothing listed realpath 2, newfstatat 305, the file is listed
  • Allocations: no flag 17,820 and 17,820. lint --staged with nothing staged 18,140 and 18,139. With one staged file 18,306 and 18,308. With 201 staged files 18,519 and 18,522. The spread between runs is 4.
  • Instructions for each walked file (release, 100,000 files, one thread, median of 5): no flag bun lint -3.0 and bun fmt -0.2, with a main-to-main spread of 2.9 and 0.5. lint --staged -70.5, fmt --staged -67.9, lint --changed=HEAD -70.6. The name test in has replaces a hash of the whole path.
  • Git processes for each run: the same (2 with nothing staged, 3 with a staged file, 4 for --changed and for --staged --fix).
  • Release binary (size): text +7,936 bytes (107,663,568 to 107,671,504), data +0, bss +0.
  • No struct that a run holds for each file changes. git::Files, one for each run, gets names, resolves and real_directories and loses top. Loader gets two fields.

Each clause and the tests that need it. One debug build for each line, with that clause deleted, then test/cli/lint/git.test.ts.

Clause deleted Tests that fail
the search reports a starting directory behind a symlink 6
the search reports a symlink it follows 8
the search reports that an argument named a file 5
bun fmt reports its arguments 4
bun lint hands the report to git::Files 2
a reported symlink makes as_listed resolve 10
an index entry is taken as it is 2
the texts under the paths through symlinks 4
the step up from a directory to the symlink above it 1
the check that a path through a symlink leads to the same directory 1
an index entry keeps its own text 1
the module graph gets the texts after the search 4
the second of two targets with one path is dropped 1
resolves is off after a search without symlinks 0, it saves the calls in the table above
a listed path is taken as it is 0, it saves one realpath for each listed file in a run through a symlink
has asks only for a listed name 0, it saves instructions, and a realpath for each directory in a run through a symlink

Without the order in --fix, the test with two configs is not stable: the staged text was one of two, 3 against 3 times in 6 runs.

Tests. test/cli/lint/git.test.ts: 88 pass. On main 14 of the 17 new tests fail. The other 3 pass on main and pin states that a simpler fix breaks. Also run on the debug build: lint.test.ts (712 pass), config-files.test.ts (842), format.test.ts (1,627), bunfig.test.ts (406), native-rules.test.ts (193), rule-throws.test.ts (51), doctor.test.ts (28), rules.test.ts (23), prettier-rule.test.ts (18). cargo check -p bun_lint_driver for x86_64-pc-windows-msvc, aarch64-apple-darwin and aarch64-unknown-linux-musl. The test file merges with #46479 and #46439 without a conflict (git merge-file).

Self-review: what is not done.

  • A real-path fallback in Disk::read and in its directory listings. It is the complete fix for the import side. This PR covers the symlinks that the run went through. An import through any other symlink reads the file on disk, as on main: a workspace link in node_modules, a link to a file, a second link in a run that names only a file.
  • Windows-only rows with a work tree at a drive root, or with the other letter case of the drive. No code in this change depends on how the root is spelled.
  • A flat eslint.config.* row in the import test. It needs a stand-in plugin package. The row with a directory below the link as an argument takes the same path.

Windows. The new tests run on Windows. They did not run there before the first push, so this PR is a draft until those lanes pass. Git checks a link out as a symlink there only with core.symlinks=true. Other clones have a text file in its place and nothing to follow. If the working directory is not spelled as its final path (a subst drive, a short name), the file is still missed, as on main for a link above the work tree.

Left for later.

…d a symlink to a directory

Git names a file by its real path. `git::Files::as_listed` used a path
in the work tree as the run spells it, so a file that a search or an
argument reaches through a symlink to a directory was never in the
lists: "No staged files to lint", exit 0.

The file search now tells `git::Files` which symlinks to directories it
went through, and whether an argument named a file. Only then does
`as_listed` resolve the directory of a path that is not listed as
spelled, once for each directory and only for a file that is named like
a listed one. A run that goes through no symlink resolves nothing, as
before.

The texts that `--staged` reads from the index are also given to the
`import` rules and the type checker under the paths through those
symlinks, so a file behind one reads what it imports as it will be
committed. The module graph gets them after the search.

`--fix` writes a file that two paths lead to from the path that git
has. A staged file whose directory became a symlink is linted once.
@robobun

robobun commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:23 AM PT - Oct 25th, 2026

✅ @robobun, your commit 7af9f7faf8f69f6174f35b3ba24c747dfcba0054 passed in Build #132461! 🎉


🧪   To try this PR locally:

bunx bun-pr 46857

That installs a local version of the PR into your bun-46857 executable, so you can run:

bun-46857 --bun

@robobun

robobun commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status: draft. The new tests run on Windows for the first time in CI, so this stays a draft until those lanes pass.

Reproduction. On main, with the script in the Notes of the description: in packages/a, bun lint exits 1, and bun lint --changed=HEAD and bun lint --staged print No changed files to lint and No staged files to lint and exit 0. On this branch all three exit 1. test/cli/lint/git.test.ts has 17 new tests. On main 14 of them fail.

PR: #46857

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant