Fix rebuilds from deleted hook cwd

What changed
- Stabilize relative rebuild execution before graphify-out queue/lock setup.
- Recover from deleted transient hook CWDs when GRAPHIFY_REPO_ROOT points at the repository root.
- Fail cleanly when the current working directory is gone and no repo root fallback is available.
- Add regression coverage for both fallback and clean-failure paths.

Why
- Detached post-commit/post-checkout rebuilds can inherit a transient CWD that is deleted before the background process starts.
- _rebuild_code previously used relative graphify-out paths before Path.cwd()/watch_path resolution could be handled, causing FileNotFoundError: [Errno 2] No such file or directory.

Validation
- .venv/bin/pytest tests/test_watch.py -q: 54 passed, 2 skipped.
- .venv/bin/ruff check graphify/watch.py tests/test_watch.py: passed.
- .venv/bin/python -m py_compile graphify/watch.py tests/test_watch.py: passed.
- env -u PYTHONPATH -u PYTHONHOME PYTHONHASHSEED=0 .venv/bin/pytest -q: 2904 passed, 30 skipped.

Notes
- Full suite without PYTHONHASHSEED=0 exposed an unrelated ordering-sensitive labeling test; with the deterministic hash seed used by graphify hooks, the suite passes.
This commit is contained in:
Francisco Javier Serna Barragan
2026-07-06 09:11:28 -06:00
committed by safishamsi
parent 6c33e3aad7
commit e5044c3253
2 changed files with 84 additions and 0 deletions
+34
View File
@@ -657,6 +657,37 @@ def _json_text(data: dict) -> str:
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def _stabilize_rebuild_cwd(watch_path: Path) -> bool:
"""Ensure relative rebuild paths have a usable CWD before queue/lock setup.
Detached git hooks can inherit a transient working directory that is deleted
before the background rebuild starts. In that state Path.cwd(),
Path('.').resolve(), and relative graphify-out mkdirs raise FileNotFoundError
before the normal rebuild error handling can run. Hooks that know the repo
root export GRAPHIFY_REPO_ROOT so the rebuild can recover by chdir'ing there.
"""
if watch_path.is_absolute():
return True
repo_root = os.environ.get("GRAPHIFY_REPO_ROOT", "").strip()
if repo_root and Path(repo_root).is_dir():
try:
os.chdir(repo_root)
return True
except OSError:
pass
try:
Path.cwd()
return True
except FileNotFoundError:
print(
"[graphify watch] Rebuild failed: current working directory "
"no longer exists and GRAPHIFY_REPO_ROOT is not set."
)
return False
def _rebuild_code(
watch_path: Path,
*,
@@ -690,6 +721,9 @@ def _rebuild_code(
Returns True on success, False on error or skipped-due-to-lock.
"""
if not _stabilize_rebuild_cwd(watch_path):
return False
out = watch_path / _GRAPHIFY_OUT
if acquire_lock:
# #1059: incremental (changed_paths is not None) hooks must not drop
+50
View File
@@ -176,6 +176,56 @@ def test_graphify_root_preserves_absolute_when_user_supplied(tmp_path):
)
def test_rebuild_code_deleted_cwd_without_repo_root_returns_false(tmp_path, monkeypatch, capsys):
"""Detached hooks can inherit a CWD that no longer exists.
Without GRAPHIFY_REPO_ROOT, the rebuild should fail cleanly before creating
relative graphify-out queue/lock files.
"""
from graphify.watch import _rebuild_code
old_cwd = Path.cwd()
gone = tmp_path / "gone"
gone.mkdir()
monkeypatch.delenv("GRAPHIFY_REPO_ROOT", raising=False)
os.chdir(gone)
gone.rmdir()
try:
assert _rebuild_code(Path("."), changed_paths=[Path("lib.py")]) is False
finally:
os.chdir(old_cwd)
out = capsys.readouterr().out
assert "current working directory no longer exists" in out
def test_rebuild_code_deleted_cwd_uses_graphify_repo_root(tmp_path, monkeypatch):
"""GRAPHIFY_REPO_ROOT lets detached hook rebuilds recover from a deleted CWD."""
from graphify.watch import _rebuild_code
old_cwd = Path.cwd()
corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "lib.py").write_text("def f(): pass\n", encoding="utf-8")
gone = tmp_path / "gone"
gone.mkdir()
monkeypatch.setenv("GRAPHIFY_REPO_ROOT", str(corpus))
os.chdir(gone)
gone.rmdir()
try:
assert _rebuild_code(
Path("."),
changed_paths=[Path("lib.py")],
no_cluster=True,
) is True
assert Path.cwd().resolve() == corpus.resolve()
assert (corpus / "graphify-out" / "graph.json").exists()
finally:
os.chdir(old_cwd)
def test_rebuild_code_evicts_nodes_from_deleted_files(tmp_path):
"""#1007: graphify update (_rebuild_code with no changed_paths) must remove
nodes and edges from files deleted since the last run."""