Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions graphify/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
69 changes: 69 additions & 0 deletions tests/test_stat_index_husk.py
Original file line number Diff line number Diff line change
@@ -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()
Loading