Skip to content

[P2.19] pre-commit pytest --lf runs the FULL suite when a lastfailed test was renamed — --lfnf none doesn't cover it #984

Description

@frankbria

Surfaced while implementing #896 (PR #980), where it blocked a commit for ~15 minutes before I killed it.

Problem

The pytest-check pre-commit hook is designed as a fast-feedback no-op when nothing previously failed:

# .pre-commit-config.yaml
entry: bash -c 'uv run pytest --lf --lfnf none -x; code=$?; [ "$code" -eq 5 ] && code=0; exit $code'

--lfnf none guards exactly one case: the lastfailed cache is empty. It does not guard the case where the cache is non-empty but its node IDs no longer resolve — a test that was renamed, moved, or deleted since the last failing run. In that case pytest falls back to its documented behaviour of running everything.

So the hook silently converts from "run nothing" to "run the entire suite" — and with #979 (full local suite ≈ 4h vs 4m38s on CI) that is a multi-hour pre-commit hook. It also carries -x, so it aborts the commit on the first unrelated failure it happens to hit.

This is self-triggering in the most common workflow: you rename a test, the old name is still in lastfailed from the run where it failed, and your very next commit hangs.

Reproduction

Minimal, self-contained — no repo state needed:

mkdir -p /tmp/gt && cat > /tmp/gt/test_gt.py <<'EOF'
def test_alpha_passes(): assert True
def test_beta_fails(): assert False
def test_gamma_passes(): assert True
EOF

# Produce a genuine lastfailed cache
uv run pytest /tmp/gt -o cache_dir=/tmp/gtcache -q
# -> 1 failed, 2 passed;  lastfailed == {"test_gt.py::test_beta_fails": true}

# Case C — target still exists: correctly selects ONLY the failure
uv run pytest /tmp/gt --lf --lfnf none -o cache_dir=/tmp/gtcache -q
# -> 1 failed                      ← 1 test ran

# Case A — rename the target away, cache now stale
sed -i 's/def test_beta_fails/def test_beta_renamed/' /tmp/gt/test_gt.py
uv run pytest /tmp/gt --lf --lfnf none -o cache_dir=/tmp/gtcache -q
# -> 1 failed, 2 passed            ← ALL 3 tests ran

Case A is the bug: --lfnf none is in effect and the hook still ran the whole directory.

Confirmed at repo scale too. With a stale entry in the cache:

$ uv run pytest tests/cli/ --lf --lfnf none --collect-only -q -o cache_dir=<stale>
483 tests collected            ← whole directory selected

$ uv run pytest tests/cli/ --lf --lfnf none --collect-only -q -o cache_dir=<empty>
run-last-failure: no previously failed tests, deselecting all items.
no tests collected (483 deselected)     ← the intended no-op

Real-world occurrence

In PR #980 I renamed test_server_module_does_not_read_workspace_root
test_only_the_allowlist_parser_reads_workspace_root. The old name stayed in
.pytest_cache/v/cache/lastfailed, and the next git commit hung on
Run last failed tests (fast feedback) for 15+ minutes (killed, not completed).
Clearing the stale entry made the same commit's hooks pass in seconds.

Suggested fix

Options, roughly in increasing order of effort:

  1. Prune unresolvable node IDs before running — cheapest faithful fix. Keeps --lf semantics; only drops entries that can no longer be collected.
  2. Scope the hook to changed files (pass_filenames: true) instead of --lf. Arguably what "fast feedback on what you just touched" should mean anyway, and immune to cache staleness entirely.
  3. Bound it — wrap in timeout <N> and treat a timeout as a pass with a warning. Doesn't fix the cause, but caps the damage and is one line.

My weak preference is (2), with (3) as a cheap belt-and-braces regardless: a pre-commit hook should have a hard ceiling on wall-clock time no matter what it decides to run. (1) preserves current intent most exactly if that matters more.

Whatever the fix, a regression test should assert the Case A behaviour above — that a stale lastfailed entry does not expand the hook's scope.

Notes

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions