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 +1 -0
- git_alibi/backup.py +133 -0
- git_alibi/cli.py +831 -0
- git_alibi/config.py +599 -0
- git_alibi/filter.py +379 -0
- git_alibi/rewrite.py +1204 -0
- git_alibi-0.1.0.dist-info/METADATA +245 -0
- git_alibi-0.1.0.dist-info/RECORD +12 -0
- git_alibi-0.1.0.dist-info/WHEEL +5 -0
- git_alibi-0.1.0.dist-info/entry_points.txt +2 -0
- git_alibi-0.1.0.dist-info/licenses/LICENSE +21 -0
- git_alibi-0.1.0.dist-info/top_level.txt +1 -0
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)
|