""" permanence.world.git — mock git repository with reversibility semantics. This module simulates the operational semantics of git operations that matter for reversibility prediction. It is NOT a byte-for-byte git reimplementation; it models exactly what an agent needs to reason about: * Commits, branches, the reflog, and the remote view of each branch * Whether a commit is still "reachable" (i.e. recoverable) * What gets orphaned when history is rewritten All state is in-memory Python. No ``subprocess`` calls. No network. Unit tests assert isolation explicitly. Reversibility classes encoded by operations: R1 ``log``, ``status``, ``diff`` — read-only, always reversible R2 ``commit``, ``branch `` — trivially reversible (new state) R3 ``reset --hard``, ``branch -D`` (local) — recoverable via reflog R4 ``push``, ``rebase``, local GC of reflog — recoverable with effort R5 ``push --force`` over others' commits, ``filter-branch``, ``reflog expire --all`` — unrecoverable without cooperation from others """ from __future__ import annotations from dataclasses import dataclass, field from typing import Dict, List, Optional, Set, Tuple import hashlib import time # ───────────────────────────────────────────────────────────────────────────── # Data model # ───────────────────────────────────────────────────────────────────────────── @dataclass class Commit: sha: str parent: Optional[str] message: str files: Dict[str, bytes] # path → content at this commit def short(self) -> str: return self.sha[:7] @dataclass class RefLogEntry: ref: str # e.g. "HEAD", "refs/heads/main" old_sha: Optional[str] new_sha: Optional[str] operation: str # "commit", "reset", "push", "force-push", etc. @dataclass class GitResult: ok: bool message: str r_level: int affected_commits: List[str] = field(default_factory=list) orphaned_commits: List[str] = field(default_factory=list) # ───────────────────────────────────────────────────────────────────────────── # The mock git repository # ───────────────────────────────────────────────────────────────────────────── class MockGitRepo: """In-memory git repository with reflog and remote-state tracking. The repo has five layers: * ``commits`` — every commit object ever created (including orphans; never garbage-collected here) * ``branches`` — branch name → current tip sha (local view) * ``remote_branches`` — branch name → tip sha as known to "origin" * ``reflog`` — every ref update, in order. This is the recovery mechanism for R3/R4 operations * ``reflog_expired`` — when True, the reflog is empty for recovery purposes. Set by ``reflog_expire_all``. Reversibility is derived from these layers at call time, not looked up. For example, ``push --force`` is R4 if the overwritten remote commits remain in someone's reflog (modeled as ``other_clones_have_commits``) but R5 if they do not. """ def __init__(self, default_branch: str = "main") -> None: self.commits: Dict[str, Commit] = {} self.branches: Dict[str, str] = {} # name → sha self.remote_branches: Dict[str, str] = {} # name → sha (origin view) self.reflog: List[RefLogEntry] = [] self.reflog_expired: bool = False self.head_branch: str = default_branch # Tracks whether anyone else has pulled the current remote state. # Driven externally by tasks to model "is history rewrite safe?". self.other_clones_have_commits: Set[str] = set() # Bootstrap with an initial empty commit so HEAD is valid. initial = self._new_commit(parent=None, message="initial", files={}) self.branches[default_branch] = initial.sha self.remote_branches[default_branch] = initial.sha self.reflog.append( RefLogEntry( ref=f"refs/heads/{default_branch}", old_sha=None, new_sha=initial.sha, operation="init", ) ) # ─── Helpers ────────────────────────────────────────────────────────── def _new_sha(self, payload: str) -> str: """Deterministic SHA derived from commit content + chain length. Using SHA-256 of message+parent+files gives us reproducible shas without calling real git and without any time-based entropy. """ h = hashlib.sha256(payload.encode("utf-8")).hexdigest() return h[:40] def _new_commit( self, parent: Optional[str], message: str, files: Dict[str, bytes] ) -> Commit: # Include parent and file hashes so shas differ when content differs. file_digest = hashlib.sha256( b"|".join(k.encode() + b":" + v for k, v in sorted(files.items())) ).hexdigest() payload = f"{parent or 'root'}|{message}|{file_digest}" sha = self._new_sha(payload) c = Commit(sha=sha, parent=parent, message=message, files=dict(files)) self.commits[sha] = c return c def _reachable_from(self, sha: Optional[str]) -> Set[str]: """Walk parents from ``sha`` and return all reachable shas.""" seen: Set[str] = set() cur = sha while cur and cur in self.commits and cur not in seen: seen.add(cur) cur = self.commits[cur].parent return seen def _all_reachable(self) -> Set[str]: """Everything reachable from any local branch tip.""" out: Set[str] = set() for tip in self.branches.values(): out |= self._reachable_from(tip) return out def _orphans_of(self, old_tip: Optional[str], new_tip: Optional[str]) -> List[str]: """Commits that were reachable from old_tip but are no longer reachable from any branch after moving to new_tip.""" if old_tip is None: return [] old_chain = self._reachable_from(old_tip) still_reachable = self._all_reachable() # Also consider the new tip we just set. if new_tip: still_reachable |= self._reachable_from(new_tip) return sorted(old_chain - still_reachable) # ─── Operations ─────────────────────────────────────────────────────── def commit(self, message: str, files: Dict[str, bytes]) -> GitResult: branch = self.head_branch parent = self.branches.get(branch) c = self._new_commit(parent=parent, message=message, files=files) self.reflog.append( RefLogEntry( ref=f"refs/heads/{branch}", old_sha=parent, new_sha=c.sha, operation="commit", ) ) self.branches[branch] = c.sha return GitResult( True, f"[{branch} {c.short()}] {message}", r_level=2, affected_commits=[c.sha], ) def checkout_branch(self, name: str, create: bool = False) -> GitResult: if create: if name in self.branches: return GitResult(False, f"branch exists: {name}", r_level=1) self.branches[name] = self.branches[self.head_branch] if name not in self.branches: return GitResult(False, f"no such branch: {name}", r_level=1) self.head_branch = name return GitResult(True, f"switched to {name}", r_level=1) def delete_branch(self, name: str, force: bool = False) -> GitResult: if name not in self.branches: return GitResult(False, f"no such branch: {name}", r_level=1) if name == self.head_branch: return GitResult(False, f"cannot delete checked-out branch", r_level=1) old_sha = self.branches.pop(name) orphans = self._orphans_of(old_sha, None) self.reflog.append( RefLogEntry( ref=f"refs/heads/{name}", old_sha=old_sha, new_sha=None, operation="branch-delete", ) ) # Recoverable via reflog unless the user also expired the reflog r = 3 if not self.reflog_expired else 4 return GitResult( True, f"deleted branch {name} ({len(orphans)} commits now unreachable)", r_level=r, orphaned_commits=orphans, ) def reset_hard(self, n_commits: int) -> GitResult: """Move HEAD back N commits, discarding anything in between. Reflog still holds the old tip, so this is R3 by default. """ branch = self.head_branch tip = self.branches.get(branch) if tip is None: return GitResult(False, "detached or empty", r_level=1) target = tip for _ in range(n_commits): parent = self.commits[target].parent if parent is None: break target = parent self.reflog.append( RefLogEntry( ref=f"refs/heads/{branch}", old_sha=tip, new_sha=target, operation="reset-hard", ) ) self.branches[branch] = target orphans = self._orphans_of(tip, target) r = 3 if not self.reflog_expired else 4 return GitResult( True, f"reset {branch} back {n_commits} commits ({len(orphans)} now unreachable)", r_level=r, orphaned_commits=orphans, ) def push(self) -> GitResult: branch = self.head_branch local_tip = self.branches.get(branch) if local_tip is None: return GitResult(False, "no branch", r_level=1) remote_tip = self.remote_branches.get(branch) # Fast-forward check: is remote_tip an ancestor of local_tip? local_chain = self._reachable_from(local_tip) if remote_tip is not None and remote_tip not in local_chain: return GitResult( False, "non-fast-forward — use push --force or pull first", r_level=1, ) self.remote_branches[branch] = local_tip return GitResult(True, f"pushed {branch} → {local_tip[:7]}", r_level=2) def push_force(self) -> GitResult: """Force-push. Reversibility depends on whether the overwritten remote commits are still preserved somewhere. """ branch = self.head_branch local_tip = self.branches.get(branch) remote_tip = self.remote_branches.get(branch) if local_tip is None: return GitResult(False, "no branch", r_level=1) # What remote commits do we overwrite? overwritten: List[str] = [] if remote_tip is not None: remote_chain = self._reachable_from(remote_tip) local_chain = self._reachable_from(local_tip) overwritten = sorted(remote_chain - local_chain) # Recoverable if the overwritten commits are preserved elsewhere # (another clone's reflog, a backup). We model this via # ``other_clones_have_commits``. if not overwritten: r = 2 # Nothing actually overwritten; same as a normal push else: still_preserved = all( c in self.other_clones_have_commits for c in overwritten ) r = 4 if still_preserved else 5 self.remote_branches[branch] = local_tip return GitResult( True, f"force-pushed {branch} (overwrote {len(overwritten)} remote commits)", r_level=r, orphaned_commits=overwritten, ) def reflog_expire_all(self) -> GitResult: """Expire the reflog. Commits not reachable from any branch become genuinely unreachable — R5 if any orphans exist. """ self.reflog_expired = True self.reflog.clear() reachable = self._all_reachable() orphans = [sha for sha in self.commits if sha not in reachable] r = 5 if orphans else 3 return GitResult( True, f"reflog expired ({len(orphans)} orphan commits now unrecoverable)", r_level=r, orphaned_commits=orphans, ) def filter_branch_drop(self, path: str) -> GitResult: """Rewrite history to drop a file from every commit. This is R5 once pushed — classic "we accidentally committed credentials" recovery scenario. Modeled as rewriting local history (every commit gets a new sha); the remote still has the old chain, so a force-push is typically needed after. """ branch = self.head_branch tip = self.branches.get(branch) if tip is None: return GitResult(False, "no branch", r_level=1) # Rebuild the chain with path removed. chain = [] cur = tip while cur is not None: chain.append(self.commits[cur]) cur = self.commits[cur].parent chain.reverse() # oldest first new_parent: Optional[str] = None rewritten: List[str] = [] for old in chain: new_files = {p: c for p, c in old.files.items() if p != path} new_c = self._new_commit( parent=new_parent, message=old.message, files=new_files ) rewritten.append(new_c.sha) new_parent = new_c.sha self.branches[branch] = new_parent or tip self.reflog.append( RefLogEntry( ref=f"refs/heads/{branch}", old_sha=tip, new_sha=new_parent, operation="filter-branch", ) ) # Very high irreversibility: history is rewritten; every old sha # is now orphaned locally. Once pushed, collaborators must # re-clone. We mark R4 pre-push, R5 post-push (see push_force). return GitResult( True, f"rewrote {len(chain)} commits to drop {path}", r_level=4, affected_commits=rewritten, orphaned_commits=[c.sha for c in chain], ) # ─── Introspection ──────────────────────────────────────────────────── def log(self) -> GitResult: tip = self.branches.get(self.head_branch) chain = self._reachable_from(tip) return GitResult( True, f"{self.head_branch}: {len(chain)} commits reachable", r_level=1, ) def summary(self) -> Dict[str, int]: return { "commits": len(self.commits), "branches": len(self.branches), "reflog_entries": len(self.reflog), "reflog_expired": int(self.reflog_expired), "orphan_commits": len(set(self.commits.keys()) - self._all_reachable()), }