Skip to content

Test the cache arms that only a real filesystem reaches - #179

Merged
blooop merged 2 commits into
mainfrom
test/integration-edges
Aug 11, 2026
Merged

blooop merged 2 commits into
mainfrom
test/integration-edges

Conversation

@blooop

@blooop blooop commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Three recovery seams, none reachable from a unit test: what decides the answer in each is the state of a real directory or the exit status of a real git clone, so a mocked subprocess picks the outcome before the code does.

repo_manager, mid-disaster — test/integration/test_repo_manager_recovery.py

  • A bare clone on disk with no metadata record is adopted, not cloned over. Proved with a ref the remote does not have, so "the same clone" is a fact about the directory's contents rather than its mtime.
  • A directory with no HEAD is cleared and replaced.
  • A failed clone leaves no directory and no record — a record naming a clone that is not there is every caller's answer to "is the cache ready".
  • The failed-clone cleanup does not take the repo lock file with it. Widening that rmtree to the parent unlinks a lock the call is still holding, which is the classic self-defeating move: the holder keeps an inode nobody can see and the next arrival locks a fresh file and walks past it.
  • The default branch is read off the clone, pinned against a real master-headed remote — _get_default_branch falls back to main through three excepts, so a repo that really is master and one the function could not read otherwise give the same answer.

Stated in the file rather than implied: for every failure a test can arrange, git removes the destination it created, so the rmtree in the except is a backstop those tests do not pin. Two mutations of it stayed green and are documented as such instead of being papered over.

migration's three OSError arms — test/test_worktree_migration.py

Driven by real permissions and a real inode type, not a patched os.rename, because what is being tested is that the arm catches what the OS actually raises.

  • A refused rename costs one directory, not the run: the clone stays under its old name and its record still points at it.
  • An unscannable corner of the cache is reported and survived — the renames already on disk are kept and the version header is still written.
  • A listing that cannot be written degrades to an instruction the user can follow instead of naming a path that is not there.

migration.py goes 90% → 100%.

The clone race itself — test/integration/test_clone_race.py

test_locks.py covers the lock. Nothing covered the thing being excluded, which needs one real git clone --bare racing another in another interpreter — threads share one file description and one GIL, so a threaded version tests a lock that is not the one shipping.

  • Simultaneous start: one clone, both callers handed it, neither handed an error.
  • Staged: the loser loads its metadata before the winner writes any, so it wakes to a .bare its own records have never heard of — the cross-process adoption case, reproduced deterministically rather than by deleting a record. A marker written inside the clone under the lock proves the loser did not destroy it.

Both fail on every run with ensure_repo's lock removed (checked 5×), and pass on every run with it (checked 8×).

Verification

Every arm was checked by mutation — 13 mutations applied to repo_manager.py, migration.py and the lock, each confirmed to turn exactly the intended test red. Two that stayed green are documented in the test file as unreachable rather than left as a silent gap.

Coverage: repo_manager.py 80% → 85%, migration.py 90% → 100%, total 94% → 95%. locks.py stays at 76% for the known reason — coverage does not follow subprocesses, and its tests are subprocesses.

pylint 10.00/10 across all five environments (default, py310–py313); ruff and format clean.

Summary by Sourcery

Add integration and filesystem-driven tests that exercise repository cache recovery, migration failure handling, and clone race behaviour under real git and OS conditions.

Tests:

  • Add filesystem-permission and write-failure tests that drive migration OSError paths via real directories and confirm non-fatal, user-notified behaviour.
  • Introduce integration tests for RepositoryManager recovery when clones or metadata are missing, partial, or unwritable, ensuring safe adoption, cleanup, and default-branch detection from real clones.
  • Add an integration test suite that reproduces cross-process clone races to verify lock behaviour and shared cache outcomes without deleting or corrupting the winning clone.
  • Increase coverage for repo_manager and migration modules by validating cache failure and recovery seams that unit tests could not reach with mocks.

Three seams, all of them recovery paths, none reachable from a unit test
because what decides the answer is the state of a real directory or the exit
status of a real `git clone`. A mocked subprocess picks the outcome before the
code does.

**repo_manager, mid-disaster** (new integration file). A bare clone on disk
with no metadata record is adopted, not cloned over -- proved by a ref the
remote does not have, which a re-clone would lose. A directory with no HEAD is
cleared and replaced. A failed clone leaves no record and, crucially, does not
take the repo lock file with it: widening that cleanup to the parent directory
unlinks a lock this call is still holding, which hands the next arrival a lock
nobody is excluded by.

Worth stating because the tests say so rather than implying it: git removes the
destination it created for every failure a test can arrange, so the `rmtree`
in the except is a backstop those tests do not pin. What they pin is the state
the cache is left in, whoever did the removing.

**migration's three OSError arms**, driven by real permissions and a real inode
type rather than a patched `os.rename`: a refused rename costs one directory
and not the run, an unscannable corner of the cache is reported and survived,
and a listing that cannot be written degrades to an instruction the user can
follow instead of a path that is not there. migration.py goes 90% -> 100%.

**The clone race itself**, in two real interpreters. test_locks.py covers the
lock; nothing covered the thing being excluded, which needs one real
`git clone --bare` racing another. Both tests fail on every run with the lock
removed. The staged one reproduces the cross-process adoption case on purpose:
the loser loads its metadata before the winner writes any, so it wakes to a
`.bare` its own records have never heard of.

Every arm here was checked by mutation.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @blooop, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds integration and filesystem-driven tests to cover repository cache recovery, worktree migration OSError paths, and cross-process clone races, improving confidence in failure handling and lock behavior without changing production logic.

File-Level Changes

Change Details Files
Add filesystem-driven tests for worktree migration OSError branches to ensure permission and inode-related failures are handled without data loss and with usable user messaging.
  • Introduce TestWhatTheFilesystemRefuses test class exercising three OSError arms via real permission and inode scenarios instead of mocking os.rename.
  • Verify refused renames leave repositories and metadata consistent while reporting failures to stderr.
  • Ensure unreadable cache subdirectories are reported but do not prevent successful migrations or version header updates.
  • Validate that failures to write orphaned-id and unmigrated-clone listings degrade to clear human instructions rather than unusable paths, while still reporting counts.
test/test_worktree_migration.py
Add integration tests for RepositoryManager disaster-recovery behavior when cache state and metadata diverge, including partial clones, failed clones, default-branch detection, and unwritable cache directories.
  • Add helpers to inspect HEAD and refs in bare repositories via real git commands.
  • Cover adoption of existing bare clones with missing metadata records, ensuring metadata reconstruction and default-branch detection from the clone.
  • Cover clearing and replacing partial clones that lack HEAD while preserving correct default branch and refs.
  • Verify failed clones leave no bare directory and no metadata record, and that cleanup does not delete the lock file or its parent directory.
  • Test that a subsequent ensure_repo call after a failure can successfully clone into a cleaned-up cache.
  • Test accurate default-branch detection for master-headed remotes rather than always defaulting to main.
  • Cover behavior when metadata records exist but underlying clone directories are missing, ensuring repo_exists/get_repo reflect filesystem reality and that ensure_repo reclones.
  • Ensure clones into unwritable cache directories raise without writing metadata or leaving partial clones.
  • Document backstop rmtree behavior and lock invariants in integration tests rather than unit tests.
test/integration/test_repo_manager_recovery.py
Add cross-process integration tests that exercise the git clone race protected by the repository lock to ensure single-clone creation and safe adoption in concurrent scenarios.
  • Introduce a Python driver script string that uses RepositoryManager, MetadataStorage, WorktreeConfig, and hold_lock to perform real bare clones and ensure_repo calls under different roles (winner, racer, loser).
  • Use subprocess-based fixtures to coordinate two driver processes via filesystem flags and timeouts, ensuring realistic concurrency independent of threads and the GIL.
  • Test simultaneous start of two racers against the same repo, asserting both processes receive the same bare path, the clone is healthy, only expected cache files exist, and metadata contains the single repository entry.
  • Test a staged race where the loser loads metadata before the winner saves, asserting the loser blocks on the lock, later adopts the winner’s clone instead of overwriting or deleting it, preserves a marker file inside the clone, and leaves metadata consistent.
  • Bound clone operations with CLONE_TIMEOUT to avoid hung CI runs while still validating lock behavior and race resolution.
test/integration/test_clone_race.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.63%. Comparing base (9339d18) to head (df8f194).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #179      +/-   ##
==========================================
+ Coverage   93.95%   94.63%   +0.67%     
==========================================
  Files          21       21              
  Lines        2565     2570       +5     
==========================================
+ Hits         2410     2432      +22     
+ Misses        155      138      -17     
Files with missing lines Coverage Δ
devlaunch/worktree/migration.py 100.00% <100.00%> (+9.90%) ⬆️
devlaunch/worktree/repo_manager.py 85.33% <100.00%> (+5.06%) ⬆️

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

… hid them

Ten findings from the review of this PR. Two were production defects the new
tests walked past, and one of my tests was actively concealing its own.

**`_clone_dirs` abandoned the rest of the cache on the first refusal.** The
whole three-level walk sat under one `try`, and owners are walked in sorted
order, so an unreadable `acme/` ended the scan for every owner after it: no
notice, no listing file, and the version header still advanced, so those
unmigrated clones -- which may hold uncommitted work -- were abandoned
permanently. Caught at each level now. My test could not see it because I named
the unreadable directory `someone-else`, which sorts *after* `blooop`; it is
`aaa-corp` now and the name is load-bearing enough to say so in the comment.

**`_get_default_branch` truncated slashed branch names.** `.split("/")[-1]`
on `refs/heads/release/1.0` records `1.0`, a ref the repository does not have,
as the branch every later operation targets. The prefix is stripped instead. The
class covering this tested only `master`, which has no slash and passes either
way; it is parametrized over `master`, `release/1.0` and `feature/auth`.

**The simultaneous-start race test greened 27% of the time with no lock at all.**
Measured over 30 runs with the flock removed: 8 passed, because when the two
processes happen to serialize by luck every assertion still holds -- and
serialization gets *more* likely on the loaded single-core runner where the lock
matters most. A guard that greens a quarter of the time under the defect it
names is worse than none. It is gone, replaced by a second staged test that
pins the contention *notice*. Both remaining tests now fail on 12 runs out of 12
with the lock removed.

And four smaller ones:

- the unwritable-cache test died in `hold_lock`'s mkdir and never reached
  `clone_repo`, so both its assertions were true before the call as well as
  after; it blocks only the clone now, and its `pytest.raises` names the error
  instead of accepting any `OSError`.
- the adoption test asserted `default_branch == "main"`, which is also what
  `_get_default_branch` returns having read nothing. Its remote is master-headed.
- `finally: proc.kill()` neither reaped the driver nor drained it, orphaning the
  `git clone` it had started into a tmp_path pytest was deleting. `stop()` kills
  the process group and drains.
- `await_flags` now watches the drivers, so one that dies at import fails in a
  second with its traceback instead of after a 60s timeout without it.

The chmod-based refusals no longer guess from `geteuid`: `refuses_writes` and
`refuses_reads` apply the mode and then *attempt the forbidden operation*,
skipping where the filesystem does not enforce it -- Docker Desktop and Colima
bind mounts store the mode and ignore it, which a uid check does not notice.

One finding is pinned rather than fixed: a refused rename still advances the
schema header, so those records keep their pre-#64 workspace ids and no later
run revisits them, which means `dl ... rm` can never find them again. The fix
is a choice between two imperfect options and belongs to whoever makes it
deliberately. Filed as #180, with the test that should go red when it lands.

Every fix re-verified by mutation.
@blooop
blooop merged commit b438d73 into main Aug 11, 2026
11 checks passed
@blooop
blooop deleted the test/integration-edges branch August 11, 2026 16:08
@blooop blooop mentioned this pull request Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant