Quand un agent IA modifie des fichiers de votre projet et casse quelque chose, le réflexe est git checkout -- . ou git stash. Les deux répondent à une autre question que la vôtre. Git peut vous ramener au dernier commit. Ce que vous voulez, c'est le dossier tel qu'il était juste avant le tour de l'agent, avec votre propre travail non commité et les fichiers que git ignore, et sans les fichiers que l'agent a créés.

L'application de bureau osFoundry règle ça avec un petit stockage adressé par contenu écrit en Rust, checkpoint_store.rs. Avant qu'une écriture de l'agent n'atteigne un fichier, les octets actuels du fichier sont sauvegardés sous <project>/.osfoundry/checkpoints/blobs/<sha256>, et /rewind les réécrit. Voici la même conception en environ 90 lignes de Python avec uniquement la bibliothèque standard, confrontée à quatre recettes git sur un tour réaliste, et chronométrée sur une copie d'une vraie arborescence de sources TypeScript de 4,686 fichiers.

Un tour, cinq façons de l'annuler

Le projet de test est un tout petit site dans un dépôt git : index.html, styles.css (212 lignes), app.js, et un .env exclu par .gitignore. Après le dernier commit, l'utilisateur a changé le titre de la page dans index.html et commencé un notes.md non suivi. C'est l'état auquel il faut revenir.

Vient ensuite le tour de l'agent : il réécrit styles.css pour le ramener à 41 lignes, modifie index.html pour charger un nouveau theme.css, réécrit app.js, fait pointer API_URL dans .env vers localhost, et crée theme.css. Chaque méthode d'annulation a tourné sur une copie fraîche, et un script a comparé chaque fichier octet par octet avec l'état d'avant le tour.

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'] 
Méthode Ce qui ne va pas
git checkout -- . La modification du titre par l'utilisateur est perdue avec celles de l'agent, theme.css reste, .env garde la valeur de l'agent
git stash -u Les changements de l'agent, la modification du titre par l'utilisateur et notes.md partent tous ensemble dans un même stash, et .env garde la valeur de l'agent
git checkout + git clean -fd theme.css est supprimé, mais notes.md aussi, la modification du titre a disparu, et .env garde la valeur de l'agent
git stash create avant le tour Tout ce qui est suivi revient, mais theme.css reste et .env n'est jamais dans l'instantané
restauration du checkpoint Correspond à l'état d'avant le tour

Les résultats de git découlent de ce qu'est git. Il restaure le dernier commit, ou un instantané des fichiers suivis et indexés, et les deux mélangent votre travail avec celui de l'agent dès que ni l'un ni l'autre n'est commité. Les fichiers ignorés lui sont invisibles sauf si vous ajoutez -a, et un checkout ne supprime jamais un fichier qui n'existait pas dans l'instantané. La dernière ligne correspond à la configuration git la plus équitable, un instantané pris avant chaque tour, et elle rate quand même deux des cinq fichiers. Elle a aussi un effet de bord : git add -A indexe tout votre travail dans le vrai index à chaque tour.

Le stockage

Un checkpoint est une liste par tour : pour chaque fichier écrit par l'agent, soit le hash de son ancien contenu, soit une note indiquant qu'il n'existait pas. Les octets vont dans blobs/ une seule fois par contenu distinct.

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) 

Trois détails décident si une restauration est correcte, et chacun est aussi une règle dans le module Rust.

La première capture d'un chemin dans un tour l'emporte. Un agent écrit souvent le même fichier plusieurs fois dans un tour. Revenir en arrière, c'est retrouver le fichier tel qu'il était au début du tour, pas un état intermédiaire entre deux écritures de l'agent lui-même ; les captures suivantes du même chemin dans le même tour sont donc ignorées.

Un fichier absent est enregistré comme absent. C'est cette seule ligne qui permet à une restauration de supprimer theme.css. Git n'a pas d'équivalent pour « ce fichier ne doit pas exister », à part un git clean qui supprime aussi des fichiers que vous vouliez garder.

Les fichiers de plus de 8 MB sont enregistrés comme skipped avec une raison, au lieu d'être laissés de côté. Une restauration signale alors qu'elle n'a pas pu les remettre, au lieu de se terminer en silence avec un mélange d'anciens et de nouveaux fichiers.

Restaurer, du plus ancien au plus récent

 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) 

Revenir au tour 1 alors que les tours 2 et 3 ont suivi doit annuler les trois. Restaurer seulement le tour 1 laisserait des fichiers du tour 2 à côté de fichiers d'avant le tour 1, une combinaison qui n'a jamais existé. L'ordre compte aussi. Pour un fichier touché dans plusieurs tours, les bons octets sont la pré-image la plus ancienne, celle capturée au début du tour 1. Parcourir du plus récent au plus ancien tombe sur la pré-image du tour 2, qui est l'état produit par le tour 1. Le code Rust contient un commentaire précisément là-dessus : il a d'abord été écrit du plus récent au plus ancien, et le test rewinding_a_turn_also_undoes_later_turns l'a détecté.

La vérification du chemin est refaite au moment de la restauration parce que index.json est un simple fichier dans le dossier qu'il décrit. Si on lui faisait confiance, n'importe qui ou n'importe quoi capable de le modifier pourrait faire écrire une restauration n'importe où sur le disque. Les tours annulés sont ensuite retirés de l'index, pour qu'un second retour au même point ne puisse pas « réussir » sur des fichiers déjà revenus.

Ce que ça coûte sur un vrai dossier

L'arborescence est une copie du dossier src d'une vraie application React et TypeScript : 4,686 fichiers, 121.4 MB, aucun fichier au-dessus de 8 MB. À chaque tour, des fichiers ont été choisis au hasard, capturés, puis on leur a ajouté une ligne ; ensuite une restauration a tourné et chaque fichier a été comparé à l'original. Toutes les restaurations correspondaient. Apple M1, 8 GB, macOS 15, SSD APFS, Python 3.9.6, git 2.39.5.

Tour Capture + écriture Taille du stockage Restauration
4 fichiers + 1 créé 3.5 ms moins de 0.1 MB 1.2 ms
50 fichiers + 1 créé 62.4 ms 0.9 MB 8.2 ms
500 fichiers + 1 créé 1,035 ms 11.7 MB 227 ms
les 4,686 fichiers 30.5 s 122.7 MB 1.3 s

À titre de comparaison, l'instantané git par tour du scénario ci-dessus (git add -A puis git stash create) a pris 61 à 68 ms sur cette arborescence sans aucun changement, et 50 à 67 ms avec quatre fichiers modifiés. Il doit examiner les 4,686 fichiers à chaque fois. Juste après l'exécution qui avait réécrit tous les fichiers, la suivante a pris 1,241 ms, parce que git recalcule le hash de chaque fichier dont l'horodatage a changé. Un checkpoint ne regarde jamais que les fichiers en cours d'écriture, donc un tour normal coûte quelques millisecondes, quelle que soit la taille du projet.

La dernière ligne révèle un problème dans le code tel qu'il est écrit. La capture moyenne a pris 1.29 ms pour les 100 premiers fichiers du tour et 9.54 ms pour les 100 derniers. Chaque capture réécrit tout le fichier index.json, qui a atteint 1.3 MB, donc le coût de chaque écriture augmente avec le nombre d'écritures précédentes. Remplacer _save par l'ajout d'une ligne JSON par capture a réglé le problème sur les mêmes 4,686 fichiers : 2.5 s au total, 0.41 ms par capture au début et 0.61 ms à la fin. Pour un agent qui modifie une poignée de fichiers par tour, la version simple suffit. Pour un formateur ou un renommage sur toute la base de code passé par un agent, utilisez le journal en ajout seul.

Ce que l'adressage par contenu fait gagner, et ce qu'il ne fait pas gagner

Nommer les blobs par leur SHA-256 déduplique les octets identiques et rien d'autre. Trois mesures montrent ce que ça donne.

Sur toute l'arborescence, 4,686 fichiers avaient 4,677 contenus distincts, donc un tour sur l'arborescence complète a stocké 4,677 blobs. Les fichiers en double sont rares dans une vraie arborescence de sources.

Un fichier de 2 MB réécrit sur dix tours, l'agent ajoutant une ligne à chaque fois : dix pré-images, toutes différentes, 10 blobs et 20.0 MB. Même fichier, mais l'agent écrit les mêmes octets à chaque tour, comme le fait une nouvelle tentative qui produit une sortie identique : 2 blobs et 4.0 MB, l'original et la version dont partait chaque tour suivant. Dans les deux cas, revenir au premier tour a rendu l'original octet par octet.

Les blobs sont stockés bruts. Les 121.4 MB de blobs du tour sur l'arborescence complète feraient 33.7 MB avec zlib au niveau 6, et le propre stockage d'objets de git pour la même arborescence après un commit faisait 41.0 MB. La compression est un ajout raisonnable si le stockage est conservé longtemps, puisque le code source se compresse à environ 3.6 pour 1. Des deltas entre versions, comme dans les pack files de git, feraient gagner davantage dans le cas de l'ajout d'une ligne, mais demandent bien plus de code qu'un stockage de pré-images n'en a besoin.

Limites de cette version

Elle ne voit que les écritures qui passent par write(). Si votre agent peut aussi lancer des commandes shell, un rm ou un sed -i passe complètement à côté. L'application osFoundry fait passer les écritures de fichiers de l'agent par une seule fonction, et c'est là qu'elle capture.

Une restauration écrit la pré-image quel que soit le contenu actuel du fichier. Si vous avez modifié un fichier à la main après l'agent, revenir sur ce tour remplace aussi votre modification. Une version plus stricte enregistre le hash de ce que l'agent a écrit et, avant de restaurer, vérifie si le fichier a toujours ce hash. Sinon, elle sauvegarde d'abord les octets actuels comme un blob de plus, pour que la restauration puisse elle aussi être annulée.

Le stockage grossit aussi tant qu'aucun tour n'est annulé. Un prune qui supprime les blobs qu'aucun checkpoint ne référence, plus une limite sur le nombre de tours à conserver, suffit.

Les deux blocs de classe ci-dessus constituent tout le stockage, en Python 3.9 avec la bibliothèque standard, rien à installer. L'application de bureau osFoundry intègre la version Rust sous la forme de /rewind dans les chats qui travaillent dans un dossier local, gratuitement depuis la page de téléchargement d'osFoundry.