Skip to content

fix(incremental): add stop_at to find_repo_root / find_project_root (#241)#242

Open
azizur100389 wants to merge 1 commit intotirth8205:mainfrom
azizur100389:fix/find-repo-root-stop-at
Open

fix(incremental): add stop_at to find_repo_root / find_project_root (#241)#242
azizur100389 wants to merge 1 commit intotirth8205:mainfrom
azizur100389:fix/find-repo-root-stop-at

Conversation

@azizur100389
Copy link
Copy Markdown
Contributor

Summary

Two tests in tests/test_incremental.py have been failing on any developer machine whose tmp_path has a git-initialized ancestor:

  • TestFindRepoRoot::test_returns_none_without_git
  • TestFindProjectRoot::test_falls_back_to_start

This PR adds an optional stop_at: Path | None = None parameter to both find_repo_root() and find_project_root(), uses it in the two flaky tests to bound the walk to tmp_path, and adds 3 new positive regression tests to lock in the new parameter's semantics.

Closes #241.

Root cause

pytest's tmp_path lives under $TMPDIR, which on Windows 11 resolves to C:/Users/<user>/AppData/Local/Temp/pytest-of-<user>/.... If the user has ever run git init anywhere under their home directory (dotfiles repo, chezmoi, yadm, a bare git init ~ — all very common on developer machines), then find_repo_root() walking up from tmp_path/no_git finds the ancestor .git and returns C:/Users/<user> instead of None. The same failure mode affects Linux and macOS users with a git-initialized home directory.

find_repo_root previously had no way to bound its ancestor walk — it always climbed to the filesystem root. This is correct production behavior (a nested script inside a git repo should find its repo) but makes the function un-testable in environments where the tmp path sits inside a git-initialized ancestor.

Fix

Add an optional stop_at: Path | None = None parameter to both find_repo_root() and find_project_root():

def find_repo_root(
    start: Path | None = None,
    stop_at: Path | None = None,
) -> Optional[Path]:
    current = start or Path.cwd()
    while current != current.parent:
        if (current / ".git").exists():
            return current
        if stop_at is not None and current == stop_at:
            return None
        current = current.parent
    ...

Semantics:

  • stop_at=None (default): existing behavior, walk all the way to the filesystem root
  • stop_at=<path>: the walk examines stop_at for .git and then stops — it never crosses above the boundary
  • A .git at exactly the boundary is still found (it's examined before the stop check)

find_project_root forwards stop_at to find_repo_root, so both public APIs gain the same testability handle.

Fully backward-compatible. All 7 production callers pass no argument:

  • cli.py (4 call sites)
  • tools/refactor_tools.py (1 call site)
  • tools/_common.py (1 call site)
  • internal call in find_project_root itself

stop_at is also a legitimate production API — callers that want to bound the ancestor walk (multi-repo orchestrators, CI containers with bind-mounted volumes, embedded sandboxes, Docker build contexts) can now do so without resorting to monkeypatching.

Tests updated / added

  1. TestFindRepoRoot::test_returns_none_without_git — now passes stop_at=tmp_path (the actual fix for the flakiness). Docstring explains why the bound is needed.
  2. TestFindRepoRoot::test_stop_at_prevents_escape_to_outer_git [NEW] — positive regression test: when an outer directory has .git, an unbounded walk finds it but a bounded walk with stop_at=inner returns None. Locks in the new parameter's semantics.
  3. TestFindRepoRoot::test_stop_at_finds_git_at_boundary [NEW] — regression test: stop_at must not suppress a .git that lives at the boundary itself. The walk examines stop_at for .git before stopping, so a repo rooted exactly at the boundary is still found.
  4. TestFindProjectRoot::test_falls_back_to_start — now passes stop_at=tmp_path and monkeypatches CRG_REPO_ROOT via monkeypatch.delenv(...) so a developer env var can no longer shadow the test expectation.
  5. TestFindProjectRoot::test_stop_at_forwarded_to_find_repo_root [NEW] — regression test: find_project_root must forward stop_at to find_repo_root, not silently drop it. Also verifies the fall-through path (return start when the bounded walk finds nothing) still works.

Test results

Stage Result
Stage 1 — TestFindRepoRoot + TestFindProjectRoot 8/8 passed (up from 6/8 on unchanged main)
Stage 2 — tests/test_incremental.py full 48 passed, 1 pre-existing failure (test_default_uses_repo_subdir — UTF-8 gitignore bug, fixed on the parallel branch in PR #240)
Stage 3 — adjacent tests/test_tools.py (find_project_root is used there) 62 passed, 15 pre-existing Windows teardown errors
Stage 4 — full suite 737 passed, 6 pre-existing Windows failures — all 6 are covered by the parallel #239/#240 fix PR; none are regressions from this change. Once both PRs merge, all 8 pre-existing Windows failures will be resolved.
Stage 5 — ruff check on incremental.py + test_incremental.py clean

Net baseline effect: this PR independently removes 2 failures (8 → 6). Combined with PR #240, the full Windows baseline goes from 8 failures to 0.

Relationship to #240

PR #240 and this PR are independent and touch disjoint code paths:

  • fix: three Windows-only bugs in v2.3.1 (#239) #240 fixes product bugs in get_data_dir() UTF-8, parse_bytes CRLF handling, and the stale FastMCP API in test_main.py.
  • This PR adds a new stop_at parameter to find_repo_root / find_project_root and fixes the 2 test environment flakiness.

They can be merged in either order — no conflicts, no shared files.

Why this fix is safe

  • stop_at default is None, preserving existing behavior exactly.
  • All 7 production callers pass no argument for stop_at.
  • A new regression test (test_stop_at_finds_git_at_boundary) locks in the "inclusive of boundary" semantics so a future refactor can't accidentally change the behavior.
  • No product API removed or renamed.

…irth8205#241)

Two tests in tests/test_incremental.py have been failing on any
developer machine whose tmp_path has a git-initialized ancestor:

  tests/test_incremental.py::TestFindRepoRoot::test_returns_none_without_git
  tests/test_incremental.py::TestFindProjectRoot::test_falls_back_to_start

Root cause
----------
pytest's tmp_path lives under $TMPDIR, which on Windows 11 resolves to
``C:/Users/<user>/AppData/Local/Temp/pytest-of-<user>/...``.  If the
user has ever run ``git init`` anywhere under their home directory
(dotfiles repo, chezmoi, yadm, a bare ``git init ~`` — all very
common on developer machines), then ``find_repo_root()`` walking up
from ``tmp_path/no_git`` finds the ancestor .git and returns
``C:/Users/<user>`` instead of ``None``.  The same failure mode affects
Linux and macOS users with a git-initialized home directory.

``find_repo_root`` has no way to bound its ancestor walk — it always
climbs to the filesystem root.  This is correct production behavior
(a nested script inside a git repo *should* find its repo) but makes
the function un-testable in environments where the tmp path sits
inside a git-initialized ancestor.

Fix
---
Add an optional ``stop_at: Path | None = None`` parameter to both
``find_repo_root()`` and ``find_project_root()``.  When provided, the
walk examines ``stop_at`` for a .git directory and then stops without
crossing above it.  ``stop_at=None`` preserves the existing "walk to
filesystem root" semantics, so this is a zero-risk backward-compatible
API addition.

All 7 production callers in cli.py (x4), tools/refactor_tools.py (x1),
tools/_common.py (x1), and the internal call in find_project_root pass
no argument for stop_at — their behavior is unchanged.

The two tests above are updated to pass ``stop_at=tmp_path`` so the
walk is bounded to the test sandbox.  ``test_falls_back_to_start`` is
also hardened with ``monkeypatch.delenv("CRG_REPO_ROOT", raising=False)``
so a developer env var can no longer shadow the test's expectation.

stop_at is also a legitimate production API — callers that want to
bound the ancestor walk (multi-repo orchestrators, CI containers with
bind-mounted volumes, embedded sandboxes, Docker build contexts) can
now do so without resorting to monkeypatching.

Tests added / updated
---------------------
1. TestFindRepoRoot::test_returns_none_without_git
   - Now passes ``stop_at=tmp_path`` (the actual fix for the flakiness).
2. TestFindRepoRoot::test_stop_at_prevents_escape_to_outer_git  [NEW]
   - Positive regression test: when an outer directory has .git, an
     unbounded walk finds it but a bounded walk with stop_at=inner
     returns None.  Locks in the new parameter's semantics.
3. TestFindRepoRoot::test_stop_at_finds_git_at_boundary  [NEW]
   - Regression test: stop_at must NOT suppress a .git that lives at
     the boundary itself.  The walk examines stop_at for .git before
     stopping, so a repo rooted exactly at the boundary is still found.
4. TestFindProjectRoot::test_falls_back_to_start
   - Now passes stop_at=tmp_path AND monkeypatches CRG_REPO_ROOT.
5. TestFindProjectRoot::test_stop_at_forwarded_to_find_repo_root  [NEW]
   - Regression test: find_project_root must forward stop_at to
     find_repo_root, not silently drop it.  Also verifies that when
     the bounded walk finds nothing, the fall-through to "return
     start" still works correctly.

Test results
------------
Stage 1 (TestFindRepoRoot + TestFindProjectRoot):
  8/8 passed (up from 6/8 on unchanged main — the 2 previously-flaky
  tests and the 3 new regression tests all green).
Stage 2 (tests/test_incremental.py full): 48 passed, 1 pre-existing
  failure (TestDataDir::test_default_uses_repo_subdir — UTF-8 gitignore
  bug fixed on separate branch in the PR that closes tirth8205#239).
Stage 3 (tests/test_tools.py adjacent — find_project_root used there):
  62 passed, 15 pre-existing Windows file-lock teardown errors.
Stage 4 (full suite): 737 passed, 6 pre-existing Windows failures (all
  covered by the parallel tirth8205#239 fix PR — none are regressions from this
  change), 165 pre-existing Windows teardown errors.  Net effect: this
  PR independently removes 2 failures from the baseline (8 -> 6); once
  both PRs merge, all 8 pre-existing Windows failures will be resolved.
Stage 5 (ruff check on incremental.py + test_incremental.py): clean.

Zero regressions.  Fully backward-compatible.
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.

Test flakiness: find_repo_root walks above test tmp_path and finds ancestor .git

1 participant