fix(inbox): retire the orphaned index when a directory's last segment goes

Retiring a directory's last concept left its index.md standing, so the
directory survived a rebuild that never creates it -- diff -r reports it as
'Only in ...'. Found by S7 once its fixture was made to actually retire a
segment: with round 2 a superset of round 1, S7 stayed green with retirement
disabled entirely.
This commit is contained in:
Kjell Tore Guttormsen 2026-09-01 00:25:29 +02:00
commit 894ccdf42f

View file

@ -310,21 +310,37 @@ def _owned_concepts_by_source(bundle: Path, profile: BundleProfile) -> dict[str,
return owned
def _retire_stale_segments(bundle: Path, stale: set[str]) -> None:
def _retire_stale_segments(bundle: Path, stale: set[str], profile: BundleProfile) -> None:
"""Delete concepts this round's plan no longer names, and prune what empties.
An emptied directory is deleted too: a scratch rebuild never creates it, so
leaving it behind is exactly the kind of one-sided difference `diff -r`
reports and S7 refuses.
An emptied directory is deleted too, and so is the index left standing in
it. A scratch rebuild writes an index only where a concept lives, so an
orphaned one is exactly the kind of one-sided difference `diff -r` reports
as `Only in ...` -- measured 2026-09-01, when retiring a directory's last
concept left its index behind and S7 caught the divergence.
The emptiness test is "holds no concept anywhere BENEATH it", not "holds no
files": a directory whose own concepts are gone may still be an ancestor of
ones that remain, and deleting its index would break the walk from the root.
"""
for relative in sorted(stale):
target = bundle / relative
target.unlink(missing_ok=True)
(bundle / relative).unlink(missing_ok=True)
suffix = profile.paths.concept_suffix
# Deepest first, so a parent is judged only after its children are gone.
for relative in sorted(stale, key=lambda item: item.count("/"), reverse=True):
parent = (bundle / relative).parent
while parent != bundle and parent.is_dir() and not any(parent.iterdir()):
parent.rmdir()
parent = parent.parent
directory = (bundle / relative).parent
while directory != bundle and directory.is_dir():
if any(
path.is_file() and path.name != profile.index.name
for path in directory.rglob(f"*{suffix}")
):
break
(directory / profile.index.name).unlink(missing_ok=True)
if any(directory.iterdir()):
break
directory.rmdir()
directory = directory.parent
def _check_segment_path(path: str) -> str:
@ -711,7 +727,9 @@ def process_inbox(
# Round N owns exactly what round N's plan names. Everything this
# document owned before and does not now is retired HERE, after the
# writes, so a failure above leaves the previous round intact.
_retire_stale_segments(bundle, owned_by_source.get(path.name, set()) - set(targets))
_retire_stale_segments(
bundle, owned_by_source.get(path.name, set()) - set(targets), profile
)
# §6 index — the last disk mutation, and only when something was written.
if persisted: