Spaces:
Sleeping
Sleeping
| """ | |
| 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 <new>`` β 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 | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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] | |
| 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. | |
| 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()), | |
| } | |