When an AI agent edits files in your project and breaks something, the reflex is git checkout -- . or git stash. Both answer a different question than the one you have. Git can take you back to the last commit. What you want is the folder as it was right before the agent's turn, with your own uncommitted work and the files git ignores, and without the files the agent created.
The osFoundry desktop app solves this with a small content-addressed store in Rust, checkpoint_store.rs. Before an agent's write reaches a file, the file's current bytes are saved under <project>/.osfoundry/checkpoints/blobs/<sha256>, and /rewind writes them back. Below is the same design in about 90 lines of standard-library Python, tested against four git recipes on one realistic turn, and timed on a copy of a real 4,686-file TypeScript source tree.
One turn, five ways to undo it
The test project is a tiny site in a git repository: index.html, styles.css (212 lines), app.js, and a .env that .gitignore excludes. After the last commit, the user changed the page title in index.html and started an untracked notes.md. That is the state to get back to.
Then the agent's turn: it rewrites styles.css down to 41 lines, edits index.html to load a new theme.css, rewrites app.js, points API_URL in .env at localhost, and creates theme.css. Each undo method ran on a fresh copy, and a script compared every file byte for byte with the state before the turn.
checkpoint restore matches the state before the turn: True differs: []
git checkout -- . matches the state before the turn: False differs: ['.env', 'index.html', 'theme.css']
git stash -u matches the state before the turn: False differs: ['.env', 'index.html', 'notes.md']
git checkout + git clean -fd matches the state before the turn: False differs: ['.env', 'index.html', 'notes.md']
git stash create before the turn matches the state before the turn: False differs: ['.env', 'theme.css']
| Method | What goes wrong |
|---|---|
git checkout -- . |
The user's title edit is lost with the agent's, theme.css stays, .env keeps the agent's value |
git stash -u |
The agent's changes, the user's title edit and notes.md all go into one stash together, and .env keeps the agent's value |
git checkout + git clean -fd
|
theme.css is removed, but so is notes.md, the title edit is gone, and .env keeps the agent's value |
git stash create before the turn |
Everything tracked comes back, but theme.css stays and .env is never in the snapshot |
| checkpoint restore | Matches the state before the turn |
The git results follow from what git is. It restores the last commit, or a snapshot of tracked and staged files, and both mix your work with the agent's once neither is committed. Ignored files are invisible to it unless you add -a, and a checkout never deletes a file that did not exist in the snapshot. The last row is the fairest git setup, a snapshot taken before every turn, and it still misses two of the five files. It also has a side effect: git add -A stages all your work in the real index on every turn.
The store
A checkpoint is a list per turn: for each file the agent wrote, either the hash of its old contents, or a note that it did not exist. The bytes go into blobs/ once per distinct content.
import hashlib, json, os, time
from pathlib import Path
MAX_BLOB = 8 * 1024 * 1024
class Checkpoints:
def __init__(self, root):
self.root = Path(root).resolve()
self.dir = self.root / ".osfoundry" / "checkpoints"
self.blobs = self.dir / "blobs"
self.index_file = self.dir / "index.json"
try:
self.index = json.loads(self.index_file.read_text())
except (OSError, ValueError):
self.index = []
def _save(self):
self.dir.mkdir(parents=True, exist_ok=True)
tmp = self.dir / "index.json.tmp"
tmp.write_text(json.dumps(self.index))
os.replace(tmp, self.index_file) # a half-written index loses everything
def capture(self, turn, label, path):
path = str(Path(path).resolve()) if Path(path).exists() else str(Path(path).absolute())
if not self._contained(path):
raise ValueError("outside the workspace: " + path)
cp = next((c for c in self.index if c["turn"] == turn), None)
if cp and any(f["path"] == path for f in cp["files"]):
return # first write in a turn wins
try:
size = os.path.getsize(path)
except FileNotFoundError:
pre = {"kind": "absent"}
else:
if size > MAX_BLOB:
pre = {"kind": "skipped", "reason": f"{size >> 20} MB over the limit"}
else:
data = Path(path).read_bytes()
sha = hashlib.sha256(data).hexdigest()
blob = self.blobs / sha
if not blob.exists(): # identical bytes are stored once
self.blobs.mkdir(parents=True, exist_ok=True)
blob.write_bytes(data)
pre = {"kind": "content", "sha256": sha, "bytes": size}
entry = {"path": path, "pre": pre}
if cp:
cp["files"].append(entry)
else:
self.index.append({"turn": turn, "label": label[:120],
"at": time.time_ns(), "files": [entry]})
self._save()
def write(self, turn, label, path, data):
"""What an agent's write tool would call."""
self.capture(turn, label, path)
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_bytes(data)
Three details decide whether a restore is correct, and each one is a rule in the Rust module too.
The first capture of a path in a turn wins. An agent often writes the same file several times in one turn. Rewinding means returning to how the file was when the turn started, not to some state between two of the agent's own writes, so later captures of the same path in the same turn are ignored.
A missing file is recorded as absent. That one line is what lets a restore delete theme.css. Git has no equivalent for "this file must not exist", short of a git clean that also deletes files you wanted to keep.
Files over 8 MB are recorded as skipped with a reason, not left out. A restore then reports that it could not put them back, instead of quietly finishing with a mix of old and new files.
Restoring, oldest first
def restore(self, turn):
target = next(c["at"] for c in self.index if c["turn"] == turn)
affected = sorted((c for c in self.index if c["at"] >= target),
key=lambda c: c["at"]) # oldest first
done, report = set(), {"restored": [], "deleted": [], "failed": []}
for cp in affected:
for f in cp["files"]:
path, pre = f["path"], f["pre"]
if path in done:
continue
done.add(path)
if not self._contained(path): # re-check, the index is just a file
report["failed"].append((path, "outside the workspace"))
elif pre["kind"] == "skipped":
report["failed"].append((path, pre["reason"]))
elif pre["kind"] == "absent":
Path(path).unlink(missing_ok=True)
report["deleted"].append(path)
else:
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_bytes((self.blobs / pre["sha256"]).read_bytes())
report["restored"].append(path)
self.index = [c for c in self.index if c["at"] < target]
self._save()
return report
def _contained(self, path):
p = Path(path)
if ".." in p.parts:
return False
p = p if p.is_absolute() else self.root / p
while not p.exists(): # deepest existing ancestor, symlinks resolved
p = p.parent
return p.resolve().is_relative_to(self.root)
Rewinding turn 1 when turns 2 and 3 came after it must undo all three. Restoring only turn 1 would leave files from turn 2 next to files from before turn 1, a combination that never existed. The order matters as well. For a file touched in several turns, the right bytes are the oldest pre-image, the one captured when turn 1 started. Walking newest-first lands on turn 2's pre-image, which is the state turn 1 produced. The Rust source has a comment about exactly this: it was first written newest-first, and the test rewinding_a_turn_also_undoes_later_turns caught it.
The path check runs again at restore time because index.json is a plain file inside the folder it describes. If it were trusted, anyone or anything that can edit it could make a restore write anywhere on disk. Rewound turns are then dropped from the index, so a second rewind to the same point can't "succeed" against files that are already back.
What it costs on a real folder
The tree is a copy of the src folder of a real React and TypeScript app: 4,686 files, 121.4 MB, no file over 8 MB. Each turn picked random files, captured them and appended a line to each, then a restore ran and every file was compared with the original. All restores matched. Apple M1, 8 GB, macOS 15, APFS SSD, Python 3.9.6, git 2.39.5.
| Turn | Capture + write | Store size | Restore |
|---|---|---|---|
| 4 files + 1 created | 3.5 ms | under 0.1 MB | 1.2 ms |
| 50 files + 1 created | 62.4 ms | 0.9 MB | 8.2 ms |
| 500 files + 1 created | 1,035 ms | 11.7 MB | 227 ms |
| all 4,686 files | 30.5 s | 122.7 MB | 1.3 s |
For comparison, the per-turn git snapshot from the scenario above (git add -A then git stash create) took 61 to 68 ms on this tree with nothing changed, and 50 to 67 ms with four edited files. It has to look at all 4,686 files every time. Right after the run that rewrote every file, the next one took 1,241 ms, because git rehashes every file whose timestamp changed. A checkpoint only ever looks at the files being written, so a normal turn costs a few milliseconds whatever the size of the project.
The last row exposes a problem in the code as written. The average capture took 1.29 ms for the first 100 files of the turn and 9.54 ms for the last 100. Every capture rewrites the whole index.json, which reached 1.3 MB, so the cost of each write grows with the number of writes before it. Replacing _save with an append of one JSON line per capture fixed it on the same 4,686 files: 2.5 s in total, 0.41 ms per capture at the start and 0.61 ms at the end. For an agent that edits a handful of files per turn, the simple version is fine. For a formatter or a codebase-wide rename run through an agent, use the append-only log.
What content addressing saves, and what it doesn't
Naming blobs by their SHA-256 deduplicates identical bytes and nothing else. Three measurements show where that lands.
Across the whole tree, 4,686 files had 4,677 distinct contents, so a full-tree turn stored 4,677 blobs. Duplicate files in a real source tree are rare.
One 2 MB file rewritten in ten turns, the agent appending one line each time: ten pre-images, all different, 10 blobs and 20.0 MB. Same file, but the agent writes the same bytes on every turn, as a retry that produces identical output does: 2 blobs and 4.0 MB, the original and the version every later turn started from. In both cases, rewinding to the first turn gave back the original byte for byte.
The blobs are stored raw. The 121.4 MB of blobs from the full-tree turn would be 33.7 MB with zlib at level 6, and git's own object store for the same tree after one commit was 41.0 MB. Compression is a reasonable addition if the store is kept for long, since source code compresses about 3.6 to 1. Deltas between versions, as in git's pack files, would save more for the append-one-line case but cost far more code than a pre-image store needs.
Limits of this version
It only sees writes that go through write(). If your agent can also run shell commands, an rm or a sed -i passes straight by. The osFoundry app routes agent file writes through one function, which is where it captures.
A restore writes the pre-image whatever is in the file now. If you edited a file by hand after the agent did, rewinding that turn replaces your edit too. A stricter version records the hash of what the agent wrote, and before restoring, checks whether the file still has that hash. If not, it saves the current bytes as one more blob first, so the restore can be undone as well.
The store also grows until turns are rewound. A prune that deletes blobs no checkpoint references, plus a cap on how many turns to keep, is enough.
The two class blocks above are the whole store, standard-library Python 3.9 with nothing to install. The osFoundry desktop app ships the Rust version as /rewind in chats that work in a local folder, free from the osFoundry download page.
Top comments (0)