Skip to content

9 min read

Roll back exactly the paths the patch touched

On this page

The recovery step was three words long and it had been in the tool since the first week:

python
def rollback():
    subprocess.run(["git", "checkout", "--", "."], cwd=repo_root, check=True)

It fired at 16:20 on a Thursday, after a tier 2 edit failed to compile its reverse dependencies. It restored platform/schemas/order.py and the regenerated client, which is what it was there to do. It also restored packages/notifications/src/digest.ts, packages/notifications/src/templates/weekly.tsx and two test files, which the engineer whose laptop this was had been writing since about 15:40 and had not committed, because she was halfway through the change and it did not compile yet either. Forty minutes of work, gone in the time it takes git to walk a tree. She had also spent part of the morning staging a specific hunk in services/api/handlers.go with git add -p, intending it as the first of two commits; that survived, because git checkout -- . copies from the index outward, but the additional unstaged edits she had layered on top of it did not. The index there is git's staging area, the set of file contents that the next git commit will record, and it is a third state sitting alongside the last commit and the bytes on disk. A rollback that reasons only about file contents leaves that third state saying something the working tree no longer agrees with, which is a quieter form of the same damage.

What each git verb actually damages

The reflex to reach for a git command here is reasonable, since git is the thing that knows what the tree used to look like. The problem is that every ready-made git verb operates on a scope defined by git — the whole tree, the whole index, all tracked files — and the scope the rollback needs is defined by the patch.

MechanismHuman's unstaged editsHuman's staged indexUntracked filesAgent's new filesVisible to the human during the run
git checkout -- .Destroyed, everywhereKept, but layered edits lostUntouchedSurviveTree unchanged until it fires
git checkout HEAD -- .Destroyed, everywhereDestroyedUntouchedSurviveTree unchanged until it fires
git stash pushRemoved at start, restored at popFlattened into the stashSkipped entirelyCollide at popTree is missing her work for the whole run
git stash push -uRemoved at startFlattenedRemoved at startCollide at popSame, plus her scratch files vanish
git clean -fdUntouchedUntouchedDeleted, including .envDeletedTree unchanged until it fires
Scoped snapshot restoreUntouchedUntouchedUntouchedDeletedTree unchanged until it fires

Two rows on that table are worth reading twice. git checkout -- . leaves untracked files alone, which sounds like restraint until you notice what an agent's untracked files are: the new module it created, the new test fixture, the new migration. The one thing a rollback most needs to remove is the one thing that verb reliably keeps, so a "clean checkout" recovery leaves the agent's half-finished new files in the tree and takes the human's edits instead — precisely inverted.

The stash rows are inverted in a different way. Stashing before the run and popping afterwards is the suggestion people make with the most confidence, and it removes the engineer's work from the working tree for the duration of the run. The agent then reads, edits and tests a tree that does not match what she is looking at in her editor, which produces its own class of confusion when she saves a file mid-run and the save is against content that is about to be replaced by a pop. The pop conflicts whenever the agent touched any file she had touched, leaving her to resolve a three-way merge she did not ask for between her own work and a patch that has already been declared a failure. And the default skips untracked files, so the agent's new modules sail through untouched while hers are the ones that were hidden.

Capture the paths, not the tree

The mechanism that fits is a snapshot scoped to the paths the patch declares, taken immediately before the write and discarded when the edit is accepted. The patch already names its paths, so the scope is known before anything is modified.

python
@dataclass
class PathSnapshot:
    path: str
    existed: bool
    content: bytes | None          # None when the path did not exist
    mode: int | None               # 0o100644, 0o100755, 0o120000 for a symlink
    symlink_target: str | None
    index_entry: tuple[str, str] | None   # (mode, blob sha) from the index, or None
    sha_at_snapshot: str | None    # to detect a third-party write before restore

def snapshot(paths: list[str], repo: Repo) -> list[PathSnapshot]:
    out = []
    for p in paths:
        full = repo.root / p
        exists = full.exists() or full.is_symlink()
        st = full.lstat() if exists else None
        is_link = exists and stat.S_ISLNK(st.st_mode)
        data = None if (is_link or not exists) else full.read_bytes()
        out.append(PathSnapshot(
            path=p,
            existed=exists,
            content=data,
            mode=st.st_mode if exists else None,
            symlink_target=os.readlink(full) if is_link else None,
            index_entry=repo.index_entry(p),      # git ls-files -s -- <p>
            sha_at_snapshot=sha256(data) if data is not None else None,
        ))
    return out

The field that separates this from a naive content copy is index_entry. A rollback that restores file contents and leaves the index alone puts the engineer's git add -p work into an inconsistent state: git status will show her staged hunk as staged, git diff --cached will show a diff against a working tree that no longer contains it, and the commit she makes an hour later contains a hunk she thought she had already reconciled. Capturing the index entry is one git ls-files -s per path, and restoring it is one call into git's plumbing, the low-level command layer that reads and writes the repository's data structures directly rather than through the porcelain commands people type:

bash
# capture
$ git ls-files -s -- platform/schemas/order.py
100644 4b825dc642cb6eb9a060e54bf8d69288fbee4904 0	platform/schemas/order.py

# restore, after the working-tree content is back
$ git update-index --add --cacheinfo 100644,4b825dc642cb6eb9a060e54bf8d69288fbee4904,platform/schemas/order.py

# for a path that was untracked before the edit and got staged during it
$ git update-index --force-remove -- platform/schemas/currency_map.py

The blob is already in the object database, because it was staged before the run, so no content has to be recreated to make that work. The restore of the working tree goes through the same atomic write from chapter 3, for the same reason: a rollback interrupted halfway is a worse state than the one it was trying to repair.

python
def restore(snaps: list[PathSnapshot], repo: Repo) -> None:
    for s in snaps:
        full = repo.root / s.path
        if not s.existed:
            full.unlink(missing_ok=True)          # a file the patch created
            prune_empty_parents(full.parent, stop=repo.root)
        elif s.symlink_target is not None:
            full.unlink(missing_ok=True)
            os.symlink(s.symlink_target, full)
        else:
            write_atomic(str(full), s.content)     # bytes, not text
            os.chmod(full, stat.S_IMODE(s.mode))
        if s.index_entry is None:
            repo.index_force_remove(s.path)
        else:
            repo.index_set(s.path, *s.index_entry)

Deleting the files the patch created is the half people forget, and prune_empty_parents is the tail of it: an agent that creates platform/schemas/currency/ to hold one new module leaves the directory behind otherwise, and an empty package directory changes import behaviour in some languages and confuses build-graph queries in most. Modes are restored explicitly because a hook that regenerated a script can drop the executable bit, and write_atomic creates its temporary file at 0600. File modification times are deliberately not restored: a build system keyed on mtime will do one unnecessary rebuild after a rollback, which costs seconds, while a restored old mtime on content that was briefly different is exactly how a stale object file survives into a build.

The whole snapshot for a typical three-file patch is under 200 kB held in memory for the duration of one apply-and-validate cycle, so there is no reason to write it anywhere. For large files or long-running validation it is worth spilling to a session directory outside the repository, because a snapshot stored inside the working tree becomes an untracked file that the agent's next git status reads and reasons about.

How far back is back

A task is usually several edits, so there are several snapshots, and the question of which one to restore has a default that is wrong. Restoring to the start of the task is the intuitive choice, because it produces the cleanest state, and it throws away every edit the loop got right on the way to the one it got wrong. In a five-edit task where the fifth edit fails to compile, restoring all five means the model has to reconstruct four correct patches from its own summary of them, which is both expensive and the situation where models most reliably reintroduce a variant of an earlier mistake. Restoring only the failed edit leaves the four good ones in place and hands the model a tree that matches the last state it validated.

That means snapshots stack rather than merge: one per edit, discarded when that edit is accepted, and the restore always unwinds the most recent. Unwinding further is a separate operation that belongs to task abandonment rather than edit failure, and when it happens it should replay the stack in reverse rather than restoring a single task-start snapshot, so that a path touched by edits two and four is restored through both records and ends up at its original content by the same route it left.

There is a prior question about whether to roll back at all. A failed compile after a written edit leaves a tree the model can act on: the error message names a file and a line, the file is on disk, and a follow-up edit that adds the missing import is a smaller and more reliable operation than regenerating the whole patch. Of the 118 rejected edits from the previous month, 71 failed a check that named a specific fixable defect, and in the 44 of those where the loop fixed forward instead of restoring, the median cost was 1.2 additional turns against 2.6 for the restore-and-retry path. The rule that came out of that data is to fix forward when the failure is a check with a located, actionable message and the tree is internally consistent, and to restore when it is not — a partial multi-file write, a file on disk that does not parse, a failure the model has already attempted twice, or the loop deciding to exit. Restoring is the mechanism that guarantees the tree is never left in a state nobody chose; it is not the mechanism for handling every red result.

When the right answer is to stop

Automatic rollback is safe only while the machine can be confident that putting the old bytes back restores the state it snapshotted. Several situations break that confidence, and in each of them a rollback does more damage than the failure it is responding to.

The first is a third-party write. If sha_at_snapshot no longer matches what is on disk for one of the snapshotted paths, someone else changed that file during the run — the engineer in her editor, a generator, a rebase in another terminal. Restoring the snapshot would overwrite an edit the agent never saw and cannot describe. The correct behaviour is to leave the tree alone, report the exact paths and both digests, and say what the failed edit was so the human can decide, which is the same detection mechanism from chapter 2 used at the other end of the cycle.

The second is side effects outside the snapshot's scope. A tier 3 edit that reached a migration and applied it to a development database, an agent that already ran git commit, a generator that wrote artefacts to paths the patch never declared, a package published to a local registry — the file contents can be restored and the world cannot. Restoring the source of a migration that has already run against a database leaves the schema and the code disagreeing, which is strictly worse than leaving both wrong in a matching way.

The third is an incomplete snapshot. If a path could not be read at capture time — a permissions error, a file that vanished between the patch being planned and the snapshot being taken — then the snapshot covers some of the patch and not the rest, and a partial restore produces a tree that matches no version that ever existed. Refusing before writing anything is the only honest option, and it is the argument for taking the snapshot as a single all-or-nothing step before the first byte is written.

The fourth is the case where the patch has already been reviewed or shared. Once a branch is pushed or a pull request is opened, the working tree is no longer the only copy, and a silent local revert leaves the remote holding the broken version. That is a coordination problem, not a filesystem problem, and coordination problems belong to people.

Refusal has to be loud enough to be actionable, which means naming the paths, the digests, the failing check, and the fact that the tree currently contains an edit that did not pass. An agent that stops in a modified tree and says so clearly is a manageable situation; one that stops silently leaves an engineer to discover an unexplained diff on Monday.

Rollback is now a bounded, precise operation, which changes the calculus of the whole cycle: it is a mechanism you would rather never invoke, and every check that runs before the write is a check whose failure costs nothing at all. Ordering the checks around that moment — what runs on a string, what runs on a file, and where the write itself sits — is the last piece of the design.

Patch application and conflict handling — read the current version at https://daviddominguez.dev/learn/en/patch-application-and-conflict-handling/roll-back-exactly-the-paths-the-patch-touched

Back to top