From 5aafd4e61b01ae10e1ec73ba4cb6645c25cdbfa1 Mon Sep 17 00:00:00 2001 From: Francisco Javier Serna Barragan <148145893+FranciscoJSBarragan@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:11:28 -0600 Subject: [PATCH] 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. --- graphify/watch.py | 34 ++++++++++++++++++++++++++++++ tests/test_watch.py | 50 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/graphify/watch.py b/graphify/watch.py index ba3f09b17..94d4e8b20 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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 diff --git a/tests/test_watch.py b/tests/test_watch.py index fb9868677..f0a54690f 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -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."""