Files
obsidian-wiki/tests/test_staging.py
T
Codex e943bb329e
setup / install-leaves-tree-clean (push) Waiting to run
feat(wiki): port upstream knowledge quality improvements
Selectively port source coverage and redirect lint, ignore-aware provenance, staging and trust checks from upstream 055d720. Preserve local archive metadata and adapt source-date and defuddle skill guidance.

Keep the existing skills directory, file index and MCP interfaces. Record per-module upstream baselines and exclusions without migrating vault data or changing services.

Validation: 625 tests passed; git diff --check passed.
2026-11-07 21:49:41 +00:00

228 lines
8.7 KiB
Python

"""Staged-write promotion: inventory, promote, discard, and the conflict path."""
from __future__ import annotations
from pathlib import Path
import pytest
from obsidian_wiki.staging import (
StagingConflict,
StagingError,
discard,
list_staged,
log_decisions,
promote,
revision,
)
def write(vault, rel, body):
path = vault / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
return path
@pytest.fixture
def vault(tmp_path):
write(tmp_path, "concepts/live.md", "---\ntitle: Live\n---\n\noriginal\n")
write(tmp_path, "_staging/concepts/live.md", "---\ntitle: Live\n---\n\nrevised\n")
write(tmp_path, "_staging/concepts/fresh.md", "---\ntitle: Fresh\n---\n\nbrand new\n")
write(tmp_path, "_staging/concepts/live.patch.md", "+ added\n- removed\n")
return tmp_path
def test_listing_classifies_new_update_and_patch(vault):
by_staged = {e.staged_path: e for e in list_staged(vault)}
assert by_staged["_staging/concepts/fresh.md"].kind == "new"
assert by_staged["_staging/concepts/live.md"].kind == "update"
patch = by_staged["_staging/concepts/live.patch.md"]
assert patch.kind == "patch"
# A patch targets the page it is named after, with the .patch infix dropped.
assert patch.live_path == "concepts/live.md"
def test_listing_pins_both_revisions(vault):
entries = {e.staged_path: e for e in list_staged(vault)}
update = entries["_staging/concepts/live.md"]
assert update.staged_revision == revision(vault / "_staging/concepts/live.md")
assert update.live_revision == revision(vault / "concepts/live.md")
# There is no live page for a new one, and that absence is itself the revision.
assert entries["_staging/concepts/fresh.md"].live_revision is None
def test_empty_staging_lists_nothing(tmp_path):
assert list_staged(tmp_path) == []
def test_promoting_a_new_page_moves_it_live(vault):
result = promote(vault, "concepts/fresh.md")
assert result["kind"] == "new"
assert (vault / "concepts/fresh.md").read_text(encoding="utf-8") == (
"---\ntitle: Fresh\n---\n\nbrand new\n"
)
assert not (vault / "_staging/concepts/fresh.md").exists()
def test_promoting_an_update_replaces_the_live_page(vault):
assert promote(vault, "concepts/live.md")["kind"] == "update"
assert "revised" in (vault / "concepts/live.md").read_text(encoding="utf-8")
def test_promotion_preserves_arbitrary_frontmatter_byte_for_byte(vault):
staged = write(
vault,
"_staging/references/odd.md",
"---\ntitle: Odd\nhouse_style: {a: 1}\ntags: [x, y]\n---\n\nbody with spacing\n",
)
original = staged.read_bytes()
promote(vault, "references/odd.md")
# A rename, not a rewrite: nothing normalises frontmatter, prose or links.
assert (vault / "references/odd.md").read_bytes() == original
def test_promotion_accepts_either_path_spelling(vault):
promote(vault, "_staging/concepts/fresh.md")
assert (vault / "concepts/fresh.md").is_file()
def test_promotion_refuses_a_patch(vault):
with pytest.raises(StagingError, match="is a patch"):
promote(vault, "concepts/live.patch.md")
assert (vault / "_staging/concepts/live.patch.md").is_file()
def test_promotion_refuses_when_the_live_page_moved_on(vault):
reviewed = revision(vault / "concepts/live.md")
# An agent rewrites the live page while a human is still reading the diff.
write(vault, "concepts/live.md", "---\ntitle: Live\n---\n\nagent wrote this\n")
with pytest.raises(StagingConflict, match="changed since it was reviewed"):
promote(vault, "concepts/live.md", expected_live_revision=reviewed)
assert "agent wrote this" in (vault / "concepts/live.md").read_text(encoding="utf-8")
assert (vault / "_staging/concepts/live.md").is_file()
def test_promotion_refuses_when_the_staged_file_moved_on(vault):
reviewed = revision(vault / "_staging/concepts/fresh.md")
write(vault, "_staging/concepts/fresh.md", "---\ntitle: Fresh\n---\n\nrestaged\n")
with pytest.raises(StagingConflict):
promote(vault, "concepts/fresh.md", expected_staged_revision=reviewed)
assert not (vault / "concepts/fresh.md").exists()
def test_pin_live_absent_catches_a_page_that_appeared(vault):
"""A bare expected_live_revision=None cannot say "there was no page"."""
write(vault, "concepts/fresh.md", "---\ntitle: Fresh\n---\n\nsomeone got there first\n")
with pytest.raises(StagingConflict, match="was created since"):
promote(vault, "concepts/fresh.md", pin_live_absent=True)
assert "got there first" in (vault / "concepts/fresh.md").read_text(encoding="utf-8")
def test_matching_revisions_promote_normally(vault):
# Keyed by staged path: a page and its patch share one live_path.
entry = {e.staged_path: e for e in list_staged(vault)}["_staging/concepts/live.md"]
promote(
vault,
entry.staged_path,
expected_staged_revision=entry.staged_revision,
expected_live_revision=entry.live_revision,
)
assert "revised" in (vault / "concepts/live.md").read_text(encoding="utf-8")
def test_discard_moves_the_file_to_raw(vault):
result = discard(vault, "concepts/fresh.md")
assert result["raw_path"] == "_raw/rejected-concepts-fresh.md"
assert "brand new" in (vault / result["raw_path"]).read_text(encoding="utf-8")
assert not (vault / "_staging/concepts/fresh.md").exists()
def test_discarding_a_patch_is_labelled_as_one(vault):
assert discard(vault, "concepts/live.patch.md")["raw_path"] == (
"_raw/rejected-patch-concepts-live.md"
)
def test_a_second_rejection_does_not_clobber_the_first(vault):
discard(vault, "concepts/fresh.md")
write(vault, "_staging/concepts/fresh.md", "---\ntitle: Fresh\n---\n\nsecond attempt\n")
assert discard(vault, "concepts/fresh.md")["raw_path"] == "_raw/rejected-concepts-fresh-2.md"
assert "brand new" in (vault / "_raw/rejected-concepts-fresh.md").read_text(encoding="utf-8")
def test_emptied_staging_directories_are_pruned(vault):
for rel in ("concepts/live.md", "concepts/fresh.md"):
promote(vault, rel)
discard(vault, "concepts/live.patch.md")
assert not (vault / "_staging/concepts").exists()
def test_a_traversing_path_is_refused_outright(vault):
with pytest.raises(StagingError, match="escapes the vault"):
promote(vault, "../../etc/passwd")
@pytest.mark.parametrize("rel", ["/etc/passwd", "concepts/absent.md"])
def test_odd_but_contained_paths_fail_as_missing(vault, rel):
"""Anchoring under _staging/ and stripping a leading slash contains these."""
with pytest.raises(StagingError, match="no staged file"):
promote(vault, rel)
@pytest.mark.parametrize(
"rel",
["../concepts/live.md", "_staging/../concepts/live.md", "concepts/../../escape.md"],
)
def test_a_live_page_cannot_be_promoted_as_if_it_were_staged(vault, rel):
"""Inside the vault is not enough — `..` reaches live pages from _staging/."""
with pytest.raises(StagingError, match="not under _staging/"):
promote(vault, rel)
with pytest.raises(StagingError, match="not under _staging/"):
discard(vault, rel)
assert (vault / "concepts/live.md").read_text(encoding="utf-8").endswith("original\n")
def test_missing_staged_file_is_reported(vault):
with pytest.raises(StagingError, match="no staged file"):
promote(vault, "concepts/absent.md")
def test_log_records_one_line_per_batch(vault):
log_decisions(vault, [promote(vault, "concepts/fresh.md"), discard(vault, "concepts/live.md")])
assert "STAGE_COMMIT accepted=1 rejected=1" in (vault / "log.md").read_text(encoding="utf-8")
def test_log_is_silent_when_nothing_happened(vault):
log_decisions(vault, [])
assert not (vault / "log.md").exists()
def test_list_staged_honors_okignore(tmp_path):
write(tmp_path, "_staging/concepts/kept.md", "kept\n")
write(tmp_path, "_staging/private/hidden.md", "hidden\n")
# Staged over an ignored live page: listing it would diff against that page.
write(tmp_path, "notes/old/live.md", "secret\n")
write(tmp_path, "_staging/notes/old/live.md", "revised\n")
write(tmp_path, ".okignore", "_staging/private\n/notes/old\n")
assert [Path(entry.live_path).as_posix() for entry in list_staged(tmp_path)] == ["concepts/kept.md"]
def test_list_staged_does_not_follow_a_symlinked_directory(tmp_path):
vault = tmp_path / "vault"
write(vault, "_staging/concepts/kept.md", "kept\n")
write(tmp_path, "outside/secret.md", "secret\n")
try:
(vault / "_staging" / "link").symlink_to(tmp_path / "outside", target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks unavailable on this platform")
assert [Path(entry.live_path).as_posix() for entry in list_staged(vault)] == ["concepts/kept.md"]