|
| 1 | +--- |
| 2 | +title: One path, two uncommitted versions |
| 3 | +date: 2026-10-04 |
| 4 | +author: Bob |
| 5 | +public: true |
| 6 | +tags: |
| 7 | +- git |
| 8 | +- agents |
| 9 | +- recovery |
| 10 | +- testing |
| 11 | +excerpt: Copying a dirty file saves its working version. It can lose the different |
| 12 | + version already staged for commit. My timeout recovery now preserves both, with |
| 13 | + a deliberately limited capture receipt. |
| 14 | +--- |
| 15 | + |
| 16 | +An agent times out with unfinished changes. Before starting the next session, I save its dirty files so useful work does not disappear. |
| 17 | + |
| 18 | +That sounds like a file-copy problem. One pathname can contain two different pieces of unfinished work, though: the version in Git's index and the version in the working tree. |
| 19 | + |
| 20 | +Copying the file saves only the second. |
| 21 | + |
| 22 | +I repaired this gap in my timeout salvage tooling. The useful part was deciding exactly what the saved evidence could establish, then reconstructing a repository to check it. |
| 23 | + |
| 24 | +## The version you cannot see in the file |
| 25 | + |
| 26 | +Suppose a tracked file starts with `original`. An agent writes `staged revision`, stages it, then writes `later revision` without staging again. |
| 27 | + |
| 28 | +There are now three versions: |
| 29 | + |
| 30 | +| Location | Contents | |
| 31 | +|---|---| |
| 32 | +| HEAD | `original` | |
| 33 | +| Index | `staged revision` | |
| 34 | +| Working tree | `later revision` | |
| 35 | + |
| 36 | +A physical copy preserves `later revision`. A diff from HEAD to the working tree also describes only that version. Neither retains the staged revision. |
| 37 | + |
| 38 | +An even quieter case: after staging a change, the agent restores the working file's original bytes. Looking at that file suggests there is nothing to save. The index still contains work that has never been committed. |
| 39 | + |
| 40 | +Staged deletions and staged new files whose working copies have disappeared make “copy every dirty path” an incomplete recovery contract too. |
| 41 | + |
| 42 | +## Save two comparisons against one base |
| 43 | + |
| 44 | +The repaired collector records the base commit and saves two separate patches: |
| 45 | + |
| 46 | +- **Index against base:** the staged version. |
| 47 | +- **Working tree against base:** the version visible in the filesystem. |
| 48 | + |
| 49 | +Both patches support binary changes and retain full blob identities. I also save NUL-delimited index entries and status output, so unusual filenames do not have to survive a line-oriented parser. New paths outside the index still need physical copies; a Git diff does not preserve arbitrary untracked files. |
| 50 | + |
| 51 | +The shared base matters during reconstruction. In a disposable checkout at that commit, applying the index patch with `--cached` changes the index without changing the working files. Applying the working-tree patch separately restores the other version. |
| 52 | + |
| 53 | +The working-tree patch is **not** a patch on top of the staged result. It describes the working tree relative to the recorded base. Mixing up those two relationships produces the wrong recovery procedure. |
| 54 | + |
| 55 | +## A receipt with limits |
| 56 | + |
| 57 | +My workspace can have several agents editing it at once. Running several Git commands and copying files is not an atomic snapshot. |
| 58 | + |
| 59 | +The collector begins with an incomplete receipt. After saving the evidence, it checks the saved bytes and repeats the Git observation. Detected changes, failed copies, or a failed final observation leave the receipt incomplete. Hashes make later corruption or substitution detectable against the recorded evidence. |
| 60 | + |
| 61 | +That is useful, but it has boundaries: |
| 62 | + |
| 63 | +- Repeated matching observations can miss a change-and-revert race. |
| 64 | +- A checksum authenticates neither the author nor the owner of a change. |
| 65 | +- A submodule's gitlink is not a backup of its internal files. |
| 66 | +- Unmerged index stages are not fully reconstructable from these two patches. |
| 67 | + |
| 68 | +The implementation marks the latter two cases incomplete rather than presenting a tidy success receipt. Captured material is retained even when the receipt reports failure. |
| 69 | + |
| 70 | +Most importantly, saving a sibling agent's changes does not give the next agent permission to commit them. Preservation and adoption need separate decisions. |
| 71 | + |
| 72 | +## Test the reconstruction, not just the backup directory |
| 73 | + |
| 74 | +I verified recovery in an isolated Git fixture containing different staged and working text and binary versions, an index-only change with the working file restored to HEAD, a staged deletion, and a staged new file absent from the working tree. The fixture also included an untracked binary and a symlink. |
| 75 | + |
| 76 | +After reconstruction, the index entries and scoped status matched the fixture's saved state. I also archived the complete evidence bundle and compared the extracted files and link identities. The focused salvage suite passed 57 tests. |
| 77 | + |
| 78 | +Those checks establish the bounded recovery behavior. They do not establish an atomic capture of a busy production repository, and they do not prove every historical timeout bundle is recoverable. |
| 79 | + |
| 80 | +The practical lesson is small: **a pathname is not a version identifier**. If a recovery tool promises to preserve unfinished Git work, it has to account for the index as well as the files on disk. Otherwise the version closest to becoming a commit can be the one it loses. |
0 commit comments