<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Internals on Londopy</title><link>https://londopy.github.io/blog/tags/internals/</link><description>Recent content in Internals on Londopy</description><image><title>Londopy</title><url>https://londopy.github.io/blog/og-image.png</url><link>https://londopy.github.io/blog/og-image.png</link></image><generator>Hugo</generator><language>en-us</language><copyright>2026 Londopy · Posts CC BY 4.0 · Code MIT</copyright><lastBuildDate>Thu, 24 Sep 2026 01:52:31 -0700</lastBuildDate><atom:link href="https://londopy.github.io/blog/tags/internals/index.xml" rel="self" type="application/rss+xml"/><item><title>Your .git Folder Is a Database. Let's Read It.</title><link>https://londopy.github.io/blog/posts/git-internals/</link><pubDate>Thu, 15 Oct 2026 09:00:00 -0700</pubDate><guid>https://londopy.github.io/blog/posts/git-internals/</guid><description>Git isn&amp;#39;t magic. It&amp;#39;s a small, clever key-value store with a few text files on top. Here&amp;#39;s how to explore it by hand.</description><content:encoded><![CDATA[<p>Every Git repo has a hidden <code>.git</code> folder. Most people never open it. That&rsquo;s where all of Git lives: every version of every file, every branch, every commit message. And it&rsquo;s surprisingly simple once you look.</p>
<p>Let&rsquo;s take it apart.</p>
<h2 id="setup">Setup</h2>
<p>Make a throwaway repo so we can poke at it safely:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">mkdir git-internals <span class="o">&amp;&amp;</span> <span class="nb">cd</span> git-internals
</span></span><span class="line"><span class="cl">git init -b main
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;hello&#34;</span> &gt; hello.txt
</span></span><span class="line"><span class="cl">git add hello.txt
</span></span><span class="line"><span class="cl">git commit -m <span class="s2">&#34;first commit&#34;</span>
</span></span></code></pre></div><p>Now look inside:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">ls .git
</span></span></code></pre></div><p>You&rsquo;ll see things like <code>HEAD</code>, <code>config</code>, <code>index</code>, <code>objects/</code>, and <code>refs/</code>. We&rsquo;ll go through the important ones.</p>
<h2 id="objects-the-actual-data">Objects: the actual data</h2>
<p>Git stores everything as <strong>objects</strong> in <code>.git/objects</code>. There are four kinds:</p>
<ul>
<li><strong>blob:</strong> the contents of a file (just the bytes, no name)</li>
<li><strong>tree:</strong> a directory listing (names, permissions, and pointers to blobs and other trees)</li>
<li><strong>commit:</strong> a snapshot (a pointer to one tree, parent commits, author, message)</li>
<li><strong>tag:</strong> an annotated tag (a pointer to an object, plus a message)</li>
</ul>
<p>Every object is named by the hash of its contents. That&rsquo;s why Git is called a <strong>content-addressable</strong> store: the address <em>is</em> the content&rsquo;s fingerprint.</p>
<h2 id="finding-our-file">Finding our file</h2>
<p>Ask Git what hash &ldquo;hello\n&rdquo; would get as a blob:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;hello&#34;</span> <span class="p">|</span> git hash-object --stdin
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">ce013625030ba8dba906f756967f9e9ca394464a
</span></span></code></pre></div><p>You&rsquo;ll get this exact hash on any machine, in any repo. Same content, same hash, always.</p>
<p>Now find it on disk. Git splits the hash: the first two characters are a folder, the rest is the file name:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">ls .git/objects/ce/
</span></span></code></pre></div><p>There it is. The file is zlib-compressed, so <code>cat</code> shows garbage. Git can read it for us:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git cat-file -t ce0136   <span class="c1"># type</span>
</span></span><span class="line"><span class="cl">git cat-file -p ce0136   <span class="c1"># contents</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">blob
</span></span><span class="line"><span class="cl">hello
</span></span></code></pre></div><p>Or decompress it yourself with Python to see the raw format:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">python3 -c <span class="s2">&#34;import zlib,sys; print(zlib.decompress(open(sys.argv[1],&#39;rb&#39;).read()))&#34;</span> .git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">b&#39;blob 6\x00hello\n&#39;
</span></span></code></pre></div><p>That&rsquo;s the entire format: the type, a space, the size in bytes, a null byte, then the content. The hash is SHA-1 of exactly that string.</p>
<h2 id="walking-from-a-commit-down-to-a-file">Walking from a commit down to a file</h2>
<p>Start at the latest commit:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git cat-file -p HEAD
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">tree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
</span></span><span class="line"><span class="cl">author ...
</span></span><span class="line"><span class="cl">committer ...
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">first commit
</span></span></code></pre></div><p>Follow the tree:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git cat-file -p aaa96c
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">100644 blob ce013625030ba8dba906f756967f9e9ca394464a    hello.txt
</span></span></code></pre></div><p>That&rsquo;s the whole chain: <strong>commit → tree → blob</strong>. A commit doesn&rsquo;t store differences; it points to a full snapshot. Git saves space because unchanged files keep the same hash, so every commit just points to the same blob again.</p>
<p>(Your commit hash will differ from mine, because a commit includes your name and the exact time. The tree and blob hashes will match, because they depend only on file names and contents.)</p>
<h2 id="refs-names-for-hashes">Refs: names for hashes</h2>
<p>Nobody wants to type 40-character hashes. <strong>Refs</strong> are files that hold them:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">cat .git/refs/heads/main
</span></span></code></pre></div><p>That&rsquo;s it. A branch is a text file containing a commit hash. Creating a branch is writing one small file. That&rsquo;s why branches in Git are so cheap.</p>
<p>And <code>HEAD</code>?</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">cat .git/HEAD
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">ref: refs/heads/main
</span></span></code></pre></div><p><code>HEAD</code> usually points to a branch, which points to a commit. When you&rsquo;re in &ldquo;detached HEAD&rdquo; state, <code>HEAD</code> contains a commit hash directly instead.</p>
<p>If a branch seems to be missing from <code>refs/heads</code>, check <code>.git/packed-refs</code>. Git bundles refs into that one file to save space.</p>
<h2 id="packfiles-how-git-stays-small">Packfiles: how Git stays small</h2>
<p>Storing every version of every file as a separate compressed object would get big. So Git periodically <strong>packs</strong> objects into <code>.git/objects/pack/</code>, storing similar objects as deltas (differences) against each other.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git gc
</span></span><span class="line"><span class="cl">git count-objects -v
</span></span></code></pre></div><p>Loose objects disappear into a <code>.pack</code> file with a matching <code>.idx</code> index. To peek inside:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git verify-pack -v .git/objects/pack/pack-*.idx <span class="p">|</span> head
</span></span></code></pre></div><p>Interesting detail: this is the <em>only</em> place Git uses deltas. Your history is modeled as snapshots; deltas are just a storage optimization underneath.</p>
<h2 id="the-index-the-staging-area-is-a-file">The index: the staging area is a file</h2>
<p><code>git add</code> writes to <code>.git/index</code>, a binary file listing what the next commit will contain. You can see it in readable form:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git ls-files --stage
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">100644 ce013625030ba8dba906f756967f9e9ca394464a 0	hello.txt
</span></span></code></pre></div><p>The staging area isn&rsquo;t a concept. It&rsquo;s a file.</p>
<h2 id="the-reflog-your-safety-net">The reflog: your safety net</h2>
<p>Every time <code>HEAD</code> or a branch moves, Git writes a line to <code>.git/logs/</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git reflog
</span></span></code></pre></div><p>This is how you recover from almost anything: a bad reset, a deleted branch, a rebase gone wrong. Find the hash from before the mistake and:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">git branch rescue &lt;hash&gt;
</span></span></code></pre></div><p>Unreachable objects are only deleted by garbage collection after a grace period (two weeks by default for loose objects). Reflog entries last 90 days, but only 30 for commits that are no longer on any branch, which is what a bad reset or rebase leaves behind. You have time.</p>
<h2 id="build-a-commit-with-no-porcelain">Build a commit with no porcelain</h2>
<p>For the grand finale, make a commit using only low-level commands:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;built by hand&#34;</span> <span class="p">|</span> git hash-object -w --stdin
</span></span><span class="line"><span class="cl"><span class="c1"># note the hash, then:</span>
</span></span><span class="line"><span class="cl">git update-index --add --cacheinfo <span class="m">100644</span> &lt;blob-hash&gt; handmade.txt
</span></span><span class="line"><span class="cl">git write-tree
</span></span><span class="line"><span class="cl"><span class="c1"># note the tree hash, then:</span>
</span></span><span class="line"><span class="cl">git commit-tree &lt;tree-hash&gt; -p HEAD -m <span class="s2">&#34;a commit made by hand&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># note the commit hash, then:</span>
</span></span><span class="line"><span class="cl">git update-ref refs/heads/main &lt;commit-hash&gt;
</span></span></code></pre></div><p>Run <code>git log</code>. Your hand-built commit is there, indistinguishable from any other. (<code>git status</code> will call <code>handmade.txt</code> deleted, because so far the file only exists in the repo. <code>git restore handmade.txt</code> writes it out.) Every friendly Git command is built from pieces like these.</p>
<h2 id="things-you-should-know">Things you should know</h2>
<ul>
<li><strong>Don&rsquo;t edit <code>.git</code> by hand in real repos.</strong> Explore in a throwaway one.</li>
<li><strong>SHA-256 repos exist.</strong> Git supports <code>git init --object-format=sha256</code>, which gives 64-character hashes. The structure is the same.</li>
<li><strong>Refs won&rsquo;t always be files.</strong> Git 3.0 will switch new repos to the reftable format, and you can try it today with <code>git init --ref-format=reftable</code>. There, branches live in binary tables under <code>.git/reftable/</code>, and <code>.git/HEAD</code> just says <code>ref: refs/heads/.invalid</code>. Commands like <code>git branch</code> and <code>git rev-parse</code> work the same either way.</li>
<li><strong>Hashes include everything.</strong> Change one byte of a file, one character of a commit message, or a timestamp, and the hash changes, along with every commit after it. That&rsquo;s why rewriting history changes all later commit IDs.</li>
</ul>
<h2 id="closing-thought">Closing thought</h2>
<p>Once you&rsquo;ve seen that a branch is a text file and a commit is a pointer to a snapshot, most of Git&rsquo;s &ldquo;weird&rdquo; behavior stops being weird. It&rsquo;s a database. Now you can read it.</p>
]]></content:encoded></item></channel></rss>