Skip to content

Build1 publisher3 min readPublished

Git keeps a deleted API key because the commit that added it is still reachable

A commit stores a complete snapshot of content-addressed objects, so committing a removal writes a new tree and leaves the old blob reachable. Two commands read the key back, and a fresh clone still carries it.

The Engineer · Build desk

Illustration accompanying Git keeps a deleted API key because the commit that added it is still reachable

What happened

  • A dev.to post walks through a committed API key that is still readable with two commands after the file is removed, the removal is committed, and the branch is pushed.
  • git gc leaves the blob alone because the commit that introduced the file is still reachable, so the blob it points to is reachable, and gc removes only what nothing points to.
  • Writing the same line into a hundred files and committing produces one blob object, because an object's name is a hash of its content, so identical content is stored once.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure Everyone who cloned can read the leaked value, checked out or not, and the server-side history gives you no list of them.
  • decision Once the commit is pushed, the choice is to make that commit unreachable in every copy that exists or to invalidate the credential itself, and only the second finishes without other people's cooperation.
  • capability The same two commands work as an audit of any repository you inherit: list every file anyone ever deleted, then print the contents of the ones that look like configuration.
  • constraint The packing figure is a claim about appending single lines to one large text file, so a repository of rewritten or already-compressed content gives you no reason to expect the same ratio.

A branch points at a commit, the commit points at a tree, and the tree is a list of names and content hashes [5]. Commit the removal and you write a new tree without that name. The old tree still carries the name and the hash, because the old commit still points at it [1]. The file is gone from the working tree. Nobody was looking there. Two commands get the value back: one lists every file ever deleted across all refs, the other prints any of them out of the commit that carried it [2].

The object count shows the same structure without a secret in it. Three files in one commit, one byte changed in one of them, commit again, and the repository holds eight objects: two commits, two trees, four blobs [4]. The blob count stays at four because the second commit's tree reuses the hashes of the two files you did not touch [5]. No diff is stored anywhere in that set; `git show` computes one at display time [6].

The size question is the one the post treats as hardest, and it answers with measurements. Fifty-one commits appending a line to a 200,000-line file left every object in place, and packing kept a base with a chain of deltas [8]. On a second machine running git 2.43.0 the same script gave 24276 KiB loose and 468 KiB packed, one object stored in full and fifty as deltas [9]. That is a ratio of about 51.9 to 1 [15]. The entire packed history of that file is smaller than one loose copy of it: divide the loose figure by 51 objects and the average is 476 KiB [14].

Appending one line at a time to a large text file is close to the best case for a delta chain, and the post says as much: the ratio depends on your git version and on how the file changes, while only the shape holds [10]. The claim the measurement actually supports is narrower and more useful: "not deleted" and "not stored twice" are different statements [16]. For the 51.9 figure to transfer, your history has to change the way that history changed.

Two corrections in the post are worth copying into your own notes. The reflog window is thirty days for anything your branches can no longer reach, which is always the case after a reset, an amend or a rebase, and the ninety-day window applies to entries still reachable from a branch [11]. A fresh clone contains the deleted object and checks out a working tree without it [12].

Every number comes out of a bash script the post publishes, which runs `git init` in a temporary directory with `commit.gpgsign` disabled and prints the object counts [17]. The author invites readers to run it and post their results with their git version, and wrote: "I would rather fix the article than defend it." [13]

That leaves the response to an actual leak. The value is readable in every clone that already exists [12], and it is readable because the commit that introduced it is still reachable [3]. Making that commit unreachable means rewriting history other people already hold, and you can change the secret without finding them.

What to watch

  • Readers running the published script on other git versions and posting loose and packed sizes, which would show how version-dependent the 51.9 to 1 figure is.
  • The same experiment on a file whose lines are rewritten in place, where the delta chain should behave differently.
  • Whether the author revises the article when a submitted measurement disagrees with the published numbers.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories