diff --git a/graphify/cache.py b/graphify/cache.py index 8fb168ce3..a12bcaeed 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -379,6 +379,20 @@ def _flush_stat_index() -> None: continue dk = _stat_key_to_relative(k, _stat_index_anchor) if _stat_index_anchor is not None else k on_disk[dk] = v + # Never resurrect a corpus that was deleted while graphify was running + # (#2974): a hook-launched `graphify update . &` in a short-lived worktree + # outlives `git worktree remove`, and an unconditional `mkdir -p` here + # rebuilt the dead path as a husk holding nothing but this index. The + # index is a pure optimisation, so when its root is gone it is simply not + # written. Creating graphify-out/cache/ under a root that still exists is + # unchanged (a first run writes the index before anything else does). + try: + if not _stat_index_root.is_dir(): + _stat_index_dirty = False + return + except OSError: + _stat_index_dirty = False + return try: p.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=p.parent, prefix="stat-index.", suffix=".tmp") diff --git a/tests/test_stat_index_husk.py b/tests/test_stat_index_husk.py new file mode 100644 index 000000000..194a418ce --- /dev/null +++ b/tests/test_stat_index_husk.py @@ -0,0 +1,69 @@ +"""The atexit stat-index flush must not resurrect a deleted directory (#2974). + +A post-commit hook runs `graphify update . &` in a short-lived worktree; the +branch merges and `git worktree remove` deletes the tree while the rebuild +is still running. The exit-time flush then `mkdir -p`'d the dead path back +into existence, leaving a husk holding nothing but +`graphify-out/cache/stat-index.json` — 81 of them over a few weeks. +""" +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from graphify import cache + + +@pytest.fixture(autouse=True) +def _fresh_index(): + def reset(): + cache._stat_index_root = None + cache._stat_index_anchor = None + cache._stat_index = {} + cache._stat_index_dirty = False + reset() + yield + reset() + + +def _dirty(corpus: Path) -> Path: + f = corpus / "a.md" + f.write_text("# hello\nbody\n", encoding="utf-8") + cache.file_hash(f, corpus) # loads + dirties the index for this corpus + assert cache._stat_index_dirty + return f + + +def test_a_corpus_deleted_mid_run_stays_deleted(tmp_path): + corpus = tmp_path / "husk-race-corpus" + corpus.mkdir() + _dirty(corpus) + shutil.rmtree(corpus) + cache._flush_stat_index() # what atexit does + assert not corpus.exists(), "the flush resurrected the deleted corpus" + assert not cache._stat_index_dirty # nothing left pending for a second attempt + + +def test_a_redirected_cache_root_that_vanished_is_not_recreated(tmp_path): + corpus = tmp_path / "c" + corpus.mkdir() + elsewhere = tmp_path / "out" + elsewhere.mkdir() + f = corpus / "a.md" + f.write_text("x\n", encoding="utf-8") + cache.file_hash(f, corpus, cache_root=elsewhere) + shutil.rmtree(elsewhere) + cache._flush_stat_index() + assert not elsewhere.exists() + + +def test_the_index_is_still_written_for_a_live_run(tmp_path): + """A first run writes the index before graphify-out/ exists at all; that + stays as it was — the root is live, so creating cache/ under it is fine.""" + corpus = tmp_path / "c" + corpus.mkdir() + _dirty(corpus) + cache._flush_stat_index() + assert (corpus / "graphify-out" / "cache" / "stat-index.json").is_file()