Skip to content

test --changed: read NUL-terminated names from git for the whole work tree - #46608

Open
robobun wants to merge 2 commits into
mainfrom
robobun/b951c835/test-changed-exact-git-listing
Open

robobun wants to merge 2 commits into
mainfrom
robobun/b951c835/test-changed-exact-git-listing

Conversation

@robobun

@robobun robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun test --changed loses a changed file, prints --changed: no changed files, nothing to run and exits 0 where bun test exits 1. Cases: a new file outside the cwd (cd packages/a && bun test --changed ../b), diff.relative, a rename, an odd name.
  • append_paths (src/runtime/cli/test/ChangedFilesFilter.rs:630) trims lines that git shapes for the cwd and a person.
  • A listed path longer than the path buffer aborts: panic: range end index 4097 out of range for slice of length 4095.

Fix

  • Each listing passes -z and is split on NUL, untrimmed. git ls-files gets the root as pathspec. -c diff.relative=false -c diff.renames=false replaces -c core.quotePath=off.
  • The root is bun's cwd joined with git rev-parse --show-cdup. A failed listing is an error and an overlong path is skipped. The --watch restart list is NUL-terminated.
  • Verified: test/cli/test/test-changed.test.ts (36 new cases, 34 fail on main), test/cli/watch/.
  • Self-reviewed: 39 concerns raised, 36 addressed. Not taken: GIT_NO_LAZY_FETCH=1 (git 2.39.5 fails with it), clearing GIT_*_PATHSPECS, new tracking issues.

Background

  • With -z git ends each name with a NUL and quotes nothing. The cdup is the ../ path from cwd to root.
  • Considered one git status --porcelain -z. It was built and dropped: it fixes no case and its gain is inside the noise.

Downsides

  • From a subdirectory git walks the whole work tree for untracked files: 6.6 ms to 45.0 ms here.
  • One untracked file anywhere ends the clean-tree skip: 28 ms to 52 ms for 100 test files.
  • A failed listing exits 1 with git's message.
Notes

Where the cases come from

No issue reports them. They come from a fuzz run over bun test --changed against the rule "a test whose result can depend on the changed file must be selected". In each case plain bun test exits 1.

Case main this PR
New file outside the cwd: cd packages/a && bun test --changed ../b, or an untracked packages/b/value.ts that a test in packages/a imports no changed files, exit 0 selected
git config diff.relative true, cwd packages/a, edit in packages/b or in the cwd no changed files, exit 0 selected
git rm --cached config.ts, ignore it, add a copy as config.example.ts, edit config.ts 2 changed files, but no test files are affected, exit 0 selected
Name with a leading blank, a quote, a tab, a CR or an LF no changed files, exit 0 selected
Repository directory whose name ends in a blank or a line break no changed files, exit 0 selected
--changed --watch: a file with a line break in its name changes beside another file its test does not rerun both rerun
A listed path longer than PATH_MAX panic, exit 134 skipped

Seven of these were run on 21 git versions, for --changed and --changed=HEAD: the untracked file outside the cwd, diff.relative, the rename, a quote, a tab, a leading blank, and a root that ends in a blank. Each selects the test on git 1.7.2, 1.7.6, 1.7.9, 1.7.12.4, 1.8.1.6, 1.8.2, 1.8.3.1, 1.8.5.6, 1.9.1, 2.1.4, 2.7.4, 2.11.4, 2.14.5, 2.15.4, 2.17.6, 2.27.1, 2.28.0, 2.39.5, 2.43.0, 2.47.3 and 2.52.0.

Commands that exit 0 on 1.4.3 and behave differently now

Command (cwd packages/a) 1.4.3 Now
Nothing changed below the cwd, one untracked file elsewhere that no test imports no changed files, nothing to run 1 changed file, but no test files are affected, exit 0. The import graph is scanned
The same plus an edit in the cwd 1 changed file, running 1/1 test file 2 changed files, running 1/1 test file
cwd at the root, nothing changed, an untracked repository inside the work tree (a clone or a git worktree add target, ?? wt/ in git status) 1 changed file, but no test files are affected no changed files, nothing to run. The import graph is not scanned
.git/index is cut short no changed files, nothing to run, exit 0. The edit is lost error: --changed: fatal: .git/index: index file smaller than expected, exit 1
Blobless sparse clone, untracked files in a directory whose .gitignore is not local exit 0 Git fetches that .gitignore, as git status does. Exit 1 with git's message when the remote does not answer
GIT_GLOB_PATHSPECS=1 GIT_NOGLOB_PATHSPECS=1 exit 0 error: --changed: fatal: global 'glob' and 'noglob' pathspec settings are incompatible, exit 1

A failed listing is an error and not a run of every test: vitest (vitest-dev/vitest#11430), Jest and bun lint --changed fail the run too.

Measurements (Linux x64, git 2.47.3, release builds of main fec4a88 and of this branch unless stated)

  • Git processes per run: bare 3 to 3, no commits 5 to 5, --changed=<ref> 3 to 3, a bad ref 2 to 2, outside a repository 1 to 1, no test files 0 to 0, plain bun test 0 to 0 (a logging git first in PATH).
  • Allocations inside get_changed_files, 20 changed files: 86 to 100 (gdb count of the allocator entry points, debug builds). The 14 are 11 argv elements, 2 argv joins and the pathspec.
  • Syscalls in the same window: 227 and 227 on main, 222 to 236 over 5 runs of this branch (gdb catch syscall, debug builds). One access for each listed name and 6 calls for the 3 spawns, as before. The spread is futex and mmap. strace is not installed here.
  • Release binary: +1,280 bytes text, data unchanged (size).
  • git ls-files from src/runtime/cli/test of this repository (24,710 files, no untracked file): 6.6 ms to 45.0 ms, median of 30 interleaved rounds. The same command twice: 6.6 ms and 6.2 ms. From the root: 45.9 ms to 46.2 ms.
  • Untracked files outside the cwd, whole run from a leaf with 100 test files: 10,000 files 27 ms to 65 ms, 50,000 files 27 ms to 153 ms, 200,000 files 27 ms to 502 ms. Bun joins and probes each listed name.
  • Clean leaf with 100 test files and 1,000 modules, one untracked file elsewhere: 28 ms to 52 ms, median of 15 interleaved rounds (the same build twice: 28.4 ms and 29.7 ms).
  • perf, valgrind and bloaty are not installed here.

Why git diff and not one git status

The design review picked one git status --porcelain -z --untracked-files=all for a bare --changed (2 git processes in place of 3). It was built with --no-optional-locks and a fallback for git older than 2.18. The self-review ruled it out:

  • It fixes none of the cases above.
  • One git status took 76.4 ms against 34.9 ms + 45.0 ms for git diff + git ls-files, with ranges that overlap.
  • It gives a bare --changed a second listing and a second decoder, and a different list from --changed=HEAD for a file that is staged and then restored.
  • Git older than 2.18 then runs 4 processes in place of 3.

The listing of bun lint --changed

src/lint/driver/git.rs has the same protocol (-z, --show-cdup, diff.relative=false, --no-renames, fatal on failure). This PR is a second copy of it and does not share code. The two differ where the commands differ: bun lint compares with the default branch, ignores submodules and resolves the ref first. One difference is on purpose: git.rs passes --literal-pathspecs, and bun lint --changed then exits 2 under GIT_GLOB_PATHSPECS=1 (global 'literal' pathspec setting is incompatible with all other global pathspec settings). Here the pathspec is only ../ segments or ., so it is passed as it is, and a test pins GIT_GLOB_PATHSPECS=1 and GIT_LITERAL_PATHSPECS=1.

Not covered

Self-review

Three of the 39 points were not taken:

  • GIT_NO_LAZY_FETCH=1 for git ls-files, to keep a partial clone off the network. Git 2.39.5 (Debian 12) then fails with fatal: could not fetch <oid> from promisor remote where the plain call fetches one blob and works. The state is in the table above.
  • Removing the four GIT_*_PATHSPECS variables from the git ls-files call. Only a pair that contradicts itself fails, and git fails on every pathspec then.
  • Tracking issues for the rows under "Not covered". None is opened.

Other open PRs on these files

What was run

Linux x64 only. bun run rust:check-all x86_64-pc-windows-msvc aarch64-apple-darwin passes. The cases with a quote, a tab or a line break in a name, the repository names that end in a blank, the stand-in for git and the restart list skip Windows.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/test/test-changed.test.ts

… tree

`bun test --changed` read git's file list as lines made for a person,
listed from the cwd. It lost a changed file when:
- the file is untracked and outside the cwd (`git ls-files` lists only
  what is below the cwd),
- `diff.relative` is set and the cwd is a subdirectory,
- git reads an edit as a rename and the old name is still on disk,
- the name holds a quote, a tab or a line break (git prints it in C
  quotes unless `-z` is given),
- the name starts with a blank (each line was trimmed),
- the repository path ends in a blank (the root was trimmed).
The run then printed "no changed files, nothing to run" and exited 0.

Every listing now passes `-z` and is split on NUL with no trim. The
root is bun's own cwd joined with `git rev-parse --show-cdup`.
`git ls-files` gets the root as its pathspec. `-c diff.relative=false`
and `-c diff.renames=false` replace `-c core.quotePath=off`, which `-z`
makes redundant.

A listing that fails is now an error. Before, a failed `git ls-files`,
or a failed `git diff` after `git diff HEAD` failed, read as an empty
list.

A path that does not fit the path buffer is skipped. Before, `bun test
--changed` panicked with "range end index 4097 out of range for slice
of length 4095".

The `--changed --watch` restart list is NUL-terminated for the same
reason: a path can hold a line break.
@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Status: open, waiting for CI and review.

Reproduction on 1.4.3 and on main (each case fails the same way, this is the shortest one):

mkdir -p r/packages/a r/packages/b && cd r
echo 'export const value = 1;' > packages/b/value.js
printf 'import { test, expect } from "bun:test";\nimport { value } from "../b/value";\ntest("a", () => { expect(value).toBe(1); });\n' > packages/a/a.test.ts
git init -q -b main . && git add -A && git -c user.name=t -c user.email=t@example.com commit -q -m init
echo 'export const value = 2;' > packages/b/value.ts   # new untracked file, resolves before value.js
cd packages/a
bun test             # 1 fail, exit 1
bun test --changed   # "--changed: no changed files, nothing to run", exit 0

With this PR bun test --changed selects a.test.ts and exits 1. The other cases are in the Notes of the description, and test/cli/test/test-changed.test.ts has one test for each.

@robobun

robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:45 AM PT - Oct 23rd, 2026

@robobun, your commit a985be8 is building: #131392

@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between a248a1d and a985be8.

📒 Files selected for processing: 2
  • src/runtime/cli/test/ChangedFilesFilter.rs
  • test/cli/test/test-changed.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Walkthrough

Watch-trigger and Git changed-file lists now use NUL-delimited paths. Git paths are resolved relative to the worktree root. Tests cover unusual paths, listing failures, and watch-mode reruns.

Changes

Changed-File Path Handling

Layer / File(s) Summary
Watch-trigger path protocol
src/jsc/hot_reloader.rs, src/runtime/cli/test/ChangedFilesFilter.rs, test/cli/test/test-changed.test.ts
The hot reloader appends a NUL terminator after each path, and the CLI parses trigger contents using that terminator. Tests cover restart lists and watch-mode reruns for paths containing newlines.
Worktree-aware Git discovery
src/runtime/cli/test/ChangedFilesFilter.rs, test/cli/test/test-changed.test.ts
Changed and untracked file queries use the parsed worktree root and a worktree-relative pathspec. Git diff settings are explicit. Tests cover invocation locations, pathspec settings, nested repositories, and unusual repository paths.
NUL listings and path validation
src/runtime/cli/test/ChangedFilesFilter.rs, test/cli/test/test-changed.test.ts
Git listings use NUL-delimited names. Listing failures report errors, and path appending uses checked path joining while skipping paths that exceed the buffer. Tests cover listing failures, repositories without commits, and path-length cases.

Possibly related issues

  • oven-sh/bun#46142: Concerns changed-file detection when a Git name-list command succeeds with empty output, a related case in the Git listing flow.

Possibly related PRs

  • oven-sh/bun#46583: Changes bun test --changed path matching for symbolic links, which connects to this PR’s worktree path handling.
  • oven-sh/bun#45938: Changes module-graph scanning for changed paths, a downstream stage of the changed-file selection flow.
  • oven-sh/bun#46262: Changes graph traversal for bun test --changed, a separate stage from this PR’s changed-file input handling.

Suggested reviewers: dylan-conway, jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to a985b

The reviewed changes have no confirmed merge-blocking risk.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly summarizes the main change: bun test --changed now reads NUL-terminated Git paths across the whole work tree.
Description check Passed The description explains the problem, fix, verification, test coverage, performance impact, and known limitations. It uses different headings from the template, but it provides the required informatio…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description explains the problem, fix, verification, test coverage, performance impact, and known limitations. It uses different headings from the template, but it provides the required information and is substantially complete.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/runtime/cli/test/ChangedFilesFilter.rs
Comment thread src/runtime/cli/test/ChangedFilesFilter.rs
…ged file

`git ls-files --others` lists a repository inside the work tree (a
clone, or the target of `git worktree add`) as `dir/` and names no file
in it. The entry matched no source. It made the run print "1 changed
file, but no test files are affected" and scan the import graph on a
clean tree, from the root before and from every directory now that the
listing covers the whole work tree.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed after a985be8 and found no new bugs; the follow-up commit closes the untracked-nested-repository case from the first round. Because this rewrites how --changed discovers the work tree and turns previously silent listing failures into exit 1, a maintainer should still weigh the design tradeoffs the description lists.

What was reviewed:

  • WorkTree::parse on each shape of rev-parse --show-cdup --show-toplevel output: ../ chain joined via the checked join, empty cdup at the root, and the duplicated-root halving when the cwd is outside the tree (both lines come from the same get_git_work_tree() value, so the split is exact; a root with a line break stays intact).
  • -c diff.relative=false / diff.renames=false on git older than 2.28: unknown -c keys are ignored, so the rename and relative cases degrade to the old behavior rather than failing.
  • The NUL terminator constant: exactly one writer (flush_changed_paths_for_reload) and one reader (consume_watch_trigger), both non-Windows; no stale newline consumer remains, and every import in ChangedFilesFilter.rs is still used.
  • The two fallback diffs and ls-files now error through git_failed; the truncated-index and stand-in-git tests exercise each path and assert git's own message.
Extended reasoning...

The PR touches src/runtime/cli/test/ChangedFilesFilter.rs (git invocation and output parsing for bun test --changed), src/jsc/hot_reloader.rs (the watch trigger-file terminator) and test/cli/test/test-changed.test.ts (34 new cases). It spawns git with user-controlled cwd and ref arguments and parses its output, but adds no new injection surface beyond the pre-existing --changed=<ref> argument. The second commit addresses the nested-repository finding posted in the prior run; the remaining open thread is the whole-tree untracked listing cost, which the author documents as an intentional downside rather than fixing. Deferring rather than approving because the change restructures the git flow, hand-parses rev-parse output, and changes failure behavior from silent-empty to exit 1, which is a judgment call for a maintainer; no CODEOWNERS entry covers these files and no third-party objection is outstanding.

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