Implement versiond M1: inotify monitor, versioning, diff, restore, systemd install

FastAPI service on 127.0.0.1:9922 that monitors user-chosen directories
with pruned inotify watches, stores versions in a local content-addressed
spool indexed in SQLite, coalesces bursts (first + last), and offers
history, diff and dry-run-first bulk restore. Includes a CLI with a
systemd user unit installer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
retoor
2026-10-08 18:46:12 +02:00
co-authored by Claude Opus 5.5
parent 3455499cea
commit ddf09bea88
17 changed files with 2932 additions and 5 deletions
+100
View File
@@ -0,0 +1,100 @@
import asyncio
from pathlib import PurePath
from versiond.db import Database
from versiond.diff import hunks, unified
from versiond.filters import Filters
from versiond.ingest import Coalescer, Repository
from versiond.store import BlobStore
def test_filters_paths():
f = Filters()
ok = ["app.py", "src/main.go", ".env", ".env.production", "Makefile", "pkg/package.json", ".gitignore"]
for p in ok:
assert f.path_reason(PurePath(p)) is None, p
rejected = {
"node_modules/x/index.js": "ignored-directory",
".git/config": "ignored-directory",
"src/__pycache__/a.cpython-312.pyc": "ignored-directory",
"a.pyc": "ignored-extension",
".bashrc_local": "hidden-file",
"notes.txt~": "temporary-file",
"main.py.swp": "ignored-extension",
"venv/lib/x.py": "ignored-directory",
"target/debug/build.rs": "ignored-directory",
"4913": "temporary-file",
"src/pkg.egg-info/PKG-INFO": "ignored-directory",
}
for p, reason in rejected.items():
assert f.path_reason(PurePath(p)) == reason, p
assert f.size_reason(204_800) is None
assert f.size_reason(204_801) == "file-too-large"
assert f.content_reason(b"abc\x00def") == "binary-content"
def test_filter_patterns():
f = Filters(patterns=("*.log", "secret/*"))
assert f.path_reason(PurePath("x/debug.log")) == "ignored-pattern"
assert f.path_reason(PurePath("secret/key.txt")) == "ignored-pattern"
assert f.path_reason(PurePath("src/key.txt")) is None
def test_diff_unified():
a = b"one\ntwo\nthree\n"
b = b"one\n2\nthree\nfour\n"
out = unified(hunks(a, b), "a", "b")
assert "-two\n+2\n" in out and "+four\n" in out
assert hunks(b"x y\n", b"x y\n", ignore_whitespace=True) == []
def _repo(tmp_path):
db = Database(tmp_path / "index.sqlite")
return Repository(db, BlobStore(tmp_path / "spool"), Filters())
def test_coalescer_keeps_first_and_last(tmp_path):
repo = _repo(tmp_path)
path = str(tmp_path / "f.py")
async def run():
c = Coalescer(repo, window=0.2, max_hold=1.0, per_hour=100)
statuses = [c.submit(path, f"v{i}\n".encode(), "test", "edit")["status"] for i in range(6)]
await asyncio.sleep(0.5)
return statuses
statuses = asyncio.run(run())
assert statuses[0] == "committed"
assert set(statuses[1:]) == {"coalesced"}
file = repo.db.file_by_path(path)
contents = [repo.blobs.get(v["blob_sha256"]) for v in reversed(repo.db.history(file["id"]))]
assert contents == [b"v0\n", b"v5\n"]
def test_coalescer_rate_limit_keeps_newest(tmp_path):
repo = _repo(tmp_path)
path = str(tmp_path / "f.py")
async def run():
c = Coalescer(repo, window=0.05, max_hold=0.1, per_hour=2)
c.submit(path, b"a", "t", "e")
await asyncio.sleep(0.1)
c.submit(path, b"b", "t", "e")
await asyncio.sleep(0.2)
status = c.submit(path, b"c", "t", "e")["status"]
await asyncio.sleep(0.2)
flushed = c.flush()
return status, flushed
status, flushed = asyncio.run(run())
assert status == "coalesced"
assert flushed == 1 # over the limit it waits, and shutdown still stores it
file = repo.db.file_by_path(path)
assert repo.blobs.get(repo.db.latest_version(file["id"])["blob_sha256"]) == b"c"
def test_dedupe_unchanged(tmp_path):
repo = _repo(tmp_path)
path = str(tmp_path / "f.py")
assert repo.commit(path, b"x", "t", "e")["status"] == "committed"
assert repo.commit(path, b"x", "t", "e")["status"] == "unchanged"