165 lines
5.7 KiB
Python
165 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Give tracked files stable mtimes derived from their immutable Git blobs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePath
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
|
|
|
|
WINDOWS_TICK_NANOSECONDS = 100
|
|
|
|
|
|
def tracked_entries(root: Path) -> list[tuple[bytes, Path]]:
|
|
output = subprocess.run(
|
|
["git", "ls-files", "--stage", "-z"],
|
|
cwd=root,
|
|
check=True,
|
|
stdout=subprocess.PIPE,
|
|
).stdout
|
|
entries: list[tuple[bytes, Path]] = []
|
|
for record in output.split(b"\0"):
|
|
if not record:
|
|
continue
|
|
metadata, separator, raw_path = record.partition(b"\t")
|
|
if not separator:
|
|
raise RuntimeError("git ls-files produced a record without a path")
|
|
mode, _object_id, stage = metadata.split(b" ")
|
|
if stage != b"0":
|
|
raise RuntimeError(
|
|
f"cannot normalize an unmerged index entry: {os.fsdecode(raw_path)}"
|
|
)
|
|
relative = PurePath(os.fsdecode(raw_path))
|
|
if relative.is_absolute() or ".." in relative.parts:
|
|
raise RuntimeError(f"unsafe tracked path: {relative}")
|
|
entries.append((mode, root.joinpath(*relative.parts)))
|
|
return entries
|
|
|
|
|
|
def content_identity(mode: bytes, path: Path) -> str:
|
|
digest = hashlib.sha256(mode + b"\0")
|
|
if path.is_symlink():
|
|
digest.update(os.fsencode(os.readlink(path)))
|
|
else:
|
|
with path.open("rb") as source:
|
|
while chunk := source.read(1024 * 1024):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def default_state_path(root: Path) -> Path:
|
|
return root / "target" / ".metacrate-mtimes.json"
|
|
|
|
|
|
def read_state(path: Path) -> dict[str, dict[str, int | str]]:
|
|
if not path.is_file():
|
|
return {}
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
if value.get("schema") != 1 or not isinstance(value.get("files"), dict):
|
|
raise RuntimeError(f"invalid tracked-mtime state: {path}")
|
|
return value["files"]
|
|
|
|
|
|
def write_state(path: Path, files: dict[str, dict[str, int | str]]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
temporary.write_text(
|
|
json.dumps({"schema": 1, "files": files}, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
os.replace(temporary, path)
|
|
|
|
|
|
def normalize(root: Path, state_path: Path, check: bool) -> int:
|
|
previous = read_state(state_path)
|
|
current: dict[str, dict[str, int | str]] = {}
|
|
changed = 0
|
|
prior_max = max(
|
|
(int(value["mtime_ns"]) for value in previous.values()), default=0
|
|
)
|
|
wall_time = time.time_ns() // WINDOWS_TICK_NANOSECONDS * WINDOWS_TICK_NANOSECONDS
|
|
now = max(wall_time, prior_max + WINDOWS_TICK_NANOSECONDS)
|
|
for mode, path in tracked_entries(root):
|
|
if not (path.is_file() or path.is_symlink()):
|
|
continue
|
|
relative = path.relative_to(root).as_posix()
|
|
identity = content_identity(mode, path)
|
|
prior = previous.get(relative)
|
|
expected = (
|
|
int(prior["mtime_ns"])
|
|
if prior is not None and prior.get("identity") == identity
|
|
else now
|
|
)
|
|
current[relative] = {"identity": identity, "mtime_ns": expected}
|
|
actual = os.stat(path, follow_symlinks=False).st_mtime_ns
|
|
if actual == expected:
|
|
continue
|
|
if check:
|
|
raise RuntimeError(f"tracked mtime is not normalized: {path}")
|
|
os.utime(path, ns=(expected, expected), follow_symlinks=False)
|
|
changed += 1
|
|
if not check:
|
|
write_state(state_path, current)
|
|
return changed
|
|
|
|
|
|
def self_test() -> None:
|
|
with tempfile.TemporaryDirectory(prefix="metacrate-mtimes-") as directory:
|
|
root = Path(directory)
|
|
subprocess.run(["git", "init", "--quiet"], cwd=root, check=True)
|
|
subprocess.run(
|
|
["git", "config", "user.email", "ci@example.invalid"], cwd=root, check=True
|
|
)
|
|
subprocess.run(
|
|
["git", "config", "user.name", "MetaCrate CI"], cwd=root, check=True
|
|
)
|
|
first = root / "first.txt"
|
|
second = root / "second.txt"
|
|
first.write_text("first\n", encoding="utf-8")
|
|
second.write_text("second\n", encoding="utf-8")
|
|
subprocess.run(["git", "add", "."], cwd=root, check=True)
|
|
subprocess.run(["git", "commit", "--quiet", "-m", "fixture"], cwd=root, check=True)
|
|
state = root / "state.json"
|
|
|
|
assert normalize(root, state, False) == 2
|
|
assert normalize(root, state, True) == 0
|
|
first_mtime = first.stat().st_mtime_ns
|
|
second_mtime = second.stat().st_mtime_ns
|
|
|
|
first.write_text("changed\n", encoding="utf-8")
|
|
assert normalize(root, state, False) == 1
|
|
assert first.stat().st_mtime_ns > first_mtime
|
|
assert second.stat().st_mtime_ns == second_mtime
|
|
assert normalize(root, state, True) == 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--check", action="store_true")
|
|
parser.add_argument("--self-test", action="store_true")
|
|
parser.add_argument("--root", type=Path, default=Path.cwd())
|
|
parser.add_argument("--state", type=Path)
|
|
args = parser.parse_args()
|
|
if args.self_test:
|
|
self_test()
|
|
print("tracked Git mtime self-test: ok")
|
|
return 0
|
|
root = args.root.resolve()
|
|
state_path = args.state or default_state_path(root)
|
|
if not state_path.is_absolute():
|
|
state_path = root / state_path
|
|
changed = normalize(root, state_path, args.check)
|
|
verb = "verified" if args.check else "normalized"
|
|
print(f"{verb} tracked Git mtimes ({changed} changed)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|