{"article":{"slug":"maybe-a-better-way-to-track-the-release-history-of-a-git-repo","title":"Maybe a better way to track the release history of a Git repo","subtitle":null,"summary":"Mark Dominus recalls how per-deployment release tags left ZipRecruiter's Git monorepo with far too many refs, and proposes an alternative: a single ref per tier and app pointing to a chain of two-parent metainfo commits, so the full release history stays auditable, revertible and browsable with ordinary git log.","content_type":"blog_post","language":"en","canonical_url":"https://blog.plover.com/prog/git/history-proposal.html","author":{"name":"Mark Dominus","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"The Universe of Discourse","url":"https://blog.plover.com/","listing_slug":null,"listing":null},"topics":[{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Developer Tools","slug":"developer-tools","url":"https://listedarticles.com/topics/developer-tools"},{"name":"DevOps","slug":"devops","url":"https://listedarticles.com/topics/devops"},{"name":"Software Engineering","slug":"software-engineering","url":"https://listedarticles.com/topics/software-engineering"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":1805,"reading_minutes":8,"published_at":"2026-10-06T00:00:00.000Z","added_at":"2026-10-13T23:11:43.476Z","updated_at":"2026-10-13T23:11:43.476Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/maybe-a-better-way-to-track-the-release-history-of-a-git-repo","markdown_url":"https://listedarticles.com/articles/maybe-a-better-way-to-track-the-release-history-of-a-git-repo.md","example":false,"citation":"Mark Dominus, The Universe of Discourse. \"Maybe a better way to track the release history of a Git repo.\" 6 Oct 2026. https://blog.plover.com/prog/git/history-proposal.html (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://blog.plover.com/prog/git/history-proposal.html"},"body_markdown":"While I was still at ZipRecruiter, our Git monorepo was developing an\nannoying problem: it had too many refs.\n\nWhenever there was a deployment of any of the applications in the\nrepo, to staging or to production, the deployment tool would create a\ntag object with a name like this:\n\n```\nrefs/release/stg.www.our-app-2019-09-06T114410.954-0700-treesha-179dab6fd11bee5195f0f8e31cea3edfb86ae23b\n```\n\nThe tag contained the name of the target tier of the deployment (`stg` here, for\n“staging”), the app name and class of hosts on which it ran,\n`www.our-app` in this case, the date\nand time, and the hash of the tree that was deployed. This made a\ncomplete record of what was deployed, where, and when.\n\nAfter we had been doing this for a while, there were thousands of\nthese tag objects. Git is well-designed for handling thousands of\ncommits, but not for handling thousands of refs. Perhaps this has\nchanged, but at the time, Git would store refs in a file called\n`packed-refs`, which, for each ref, contained an entry with the ref's\nname and the ID of the object to which it referred.\n\n(As with all things Git, the actual situation is\nsomewhat more complicated. These additional ramifications don't affect\nthe following discussion.)\n\nAny operation that required resolving a ref name to its object had to\nsearch through the `packed-refs` file, which was taking an appreciable\namount of time, far more than was desirable for an operation that\neveryone did many times a day. This affected not just local operations\nbut operations on the Git server as well, and it was clear that it was\nonly going to get slower as time went on. Also, the outputs of\ncommands like `git fetch` and `git branch` were becoming increasingly\ncluttered with the names of tag objects whose only purpose was to\nrecord release history.\n\nIt seemed reasonable to me record the deployment history *in* the\nrepository, but using thousands of tag objects clearly had serious\ndrawbacks. In addition to the performance problems, the metadata\nitself was purely a convention in the tag name format, not legible to\nGit itself. There was a tree ID there, but nothing enforcing the rule\nthat the tag should refer to a commit with that tree.\n\nAlso, the tag objects themselves seemed poorly-designed for the\npurpose. One good reason for storing history data in the respoitory is to\nrender it immutable. But tags aren't immutable! They can be deleted\nand new ones can be inserted at any time, with any names desired.\n\nAnd more important: consider the most natural sort of question that\nsomeone might want answered by the release history:\n\n> When did we last deploy the `www.our-app` app to production?\n\nThe only way to answer this question was to get the *full* list of *all* tags,\nfilter it for `prod.www.our-app`, then sort by date and take the last item.\n\nOr similarly: What if the most recent staging deployment had turned\nout to be bad, and we wanted to immediately revert to the version\nbefore that? Again, we (or the reversion process) would have had to\nfilter and sort a list of thousands of tags, then pick out the\nsecond-last one.\n\nI proposed this alternative in 2019. It stores the deployment history\nof each app as a series of unnamed but related Git objects, using\nGit's own history mechanism, so it's simple, efficient, and meshes\nwell with Git's commands.\n\nI don't think it was ever adopted, and it might turn out to have\nserious drawbacks I haven't foreseen. But it might be useful to\nsomeone in a similar situation.\n\n---\n\n# Maintaining an audit and reversion trail without thousands of refs\n\nCurrently, *each* time a new version of an app is deployed, we create a ref:\n\n`refs/release/stg.www.our-app-2019-09-06T114410.954-0700-treesha-179dab6fd11bee5195f0f8e31cea3edfb86ae23b`\n\nAs these refs proliferate, many operations in the Git repo become slower and slower.\n\nWe can record this information in a way that is at least as useful and which doesn't clutter up the ref list.\n\n## Current practice\n\nAt present, the release procedure creates a ref something like this:\n\n* A new release is created at commit *H*\n* A new tag object is created, pointing to *H*, something like\n\n  `git tag -a refs/release/stg.www.our-app-2019-09-.... H`\n\nEvery new tag object has a name in the ref database. This adds a name for every release. The structure is something like this:\n\n![](https://pic.blog.plover.com/prog/git/history-proposal/current-practice.svg)\nOne ref object per release, each pointing to its own head commit\n\n## Alternative proposal\n\n* Each (tier, app) pair is represented by a linked list of release commits\n* The head of the list is the most recent release\n* **One ref** points to the head of the list\n* Older releases can be found by following the links back in time\n\nMain point of this proposal: **There is only one ref for each (tier, app) pair,** no matter how many releases have been done.\n\nIt will look like this:\n\n![](https://pic.blog.plover.com/prog/git/history-proposal/alternative-proposal.svg)A chain of metainfo commits, each with the previous\nmetainfo commit as first parent and the actual release commit as\nsecond parent\n\nThe **one ref** (which might or might not be an actual ref object) is\nnamed `stg.www.our-app`. That points not to the\nactual release commit, but to\na commit that contains\nmetainformation, such as the treesha. The\nmetainformation could be stored in any of several ways. (See below.)\nThe exact time of the release doesn't need to be stored explicitly\nbecause it is exactly the time at which the metainfo\ncommit was created.\n\n**The metainfo commit has two\nparent commits. One is the actual release\ncommit, the one that, in the current system, is the\ntarget of the named ref object. The other parent is the\nmetainfo commit from the\n*previous* release.**\n\nThe metainfo commits are chained\ntogether exactly the same way Git normally chains commit history, so\nthe usual Git commands will work on the release history in reasonable\nand useful ways. In particular, the command `git log\nstg.www.our-app` will produce a listing of\nthe release history.\n\n## Technical details\n\nCreating this sort of two-parent commit is easy. Suppose the ref\n`stg.www.our-app` points to the current metainfo\ncommit. And suppose we want to release\nnew commit *H*. We do:\n\n(Write some useful commit message into `/tmp/message.$$`.)\n\n```\ngit commit-tree -p refs/release/stg.www.our-app -p H \\\n    H^{tree} < /tmp/message.$$\n```\n\nThis creates a new metainfo commit\nwith two parents, given by the arguments of the `-p` options. The\nfirst parent is the metainfo commit\nfor the current release and the second is new commit\n*H*.\n\nThe `git commit-tree` command creates the new\nmetainfo commit and writes its SHA on standard\noutput. Say this SHA is *N*.\n\n```\ngit push origin N:refs/release/stg.www.our-app\n```\n\nThe release ref now points to the metainfo commit for the new release.\n\n## Usage\n\nThis will be easier to use than what we have now, because Git's\ncommand set was designed to manage a list of commits linked in this\nway; this is exactly how Git normally expects to represent\nhistory. This natural fit with Git's command set shows up in many\nplaces.\n\nYou can fetch a complete release history for any project with a single fetch:\n\n```\ngit fetch origin refs/release/stg.www.our-app:HISTORY\n```\n\nAfter this, `git log HISTORY` will list the entire release history in\nreverse chronological order. All the usual git-log options make sense\nto filter or search this list. For example, if we want to find the\nlast release before a certain date, we can use:\n\n```\ngit log -1 --until=date HISTORY\n```\n\nIf we want a forward-chronological list of release commit SHAs with\ntheir release dates, we could use something like:\n\n```\n    git log --reverse --format=\"%P %cd\" HISTORY\n```\n\nAt all times:\n\n* The current release commit is accessible at `HISTORY^2`.\n* The metainfo commit from *n* releases ago is at `HISTORY~n`.\n* The release commit from *n* releases ago is at `HISTORY~n^2`.\n\nNotice: if `M` is a metainfo commit that describes a release, `M^`\nis the metainfo commit for the release before that, and `M^{2}` is\nthe exact commit that was actually released. We don't have to record\nthe tree IDs in the ref names; the tree that was released in release\n`M` is `M^{2}^{tree}`. These notations will work as expected in *all*\ngit commands; see [`git-rev-parse`](https://git-scm.com/docs/git-rev-parse#_specifying_revisions).\n\nBecause Git naturally understands this way of organizing history, it\nwill be easy to locate a particular previous release for\nrollback. When we roll back, we can roll back to the exact same commit\nas before: the new metainfo commit\nand the old one can share the exact same release\ncommit object.\n\nWe can see the differences between the last two releases, without\nknowing their exact dates, with something like:\n\n```\n    git diff HISTORY~1^2 HISTORY^2\n```\n\nThe metainfo commit is a full\ncommit with a full set of Git commit metadata, such as a creation\ndate. It has a commit message where we can record any unstructured\ntext that we want. We could also require the commit message to be in\nTOML format, or we could use the tooling provided by Git to store\nsemi-structured information in the commit message; see\n[`git-interpret-trailers`](https://git-scm.com/docs/git-interpret-trailers).\n\nIf the built-in commit structure is not big enough for everything we\nwant to record, the metainfo commit\nalso has an associated tree where we could record any amount of\nstructured information. In the example above I had its tree be\nidentical to the release commit's tree,\nwhich might be convenient. (That way, if you wanted to see the files\nthat were released, you could look in either the release\ncommit or the metainfo\ncommit; this would make some of the commands\nsimpler.) But the tree doesn't have to be the same. We could also add\nmetainformation files to this tree, or we could commit an entirely\nunrelated tree with nothing in it other than structured\nmetainformation.\n\n## Performance?\n\nPloni Almoni asks about the performance issues of tracing out a\nlinked list of commits. But this is exactly the same mechanism that\nGit normally uses to record history. When we do `git log`, Git is\ntracing a linked list of commits in exactly the way suggested here. If\nany operation in Git is going to be fast, it will be this one.\n\nGit currently takes a few seconds to trace the 275,000-commit history\nof our monorepo. Tracing the similarly-structured history of a few\nhundred release commits is not going to be an issue.\n\n---\n\nI wrote this originally, but my only copy was in the\nform of a PDF. I had Claude convert the PDF to Markdown, which is the\nnative format accepted by my blog, and the two diagrams to SVG. I\nthen substantially edited the text and the diagrams. Along the way\nClaude pointed out three minor errors, which I\ncorrected. I also took Claude's advice on how to adjust the colors from the\noriginal to make them more legible to color-blind readers.\n\nEvery\nword was written by my brain using my fingers.\n","body_html":"<p>While I was still at ZipRecruiter, our Git monorepo was developing an\nannoying problem: it had too many refs.</p>\n<p>Whenever there was a deployment of any of the applications in the\nrepo, to staging or to production, the deployment tool would create a\ntag object with a name like this:</p>\n<pre><code>refs/release/stg.www.our-app-2019-09-06T114410.954-0700-treesha-179dab6fd11bee5195f0f8e31cea3edfb86ae23b</code></pre>\n<p>The tag contained the name of the target tier of the deployment (<code>stg</code> here, for\n“staging”), the app name and class of hosts on which it ran,\n<code>www.our-app</code> in this case, the date\nand time, and the hash of the tree that was deployed. This made a\ncomplete record of what was deployed, where, and when.</p>\n<p>After we had been doing this for a while, there were thousands of\nthese tag objects. Git is well-designed for handling thousands of\ncommits, but not for handling thousands of refs. Perhaps this has\nchanged, but at the time, Git would store refs in a file called\n<code>packed-refs</code>, which, for each ref, contained an entry with the ref&#39;s\nname and the ID of the object to which it referred.</p>\n<p>(As with all things Git, the actual situation is\nsomewhat more complicated. These additional ramifications don&#39;t affect\nthe following discussion.)</p>\n<p>Any operation that required resolving a ref name to its object had to\nsearch through the <code>packed-refs</code> file, which was taking an appreciable\namount of time, far more than was desirable for an operation that\neveryone did many times a day. This affected not just local operations\nbut operations on the Git server as well, and it was clear that it was\nonly going to get slower as time went on. Also, the outputs of\ncommands like <code>git fetch</code> and <code>git branch</code> were becoming increasingly\ncluttered with the names of tag objects whose only purpose was to\nrecord release history.</p>\n<p>It seemed reasonable to me record the deployment history <em>in</em> the\nrepository, but using thousands of tag objects clearly had serious\ndrawbacks. In addition to the performance problems, the metadata\nitself was purely a convention in the tag name format, not legible to\nGit itself. There was a tree ID there, but nothing enforcing the rule\nthat the tag should refer to a commit with that tree.</p>\n<p>Also, the tag objects themselves seemed poorly-designed for the\npurpose. One good reason for storing history data in the respoitory is to\nrender it immutable. But tags aren&#39;t immutable! They can be deleted\nand new ones can be inserted at any time, with any names desired.</p>\n<p>And more important: consider the most natural sort of question that\nsomeone might want answered by the release history:</p>\n<blockquote><p>When did we last deploy the <code>www.our-app</code> app to production?</p></blockquote>\n<p>The only way to answer this question was to get the <em>full</em> list of <em>all</em> tags,\nfilter it for <code>prod.www.our-app</code>, then sort by date and take the last item.</p>\n<p>Or similarly: What if the most recent staging deployment had turned\nout to be bad, and we wanted to immediately revert to the version\nbefore that? Again, we (or the reversion process) would have had to\nfilter and sort a list of thousands of tags, then pick out the\nsecond-last one.</p>\n<p>I proposed this alternative in 2019. It stores the deployment history\nof each app as a series of unnamed but related Git objects, using\nGit&#39;s own history mechanism, so it&#39;s simple, efficient, and meshes\nwell with Git&#39;s commands.</p>\n<p>I don&#39;t think it was ever adopted, and it might turn out to have\nserious drawbacks I haven&#39;t foreseen. But it might be useful to\nsomeone in a similar situation.</p>\n<hr />\n<h1 id=\"maintaining-an-audit-and-reversion-trail-without-thousands-of-re\">Maintaining an audit and reversion trail without thousands of refs</h1>\n<p>Currently, <em>each</em> time a new version of an app is deployed, we create a ref:</p>\n<p><code>refs/release/stg.www.our-app-2019-09-06T114410.954-0700-treesha-179dab6fd11bee5195f0f8e31cea3edfb86ae23b</code></p>\n<p>As these refs proliferate, many operations in the Git repo become slower and slower.</p>\n<p>We can record this information in a way that is at least as useful and which doesn&#39;t clutter up the ref list.</p>\n<h2 id=\"current-practice\">Current practice</h2>\n<p>At present, the release procedure creates a ref something like this:</p>\n<ul><li>A new release is created at commit *H*</li><li><p>A new tag object is created, pointing to *H*, something like</p><p><code>git tag -a refs/release/stg.www.our-app-2019-09-.... H</code></p></li></ul>\n<p>Every new tag object has a name in the ref database. This adds a name for every release. The structure is something like this:</p>\n<figure><img src=\"https://pic.blog.plover.com/prog/git/history-proposal/current-practice.svg\" alt=\"\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></figure>\n<p>One ref object per release, each pointing to its own head commit</p>\n<h2 id=\"alternative-proposal\">Alternative proposal</h2>\n<ul><li>Each (tier, app) pair is represented by a linked list of release commits</li><li>The head of the list is the most recent release</li><li><strong>One ref</strong> points to the head of the list</li><li>Older releases can be found by following the links back in time</li></ul>\n<p>Main point of this proposal: <strong>There is only one ref for each (tier, app) pair,</strong> no matter how many releases have been done.</p>\n<p>It will look like this:</p>\n<p><img src=\"https://pic.blog.plover.com/prog/git/history-proposal/alternative-proposal.svg\" alt=\"\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" />A chain of metainfo commits, each with the previous\nmetainfo commit as first parent and the actual release commit as\nsecond parent</p>\n<p>The <strong>one ref</strong> (which might or might not be an actual ref object) is\nnamed <code>stg.www.our-app</code>. That points not to the\nactual release commit, but to\na commit that contains\nmetainformation, such as the treesha. The\nmetainformation could be stored in any of several ways. (See below.)\nThe exact time of the release doesn&#39;t need to be stored explicitly\nbecause it is exactly the time at which the metainfo\ncommit was created.</p>\n<p><strong>The metainfo commit has two\nparent commits. One is the actual release\ncommit, the one that, in the current system, is the\ntarget of the named ref object. The other parent is the\nmetainfo commit from the\n<em>previous</em> release.</strong></p>\n<p>The metainfo commits are chained\ntogether exactly the same way Git normally chains commit history, so\nthe usual Git commands will work on the release history in reasonable\nand useful ways. In particular, the command <code>git log\nstg.www.our-app</code> will produce a listing of\nthe release history.</p>\n<h2 id=\"technical-details\">Technical details</h2>\n<p>Creating this sort of two-parent commit is easy. Suppose the ref\n<code>stg.www.our-app</code> points to the current metainfo\ncommit. And suppose we want to release\nnew commit *H*. We do:</p>\n<p>(Write some useful commit message into <code>/tmp/message.$$</code>.)</p>\n<pre><code>git commit-tree -p refs/release/stg.www.our-app -p H \\\n    H^{tree} &lt; /tmp/message.$$</code></pre>\n<p>This creates a new metainfo commit\nwith two parents, given by the arguments of the <code>-p</code> options. The\nfirst parent is the metainfo commit\nfor the current release and the second is new commit\n*H*.</p>\n<p>The <code>git commit-tree</code> command creates the new\nmetainfo commit and writes its SHA on standard\noutput. Say this SHA is *N*.</p>\n<pre><code>git push origin N:refs/release/stg.www.our-app</code></pre>\n<p>The release ref now points to the metainfo commit for the new release.</p>\n<h2 id=\"usage\">Usage</h2>\n<p>This will be easier to use than what we have now, because Git&#39;s\ncommand set was designed to manage a list of commits linked in this\nway; this is exactly how Git normally expects to represent\nhistory. This natural fit with Git&#39;s command set shows up in many\nplaces.</p>\n<p>You can fetch a complete release history for any project with a single fetch:</p>\n<pre><code>git fetch origin refs/release/stg.www.our-app:HISTORY</code></pre>\n<p>After this, <code>git log HISTORY</code> will list the entire release history in\nreverse chronological order. All the usual git-log options make sense\nto filter or search this list. For example, if we want to find the\nlast release before a certain date, we can use:</p>\n<pre><code>git log -1 --until=date HISTORY</code></pre>\n<p>If we want a forward-chronological list of release commit SHAs with\ntheir release dates, we could use something like:</p>\n<pre><code>    git log --reverse --format=&quot;%P %cd&quot; HISTORY</code></pre>\n<p>At all times:</p>\n<ul><li>The current release commit is accessible at <code>HISTORY^2</code>.</li><li>The metainfo commit from *n* releases ago is at <code>HISTORY~n</code>.</li><li>The release commit from *n* releases ago is at <code>HISTORY~n^2</code>.</li></ul>\n<p>Notice: if <code>M</code> is a metainfo commit that describes a release, <code>M^</code>\nis the metainfo commit for the release before that, and <code>M^{2}</code> is\nthe exact commit that was actually released. We don&#39;t have to record\nthe tree IDs in the ref names; the tree that was released in release\n<code>M</code> is <code>M^{2}^{tree}</code>. These notations will work as expected in <em>all</em>\ngit commands; see <a href=\"https://git-scm.com/docs/git-rev-parse#_specifying_revisions\" rel=\"nofollow ugc noopener\"><code>git-rev-parse</code></a>.</p>\n<p>Because Git naturally understands this way of organizing history, it\nwill be easy to locate a particular previous release for\nrollback. When we roll back, we can roll back to the exact same commit\nas before: the new metainfo commit\nand the old one can share the exact same release\ncommit object.</p>\n<p>We can see the differences between the last two releases, without\nknowing their exact dates, with something like:</p>\n<pre><code>    git diff HISTORY~1^2 HISTORY^2</code></pre>\n<p>The metainfo commit is a full\ncommit with a full set of Git commit metadata, such as a creation\ndate. It has a commit message where we can record any unstructured\ntext that we want. We could also require the commit message to be in\nTOML format, or we could use the tooling provided by Git to store\nsemi-structured information in the commit message; see\n<a href=\"https://git-scm.com/docs/git-interpret-trailers\" rel=\"nofollow ugc noopener\"><code>git-interpret-trailers</code></a>.</p>\n<p>If the built-in commit structure is not big enough for everything we\nwant to record, the metainfo commit\nalso has an associated tree where we could record any amount of\nstructured information. In the example above I had its tree be\nidentical to the release commit&#39;s tree,\nwhich might be convenient. (That way, if you wanted to see the files\nthat were released, you could look in either the release\ncommit or the metainfo\ncommit; this would make some of the commands\nsimpler.) But the tree doesn&#39;t have to be the same. We could also add\nmetainformation files to this tree, or we could commit an entirely\nunrelated tree with nothing in it other than structured\nmetainformation.</p>\n<h2 id=\"performance\">Performance?</h2>\n<p>Ploni Almoni asks about the performance issues of tracing out a\nlinked list of commits. But this is exactly the same mechanism that\nGit normally uses to record history. When we do <code>git log</code>, Git is\ntracing a linked list of commits in exactly the way suggested here. If\nany operation in Git is going to be fast, it will be this one.</p>\n<p>Git currently takes a few seconds to trace the 275,000-commit history\nof our monorepo. Tracing the similarly-structured history of a few\nhundred release commits is not going to be an issue.</p>\n<hr />\n<p>I wrote this originally, but my only copy was in the\nform of a PDF. I had Claude convert the PDF to Markdown, which is the\nnative format accepted by my blog, and the two diagrams to SVG. I\nthen substantially edited the text and the diagrams. Along the way\nClaude pointed out three minor errors, which I\ncorrected. I also took Claude&#39;s advice on how to adjust the colors from the\noriginal to make them more legible to color-blind readers.</p>\n<p>Every\nword was written by my brain using my fingers.</p>","headings":[{"level":1,"text":"Maintaining an audit and reversion trail without thousands of refs","id":"maintaining-an-audit-and-reversion-trail-without-thousands-of-re"},{"level":2,"text":"Current practice","id":"current-practice"},{"level":2,"text":"Alternative proposal","id":"alternative-proposal"},{"level":2,"text":"Technical details","id":"technical-details"},{"level":2,"text":"Usage","id":"usage"},{"level":2,"text":"Performance?","id":"performance"}]}}