Reinventing issue tracking: Local-first and Git-native

Reinventing issue tracking: Local-first and Git-native

重塑问题追踪:本地优先与 Git 原生

Published on September 15, 2026 发布于 2026 年 9 月 15 日

A core ingredient of collaboration is a shared issue tracking environment. When I went to design issue tracking for Manganin, I already had some requirements in mind. The backing data for issues should be stored in Git, and in a way that is playing to the strengths of the tool, not fighting against it. 协作的核心要素之一是共享的问题追踪环境。当我着手为 Manganin 设计问题追踪系统时,心中已经有了一些明确的需求。问题的底层数据应该存储在 Git 中,并且要发挥该工具的优势,而不是与其对抗。

Git is a powerful tool to manage versioning, but it isn’t the obvious choice of database. Bolting on Postgres would be the standard method, but introduces a whole class of vendor lock-in issues, introduces dependencies, and complicates deployment. Working with issues via the command line, and for periods without internet, needs to be ergonomic without installing additional tooling. Git 是管理版本控制的强大工具,但它并非数据库的首选。附加 Postgres 是标准做法,但这会引入一系列供应商锁定问题、增加依赖项并使部署复杂化。通过命令行处理问题,以及在没有网络的情况下工作,需要具备良好的易用性,且无需安装额外的工具。

This is what I mean when I talk about “local-first” as a core principle of Manganin. You should be able to access all core functionality on your own device, without internet access or complicated setup steps. The workflow should integrate seamlessly with proven tools that you already know (and have lovingly configured). An open source tool that has had the efforts of passionate engineers poured into it over decades is going to be better than a crappy electron app whipped up in a weekend to do the same thing with a proprietary tech stack. 这就是我将“本地优先”作为 Manganin 核心原则的含义。你应该能够在自己的设备上访问所有核心功能,无需互联网连接或复杂的设置步骤。工作流应该与你已经熟悉(并精心配置过)的成熟工具无缝集成。一个由充满激情的工程师们倾注数十年心血打造的开源工具,远胜于那些为了实现同样功能、用专有技术栈在周末匆忙拼凑出来的劣质 Electron 应用。

I soon realized that having both of these while maintaining usability was more difficult than it seemed. This devlog explains my approach to issue tracking that meets these requirements, and documents some of my (many) failures along the way. 我很快意识到,在保持易用性的同时实现这两点比看起来要困难得多。这篇开发日志解释了我满足这些需求的问题追踪方案,并记录了我在此过程中经历的一些(许多)失败。

Issues alongside code

与代码共存的问题

The naive solution is to just have a .issues/ directory in the project root, with each file within representing an issue. I thought this was neat because the issues were tied with the code—every branch has its own issue state, so you close an issue in the same commit as fixing it. But once I followed this benefit to its logical conclusion, I realized that it is also the fatal flaw. When a new issue is opened (on the main branch), to which active branches does it apply? Should you rebase constantly to keep up with new issues? Oh no—Rebasing. 最简单的解决方案是在项目根目录下创建一个 .issues/ 目录,其中的每个文件代表一个问题。我曾认为这很巧妙,因为问题与代码绑定在一起——每个分支都有自己的问题状态,因此你可以在修复问题的同时提交关闭问题的 commit。但当我将这一优势推导至逻辑终点时,我意识到这恰恰是致命缺陷。当在主分支上开启一个新问题时,它适用于哪些活跃分支?你是否需要不断地进行变基(rebase)来跟上新问题?噢不——变基。

Issue churn generally happens with a much higher frequency than actual code commits. Every time someone opens, edits, closes, reopens an issue, you have to pull. This would get old pretty fast once the project achieved any kind of velocity. 问题的变动频率通常远高于实际的代码提交。每当有人开启、编辑、关闭或重新打开一个问题时,你就必须执行 pull 操作。一旦项目达到一定的开发速度,这种方式很快就会让人厌烦。

Special refs

特殊引用(Special refs)

Git stores tracking information in refs. Branches are stored in refs/heads, tags are typically enumerated in refs/tags, and so on. I learned about a trick at the Recurse Center: you can point to arbitrary data with unconventionally named refs to store data in Git, in a way that is completely opaque to (but still faithfully propagated by) repository hosts such as GitHub. You can put issue tracking information in here so that it doesn’t clutter up the source tree but is still stored and cloned with the repo. Git 将追踪信息存储在引用(refs)中。分支存储在 refs/heads 中,标签通常列在 refs/tags 中,依此类推。我在 Recurse Center 学到一个技巧:你可以使用非常规命名的引用指向任意数据,从而将数据存储在 Git 中,这种方式对 GitHub 等仓库托管平台完全透明(但仍会被忠实地同步)。你可以将问题追踪信息放在这里,这样它既不会弄乱源码树,又能随仓库一起存储和克隆。

Each issue is assigned an autoincrementing integer and put in a ref addressed by that index, such as refs/issues/12. The problem is, this makes issues massively annoying to edit locally. Using this method for actual local development would probably require downloading a separate tool to manage this complexity. Git was not designed to be used in this manner, and it shows in the ergonomics. There are a lot of layers of complexity wrapping what is essentially just a small text file. More layers means more chances for things to go wrong and more unneeded redundancy of information—there are 4 different IDs that have to be created that essentially refer to a single issue. 每个问题都被分配一个自增整数,并放入以该索引命名的引用中,例如 refs/issues/12。问题在于,这使得在本地编辑问题变得极其麻烦。使用这种方法进行实际的本地开发,可能需要下载一个单独的工具来管理这种复杂性。Git 的设计初衷并非如此,这在易用性上表现得很明显。本质上只是一个小文本文件,却被包裹了多层复杂性。层级越多,出错的几率就越大,且存在更多不必要的信息冗余——为了指向同一个问题,竟然需要创建 4 个不同的 ID。

Process to edit an issue (technical)

编辑问题的流程(技术细节)

Git addresses stored objects with object IDs, or OIDs. We need to store some data in Git’s database, then point to it with a ref so that it can be discovered by other commands or tools, and to prevent it from being garbage collected. First, we will use git hash-object -w to store some data in Git’s database. This will give us the OID of the “blob”, which is just some data. Then, we need to turn the blob into a tree, and the tree into a commit. Then, we point a ref to that commit by calling git update-ref. If this command isn’t run, no refs actually point to the new objects we’ve made. That way, if a step before this fails and the process can’t be completed, nothing actually changes, and all of the objects we’ve set up will eventually be GC’d. Atomicity! Git 使用对象 ID(OID)来寻址存储的对象。我们需要将一些数据存储在 Git 数据库中,然后用一个引用指向它,以便其他命令或工具可以发现它,并防止它被垃圾回收。首先,我们使用 git hash-object -w 将数据存入 Git 数据库。这将得到“blob”的 OID,即数据本身。然后,我们需要将 blob 转换为树(tree),再将树转换为提交(commit)。接着,通过调用 git update-ref 将引用指向该提交。如果不运行此命令,没有任何引用指向我们创建的新对象。这样,如果之前的步骤失败导致流程无法完成,实际上什么都不会改变,所有设置的对象最终都会被垃圾回收。这就是原子性!

# Returns the OID of the newly written blob
# 返回新写入 blob 的 OID
git hash-object -w {new contents}

# The previous step gave us the OID of a "blob"
# 上一步得到了“blob”的 OID
# If there's anything else in the tree, it will need to be re-added
# 如果树中还有其他内容,需要重新添加
echo "100644 blob {issue_oid}\tissue.txt\n" | git mktree

# Notice the similarity in this command to `git commit`
# 注意此命令与 `git commit` 的相似之处
git commit-tree -m " " {tree_oid}

git update-ref refs/issues/{id} {commit_oid}

Note: this is a simplification. The git update-ref command in particular needs additional parameters to act as guardrails against data races. There are additional problems with this method, particularly surrounding avoiding conflicts and race conditions with other people editing the same issues. Implementing this method raised questions around what should be done about conflicts and invalid data, and what exactly an issue ID represents. 注意:这是一种简化。特别是 git update-ref 命令需要额外的参数作为防止数据竞争的护栏。这种方法还有其他问题,特别是围绕如何避免与他人同时编辑同一问题时产生的冲突和竞争条件。实现这种方法引发了关于如何处理冲突和无效数据,以及问题 ID 究竟代表什么的问题。

Keep it simple, stupid

保持简单,笨蛋(KISS 原则)

I took a step back for a couple weeks to think. I had preconceived notions of what issue tracking should be, formed from working with existing software built for SQL backends. What would it look like if I forgot all of that, and tried to work with Git? The solution seems obvious in retrospect: just work in the way I want to work, and build a tool that facilitates that workflow. Trying to force Git to work like a relational database results in massively overcomplicating things. If you’re going to have Git track changes in something distinct from your source code, it should be stored distinctly. Instead of trying to force two disparate things to be stored together, why not just store them apart in the most ergonomic way, and use tooling to bridge the gap between them? 我退后一步,思考了几周。我之前对问题追踪应该是什么样有着先入为主的观念,这些观念源于使用基于 SQL 后端的现有软件。如果我忘记这一切,尝试直接使用 Git,会是什么样子?回想起来,解决方案显而易见:按照我想要的方式工作,并构建一个促进该工作流的工具。试图强迫 Git 像关系型数据库一样工作只会导致极度的复杂化。如果你要让 Git 追踪与源码不同的内容,就应该将它们分开存储。与其强行将两个不同的事物存放在一起,为什么不以最符合人体工程学的方式分开存储,并使用工具来弥合它们之间的鸿沟呢?