#!/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())