DEV Community

Cover image for Warum kann git checkout nicht rĂŒckgĂ€ngig machen, was ein KI-Coding-Agent geĂ€ndert hat?

Warum kann git checkout nicht rĂŒckgĂ€ngig machen, was ein KI-Coding-Agent geĂ€ndert hat?

Wenn ein KI-Agent Dateien in deinem Projekt bearbeitet und dabei etwas kaputt macht, ist der Reflex git checkout -- . oder git stash. Beide beantworten eine andere Frage als die, die du hast. Git bringt dich zurĂŒck zum letzten Commit. Was du willst, ist der Ordner genau so, wie er direkt vor dem Turn des Agenten war: mit deiner eigenen, noch nicht committeten Arbeit und den Dateien, die git ignoriert, und ohne die Dateien, die der Agent angelegt hat.

Die osFoundry-Desktop-App löst das mit einem kleinen inhaltsadressierten Speicher in Rust, checkpoint_store.rs. Bevor ein Schreibvorgang des Agenten eine Datei erreicht, werden ihre aktuellen Bytes unter <project>/.osfoundry/checkpoints/blobs/<sha256> gesichert, und /rewind schreibt sie zurĂŒck. Unten steht dasselbe Design in etwa 90 Zeilen Python mit reiner Standardbibliothek, getestet gegen vier git-Rezepte an einem realistischen Turn und gemessen an einer Kopie eines echten TypeScript-Quellbaums mit 4,686 Dateien.

Ein Turn, fĂŒnf Arten, ihn rĂŒckgĂ€ngig zu machen

Das Testprojekt ist eine winzige Website in einem git-Repository: index.html, styles.css (212 Zeilen), app.js und eine .env, die .gitignore ausschließt. Nach dem letzten Commit hat der Nutzer den Seitentitel in index.html geĂ€ndert und eine ungetrackte notes.md angefangen. Das ist der Zustand, zu dem es zurĂŒckgehen soll.

Dann der Turn des Agenten: Er schreibt styles.css auf 41 Zeilen zusammen, Ă€ndert index.html so, dass eine neue theme.css geladen wird, schreibt app.js neu, lĂ€sst API_URL in .env auf localhost zeigen und legt theme.css an. Jede Undo-Methode lief auf einer frischen Kopie, und ein Skript verglich jede Datei Byte fĂŒr Byte mit dem Zustand vor dem 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']
Enter fullscreen mode Exit fullscreen mode
Methode Was schiefgeht
git checkout -- . Die TitelÀnderung des Nutzers geht zusammen mit denen des Agenten verloren, theme.css bleibt, .env behÀlt den Wert des Agenten
git stash -u Die Änderungen des Agenten, die TitelĂ€nderung des Nutzers und notes.md landen alle zusammen in einem Stash, und .env behĂ€lt den Wert des Agenten
git checkout + git clean -fd theme.css wird entfernt, aber notes.md auch, die TitelÀnderung ist weg, und .env behÀlt den Wert des Agenten
git stash create vor dem Turn Alles Getrackte kommt zurĂŒck, aber theme.css bleibt, und .env ist nie im Snapshot
Checkpoint-Wiederherstellung Entspricht dem Zustand vor dem Turn

Die git-Ergebnisse folgen daraus, was git ist. Es stellt den letzten Commit wieder her oder einen Snapshot der getrackten und gestagten Dateien, und beides vermischt deine Arbeit mit der des Agenten, sobald keine von beiden committet ist. Ignorierte Dateien sieht es nicht, außer du gibst -a mit, und ein Checkout löscht nie eine Datei, die im Snapshot nicht existierte. Die letzte Zeile ist das fairste git-Setup, ein Snapshot vor jedem Turn, und trotzdem verfehlt es zwei der fĂŒnf Dateien. Dazu kommt ein Nebeneffekt: git add -A staged bei jedem Turn deine gesamte Arbeit im echten Index.

Der Speicher

Ein Checkpoint ist eine Liste pro Turn: FĂŒr jede Datei, die der Agent geschrieben hat, steht dort entweder der Hash ihres alten Inhalts oder ein Vermerk, dass sie nicht existierte. Die Bytes landen einmal pro unterschiedlichem Inhalt in blobs/.

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)
Enter fullscreen mode Exit fullscreen mode

Drei Details entscheiden darĂŒber, ob eine Wiederherstellung korrekt ist, und jedes davon ist auch im Rust-Modul eine Regel.

Die erste Erfassung eines Pfads in einem Turn gewinnt. Ein Agent schreibt dieselbe Datei oft mehrmals in einem Turn. ZurĂŒckspulen heißt, zu dem Stand zurĂŒckzukehren, den die Datei zu Beginn des Turns hatte, nicht zu irgendeinem Zwischenstand zwischen zwei SchreibvorgĂ€ngen des Agenten. SpĂ€tere Erfassungen desselben Pfads im selben Turn werden deshalb ignoriert.

Eine fehlende Datei wird als absent vermerkt. Genau diese eine Zeile erlaubt es einer Wiederherstellung, theme.css zu löschen. Git hat kein GegenstĂŒck zu "diese Datei darf nicht existieren", außer einem git clean, das auch Dateien löscht, die du behalten wolltest.

Dateien ĂŒber 8 MB werden mit BegrĂŒndung als skipped vermerkt, nicht einfach weggelassen. Eine Wiederherstellung meldet dann, dass sie sie nicht zurĂŒckholen konnte, statt still mit einer Mischung aus alten und neuen Dateien fertig zu werden.

Wiederherstellen, die Àltesten zuerst

    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)
Enter fullscreen mode Exit fullscreen mode

Wer Turn 1 zurĂŒckspult, nachdem die Turns 2 und 3 gefolgt sind, muss alle drei rĂŒckgĂ€ngig machen. WĂŒrde man nur Turn 1 wiederherstellen, lĂ€gen Dateien aus Turn 2 neben Dateien von vor Turn 1, eine Kombination, die es nie gab. Auch die Reihenfolge zĂ€hlt. FĂŒr eine Datei, die in mehreren Turns angefasst wurde, sind die richtigen Bytes das Ă€lteste Pre-Image, also das, das beim Start von Turn 1 erfasst wurde. Geht man von neu nach alt vor, landet man beim Pre-Image von Turn 2, und das ist der Zustand, den Turn 1 erzeugt hat. Im Rust-Quellcode steht genau dazu ein Kommentar: Zuerst wurde es von neu nach alt geschrieben, und der Test rewinding_a_turn_also_undoes_later_turns hat es aufgedeckt.

Die PfadprĂŒfung lĂ€uft beim Wiederherstellen erneut, weil index.json eine ganz normale Datei in dem Ordner ist, den sie beschreibt. WĂŒrde man ihr vertrauen, könnte jeder und alles, was sie bearbeiten kann, eine Wiederherstellung an eine beliebige Stelle auf der Festplatte schreiben lassen. Danach werden die zurĂŒckgespulten Turns aus dem Index entfernt, damit ein zweites ZurĂŒckspulen auf denselben Punkt nicht gegen Dateien "erfolgreich" sein kann, die lĂ€ngst wieder da sind.

Was es in einem echten Ordner kostet

Der Baum ist eine Kopie des src-Ordners einer echten React- und TypeScript-App: 4,686 Dateien, 121.4 MB, keine Datei ĂŒber 8 MB. In jedem Turn wurden zufĂ€llige Dateien ausgewĂ€hlt, erfasst und um eine Zeile ergĂ€nzt, danach lief eine Wiederherstellung, und jede Datei wurde mit dem Original verglichen. Alle Wiederherstellungen stimmten. Apple M1, 8 GB, macOS 15, APFS-SSD, Python 3.9.6, git 2.39.5.

Turn Erfassen + Schreiben SpeichergrĂ¶ĂŸe Wiederherstellen
4 Dateien + 1 neu angelegt 3.5 ms unter 0.1 MB 1.2 ms
50 Dateien + 1 neu angelegt 62.4 ms 0.9 MB 8.2 ms
500 Dateien + 1 neu angelegt 1,035 ms 11.7 MB 227 ms
alle 4,686 Dateien 30.5 s 122.7 MB 1.3 s

Zum Vergleich: Der git-Snapshot pro Turn aus dem Szenario oben (git add -A, dann git stash create) brauchte auf diesem Baum ohne Änderungen 61 bis 68 ms und mit vier bearbeiteten Dateien 50 bis 67 ms. Er muss jedes Mal alle 4,686 Dateien ansehen. Direkt nach dem Lauf, der jede Datei neu geschrieben hatte, brauchte der nĂ€chste 1,241 ms, weil git jede Datei neu hasht, deren Zeitstempel sich geĂ€ndert hat. Ein Checkpoint sieht sich immer nur die Dateien an, die gerade geschrieben werden, also kostet ein normaler Turn ein paar Millisekunden, egal wie groß das Projekt ist.

Die letzte Zeile deckt ein Problem im Code in dieser Form auf. Die durchschnittliche Erfassung dauerte bei den ersten 100 Dateien des Turns 1.29 ms und bei den letzten 100 9.54 ms. Jede Erfassung schreibt das komplette index.json neu, das auf 1.3 MB anwuchs, also steigen die Kosten jedes Schreibvorgangs mit der Zahl der SchreibvorgĂ€nge davor. _save durch das AnhĂ€ngen einer JSON-Zeile pro Erfassung zu ersetzen, hat das auf denselben 4,686 Dateien behoben: 2.5 s insgesamt, 0.41 ms pro Erfassung am Anfang und 0.61 ms am Ende. FĂŒr einen Agenten, der pro Turn eine Handvoll Dateien bearbeitet, reicht die einfache Version. FĂŒr einen Formatter oder ein Umbenennen quer durch die Codebasis, das ĂŒber einen Agenten lĂ€uft, nimm das Append-only-Log.

Was Inhaltsadressierung spart und was nicht

Blobs nach ihrem SHA-256 zu benennen, dedupliziert identische Bytes und sonst nichts. Drei Messungen zeigen, wo das landet.

Über den ganzen Baum hatten 4,686 Dateien 4,677 unterschiedliche Inhalte, ein Turn ĂŒber den kompletten Baum speicherte also 4,677 Blobs. Doppelte Dateien sind in einem echten Quellbaum selten.

Eine 2-MB-Datei, in zehn Turns neu geschrieben, wobei der Agent jedes Mal eine Zeile anhĂ€ngt: zehn Pre-Images, alle verschieden, 10 Blobs und 20.0 MB. Dieselbe Datei, aber der Agent schreibt in jedem Turn dieselben Bytes, wie es ein Retry mit identischer Ausgabe tut: 2 Blobs und 4.0 MB, das Original und die Version, von der jeder spĂ€tere Turn ausging. In beiden FĂ€llen lieferte das ZurĂŒckspulen auf den ersten Turn das Original Byte fĂŒr Byte zurĂŒck.

Die Blobs werden unkomprimiert gespeichert. Die 121.4 MB an Blobs aus dem Turn ĂŒber den kompletten Baum kĂ€men mit zlib auf Stufe 6 auf 33.7 MB, und gits eigener Objektspeicher fĂŒr denselben Baum nach einem Commit lag bei 41.0 MB. Kompression ist eine sinnvolle ErgĂ€nzung, wenn der Speicher lange aufbewahrt wird, denn Quellcode komprimiert etwa 3.6 zu 1. Deltas zwischen Versionen wie in gits Packfiles wĂŒrden im Fall "eine Zeile anhĂ€ngen" mehr sparen, kosten aber weit mehr Code, als ein Pre-Image-Speicher braucht.

Grenzen dieser Version

Sie sieht nur SchreibvorgĂ€nge, die ĂŒber write() gehen. Wenn dein Agent auch Shell-Befehle ausfĂŒhren darf, rutscht ein rm oder ein sed -i einfach durch. Die osFoundry-App leitet die DateischreibvorgĂ€nge des Agenten durch eine einzige Funktion, und genau dort erfasst sie.

Eine Wiederherstellung schreibt das Pre-Image zurĂŒck, egal was gerade in der Datei steht. Wenn du eine Datei nach dem Agenten von Hand bearbeitet hast, ersetzt das ZurĂŒckspulen dieses Turns auch deine Änderung. Eine strengere Version vermerkt den Hash dessen, was der Agent geschrieben hat, und prĂŒft vor dem Wiederherstellen, ob die Datei noch diesen Hash hat. Wenn nicht, sichert sie zuerst die aktuellen Bytes als weiteren Blob, sodass sich auch die Wiederherstellung rĂŒckgĂ€ngig machen lĂ€sst.

Außerdem wĂ€chst der Speicher, bis Turns zurĂŒckgespult werden. Ein prune, das Blobs löscht, auf die kein Checkpoint mehr verweist, plus eine Obergrenze fĂŒr die Zahl der aufbewahrten Turns genĂŒgt.

Die beiden Klassenblöcke oben sind der komplette Speicher, Python 3.9 mit reiner Standardbibliothek, nichts zu installieren. Die Desktop-App von osFoundry liefert die Rust-Version als /rewind in Chats, die in einem lokalen Ordner arbeiten, kostenlos ĂŒber die Download-Seite von osFoundry.

Top comments (0)