git-alibi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
git_alibi/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
git_alibi/backup.py ADDED
@@ -0,0 +1,133 @@
1
+ """Backup of original commit timestamps, organised as rewrite snapshots.
2
+
3
+ The backup file lives at <git_dir>/alibi/backup.json.
4
+
5
+ Each call to ``save_rewrite`` appends one snapshot — capturing the before/after
6
+ SHA and pre-rewrite timestamps for every commit touched by that run. Multiple
7
+ snapshots allow restoring to any previous state via SHA chaining:
8
+
9
+ sha_after[n] == sha_before[n+1]
10
+
11
+ so walking backwards through snapshots traces a commit from its current SHA
12
+ all the way back to its original timestamps.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import TypedDict
21
+
22
+ import git
23
+
24
+
25
+ class RewriteCommit(TypedDict):
26
+ sha_before: str # SHA of the commit before this rewrite
27
+ sha_after: str # SHA of the commit after this rewrite
28
+ author_date: str # author timestamp before this rewrite
29
+ committer_date: str # committer timestamp before this rewrite
30
+
31
+
32
+ class RewriteSnapshot(TypedDict):
33
+ id: int
34
+ timestamp: str
35
+ commits: list[RewriteCommit]
36
+
37
+
38
+ def backup_path(repo: git.Repo) -> Path:
39
+ """Return the absolute path to the backup file for this repository.
40
+
41
+ Uses repo.common_dir when available (handles git worktrees correctly —
42
+ all worktrees share one backup in the main .git directory). Falls back
43
+ to repo.git_dir for older GitPython versions.
44
+ """
45
+ git_dir = Path(getattr(repo, "common_dir", None) or repo.git_dir)
46
+ return git_dir / "alibi" / "backup.json"
47
+
48
+
49
+ def _load(path: Path) -> dict:
50
+ if not path.exists():
51
+ return {}
52
+ try:
53
+ return json.loads(path.read_text(encoding="utf-8"))
54
+ except Exception as exc:
55
+ raise RuntimeError(f"Failed to read backup file {path}: {exc}") from exc
56
+
57
+
58
+ def _write(path: Path, data: dict) -> None:
59
+ """Atomically write data to path (via .tmp → rename)."""
60
+ tmp = path.with_suffix(".json.tmp")
61
+ try:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=True), encoding="utf-8")
64
+ tmp.replace(path)
65
+ except OSError as exc:
66
+ raise RuntimeError(f"Cannot write backup file {path}: {exc}") from exc
67
+
68
+
69
+ def load_rewrites(repo: git.Repo) -> list[RewriteSnapshot]:
70
+ """Load all rewrite snapshots from the backup file.
71
+
72
+ Raises RuntimeError if no backup file exists or the format is unrecognised.
73
+ """
74
+ path = backup_path(repo)
75
+ if not path.exists():
76
+ raise RuntimeError(
77
+ f"No backup file found at {path}. "
78
+ "Run 'alibi rewrite' (without --no-backup) at least once to create one."
79
+ )
80
+ data = _load(path)
81
+ if "rewrites" not in data:
82
+ raise RuntimeError(
83
+ "Backup file is in an old format and cannot be used for restore. "
84
+ "Run 'alibi rewrite' again to create a new backup."
85
+ )
86
+ return data.get("rewrites", [])
87
+
88
+
89
+ def save_rewrite(repo: git.Repo, commits: list[RewriteCommit]) -> int:
90
+ """Append a new rewrite snapshot to the backup file and return its ID.
91
+
92
+ Each call records the before/after SHA and pre-rewrite timestamps for
93
+ every commit touched by one alibi rewrite run. Returns -1 and writes
94
+ nothing if commits is empty.
95
+ """
96
+ if not commits:
97
+ return -1
98
+
99
+ path = backup_path(repo)
100
+ data = _load(path)
101
+ now = datetime.now(timezone.utc).isoformat()
102
+
103
+ if not data or "rewrites" not in data:
104
+ data = {"created_at": now, "updated_at": now, "rewrites": []}
105
+
106
+ next_id = (data["rewrites"][-1]["id"] + 1) if data["rewrites"] else 1
107
+ data["rewrites"].append(
108
+ {
109
+ "id": next_id,
110
+ "timestamp": now,
111
+ "commits": commits,
112
+ }
113
+ )
114
+ data["updated_at"] = now
115
+
116
+ _write(path, data)
117
+ return next_id
118
+
119
+
120
+ def remove_rewrites_from(repo: git.Repo, from_id: int) -> None:
121
+ """Remove the snapshot with the given ID and all subsequent snapshots.
122
+
123
+ Called after a successful restore to prune stale history — snapshots
124
+ after the restore point no longer reflect any real repo state.
125
+ """
126
+ path = backup_path(repo)
127
+ data = _load(path)
128
+ if not data or "rewrites" not in data:
129
+ return
130
+
131
+ data["rewrites"] = [r for r in data["rewrites"] if r["id"] < from_id]
132
+ data["updated_at"] = datetime.now(timezone.utc).isoformat()
133
+ _write(path, data)