2026-10-08 18:46:12 +02:00
|
|
|
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()
|
2026-10-10 03:41:36 +02:00
|
|
|
ok = ["app.py", "src/main.go", ".env", ".env.production", "Makefile", "pkg/package.json", ".gitignore",
|
|
|
|
|
# user data: images, archives, databases, documents (backed up as raw files)
|
|
|
|
|
"photos/vacation.jpg", "img/logo.png", "backup/site.tar.gz", "data/archive.zip",
|
|
|
|
|
"app.db", "app.sqlite3", "report.pdf", "song.mp3", "clip.mp4"]
|
2026-10-08 18:46:12 +02:00
|
|
|
for p in ok:
|
|
|
|
|
assert f.path_reason(PurePath(p)) is None, p
|
2026-10-10 03:41:36 +02:00
|
|
|
journals = ["app.db-wal", "app.db-shm", "app.sqlite-journal", "data/x-wal"]
|
|
|
|
|
for p in journals:
|
|
|
|
|
assert f.path_reason(PurePath(p)) == "database-journal", p
|
2026-10-08 18:46:12 +02:00
|
|
|
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",
|
2026-10-10 03:41:36 +02:00
|
|
|
"draft.temp": "ignored-extension",
|
|
|
|
|
"x.kate-swp": "ignored-extension",
|
|
|
|
|
"~$lock.docx": "temporary-file",
|
|
|
|
|
"#autosave#": "temporary-file",
|
2026-10-08 18:46:12 +02:00
|
|
|
"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
|
2026-10-10 03:41:36 +02:00
|
|
|
assert f.size_reason(10_485_760) is None
|
|
|
|
|
assert f.size_reason(10_485_761) == "file-too-large"
|
2026-10-08 18:46:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-08 20:24:04 +02:00
|
|
|
def test_directory_patterns():
|
|
|
|
|
f = Filters(patterns=("app/data/uploads",))
|
|
|
|
|
assert f.skip_dir("uploads", "app/data/uploads")
|
|
|
|
|
assert not f.skip_dir("uploads", "other/uploads")
|
|
|
|
|
assert f.path_reason(PurePath("app/data/uploads/a/b.txt")) == "ignored-pattern"
|
|
|
|
|
assert f.path_reason(PurePath("app/data/config.json")) is None
|
|
|
|
|
|
|
|
|
|
|
2026-10-08 18:46:12 +02:00
|
|
|
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"
|