recovering-a-clobbered-file

SkillFiles & storage

Use when a file may have been overwritten or truncated by a whole-file write, a shell redirect or an editor write: finding which revision holds the pre-write content, reading the tells that it happened, restoring it, and proving the restore reinstated only what was lost. Also when judging whether a green build says anything about a changed config file.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the recovering-a-clobbered-file skill

What this skill tells your AI

The instructions your AI receives, as published by nextlyhq/nextly in .claude/skills/recovering-a-clobbered-file/SKILL.md and read by ahel’s review.

First, name the BASELINE — everything below compares against it

Every check in this rule asks one question: what was at this path before the write. So the baseline is chosen once, and each command follows from it. Getting this wrong is not a smaller version of the same answer — it silently swaps which content is being judged.

The question is not "which of these two is it". Any of the last commit, the index, a retained stash, or nothing in git at all can hold that content — an untracked or ignored file was never in either, a stash holds edits that were reapplied and then clobbered, and edits that were only ever in the working tree are in none of them. Enumerating the cases cannot be complete, because which revision holds the content depends on how the work reached the tree; what settles it is naming the revision and CHECKING it holds what you expect:

  • git show <rev>:<path> prints a candidate's copy — HEAD, a stash, any commit — and git show :<path> prints the index copy. Read the one you intend to restore from BEFORE restoring, and confirm it contains the pre-write content rather than assuming it does.

  • A command that prints nothing has not shown you the file is absent. It is equally the shape of asking the wrong question, and two ways of doing that are easy to hit here. <rev>:<path> names a path inside the revision's TREE, so the absolute path a symlink resolver just produced is not a valid argument and every candidate reads as missing; convert to repository-relative first. And a path that was untracked when it was stashed is not in the stash commit at all — git stash -u puts it in the stash's THIRD parent, reachable as stash@{n}^3:<path> — so the obvious query says absent while git holds an exact copy.

  • The reading commands follow from that choice. Against a commit, git diff <rev> -- <path>; against the index, the bare git diff -- <path>, which is working tree versus index. Note that git status shows only MM and reveals neither.

  • Those forms only compare TRACKED content. If the recovered path is untracked — the stash@{n}^3 case above is the common one — git diff <rev> reports the baseline as deleted and says nothing about what the file now holds, whatever that is. Measured: with the original in the stash and replacement text on disk, it prints -ORIGINAL and no + line at all, so a clobber reads as a plain deletion. Materialise the candidate into a file created for the purpose, and compare with git diff --no-index, which reads both sides from the filesystem and shows -ORIGINAL +CLOBBERED:

    base="$(mktemp)" && git show <rev>:<path> > "$base" && git diff --no-index "$base" <path>
    

    mktemp rather than a fixed name, and one && chain rather than separate steps, for the reason this whole rule exists: > /tmp/base truncates whatever is already at that path — through a symlink included — and two recoveries at once overwrite each other's baseline. Chaining also stops a failed git show leaving an empty file that the comparison then reads as recovered content.

Before concluding git holds nothing, look at the objects no ref points at. Content staged and then reset out of the index is unreachable rather than gone: git fsck --unreachable lists the blob and git cat-file -p <sha> prints it. Measured — a file staged with deliberate content, unstaged, then clobbered is absent from git show :<path> and recovered verbatim from its unreachable blob. That is the last place to look, and no <rev>:<path> form above reaches it.

If no revision and no unreachable object holds it — and you have established that rather than inferred it from an empty result — stop. Say so and look outside git — editor local history, a backup, an open buffer. A recovery that cannot recover is worth naming as one; running the steps anyway converts a known loss into an unnoticed one, because the resulting diff looks entirely clean.

Both failure directions are live, which is why this is not a detail. Comparing against HEAD when the index is the baseline reports the staged additions as though they were your edit — and, worse, a clobber that preserved everything in HEAD while destroying the staged lines shows additions ONLY, clearing the overwrite that just happened. Comparing against the index when HEAD is the baseline misses anything already staged.

Three tells that this has happened, in the order they appear

They are worth knowing individually, because each looks like good news:

  1. The diffstat shows deletions on a file you believe you are creating, read against the baseline named above. A created file has no deleted lines, so one - in its ++--- bar settles it — but only when the comparison starts from before the file existed.

    Read against the wrong baseline it answers a different question, and a new path that was STAGED is the case that makes this concrete. The index holds its pre-write content and HEAD does not, so the index IS the baseline — the bare git diff --stat is the right command, and it correctly shows deletions when that staged content was clobbered. Reaching for HEAD there compares against a revision where the path does not exist at all, which reports additions only and clears the overwrite. The baseline section decides this; the tell just uses what it decided. This is the cheapest tell and the one most easily read past, because by then the write has already succeeded and attention has moved on.

  2. A metric improves far more than the change should explain. "1264 inputs became 119" was recorded as evidence that the new scoping was tight. It was evidence that inputs had been REMOVED. A number that moves an order of magnitude in the direction you were hoping for is the moment to ask which change produced it, not to write it down as a result.

  3. Content you did not write, and did not mean to remove, is gone. Conclusive, and it has to be stated as the disappearance rather than as the suspicion that led there.

    Two of these three settle it and one does not, so the split is worth stating plainly rather than by rank: a deletion against the correctly chosen baseline (1) and vanished content (3) are each conclusive on their own; the metric (2) is only ever a prompt to go and look, because a number can move for reasons that have nothing to do with a write.

    What led there in the real case was noticing that the replaced file already used $TURBO_EXTENDS$ — the mechanism the new comment introduced as if it were absent. That is a good prompt to go and look, and it is NOT evidence: adding $TURBO_EXTENDS$ to test:coverage when test already uses it means the file was also "already doing the thing", with nothing overwritten at all. A tell that fires there sends a correct additive edit into a destructive recovery procedure, which is worse than the miss it was guarding.

    So confirm it against the baseline named above — the --stat, then the hunks — and require content that predates your edit to have vanished.

Configuration coverage is UNEVEN, so find out before trusting green

The clobber survived lint, check-types and the full unit suite. The reason is narrower than "configuration is untested", and the narrow version is the useful one, because the broad version is false: nine suites in this repo read build configuration and assert on it. packages/blocks-engine/src/typecheck-config.test.ts parses tsconfig.json, tsconfig.tests.json and package.json and pins exact settings; packages/builder/src/layering.test.ts parses vitest.config.ts and asserts its include. For a property one of those covers, a green run IS corroboration.

turbo.json has no such suite, which is why this one went through. So the question to answer before reading green as evidence is not "is this a config file" but "does anything read the property I just changed" — and the answer varies by file, by package, and by which field inside it.

The consequence of getting that wrong is delayed and looks unrelated to the diff. A dropped inputs entry means the hash no longer moves when that input changes, so turbo replays a cached pass over code nothing ran against. A dropped dependsOn edge means two tasks that were ordered may now be scheduled together. Neither fails at the moment of the edit, and by the time either surfaces the diff that caused it mentions none of the symptoms.

$TURBO_EXTENDS$ means the package config ADDS

Two package configs use it — packages/ui/turbo.json and packages/blocks-react/turbo.json — and both rely on the property: $TURBO_EXTENDS$ inside dependsOn or inputs interpolates the ROOT task's list at that position. ["$TURBO_EXTENDS$", "build"] is "everything the root task depends on, plus this package's own build".

So a package task list is an APPENDIX, never a replacement, and writing one from scratch is a silent subtraction: the file still parses, turbo still runs, and the inherited entries are simply not there. To add an input, append to the existing array. If you find yourself composing the whole array, you are about to drop whatever the root supplies.

Restoring, and proving the restore

Recovery is deliberately not a procedure here. Which revision holds a given pre-write state depends on how the work reached the tree — committed, staged, stashed, or never recorded — and a manual that covers most of those and is silent about the rest reads as complete, which is worse than being brief. Three things are worth stating; the rest is the situation in front of you.

1. Identify which revision actually holds the pre-write content, and read it, before running anything. That is the baseline question above, including its stop condition. No command is right by default.

2. The restore does not carry your edit. Re-apply it on top as the delta it actually was — an append only where the edit was additive; for a removal or a replacement, appending duplicates a setting or reinstates content meant to go.

3. Prove it BEFORE submitting, against the same baseline. Expect only the edit you meant. This step is not optional, because the clobber and the restore both live inside one PR: the branch diffstat nets out and reads as though nothing happened, and that summary is exactly what hides the loss. The merge-commit check in the verifying-merged-work skill is a separate, post-merge confirmation — by the time it can run, a broken config has already merged.

Two commands are worth knowing because their names mislead: git checkout -- <path> restores from the INDEX rather than a commit, and git checkout origin/main -- <path> answers "what does main have", which is a different question from "what was here before my write" and quietly imports upstream changes when main has moved.

Signals

GitHub stars
57
Forks
6
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
recovering-a-clobbered-file
Source
github.com/nextlyhq/nextly