chane35's picture
PERMANENCE: reversibility-aware RL environment for training LLM agents
796da7c verified
Raw
History Blame Contribute Delete
16.1 kB
"""
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
# ─────────────────────────────────────────────────────────────────────────────
@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()),
}