This is an update on the development of Flirt.
Make sure to read the announcement post before this one, otherwise it won’t make sense:
Announcing Development on Flirt.
According to the roadmap, my goal for December and January was:
Develop a reasonably detailed specification of the feature set, taking care to support a broad variety of backends while keeping the user experience across backends consistent.
Implement this feature set for the “Git native” backend.
I have mostly achieved this goal, although the native backend is unfortunately not yet feature-complete.
The basic mechanism of storing, sending and receiving review information via a Git remote is working though.
Let’s get into it!
Table of contents:
I analyzed three code review platforms for their features: GitHub, the mailing list and Gerrit.
Many others (Gitlab, Forgejo etc.) are similar to GitHub, so I should be able to support them later without too much trouble.
I won’t reproduce my entire analysis here, but only touch on some interesting features that will or won’t be supported.
Let me know if you disagree with anything or you think I forgot to consider something!
Flirt wants to encourage code review on a per-commit basis.
Commenting on the combined diff of a multi-patch Spirit is therefore discouraged.
In fact, I’m planning to not support it at all.
That raises a question though:
How is Flirt going to deal with existing comments on the full diff, e.g. ones made in the GitHub PR UI?
I think there are some decent fallbacks, but I haven’t decided on a specific one yet.
I could treat them the same way as “free-standing” comments, which is something Flirt will need to support anyway.
(For example, a regular comment that’s not attached to a diff at all in the GitHub “Conversation” tab.)
Another option is to treat it as a comment on the last patch of the Spirit.
The last patch may not have changed the line the comment was made on, but at least the state of the code would match.
And supporting comments anywhere in the codebase is desirable anyway.
For example, if a Spirit author forgot to update documentation, Flirt should allow reviewers to comment at the correct location in the documentation, even though the patch doesn’t touch the documentation at all.
Gerrit has an interesting feature, which allows you to comment on a range of characters within a line.
(Actually, it can even be a range spanning multiple lines, but starting and ending at arbitrary points within a line.)
I think this can be useful in some situations.
For example, I sometimes see markdown files in the wild where a whole paragraph is written on a single line.
In that case, commenting on that line may not be specific enough, since the comment could apply to any part of a whole paragraph.
I’m not planning to support this though, because I’m not convinced this is a widely required feature.
(Gerrit users: Let me know if I’m wrong!)
In the case of my markdown example, it seems straight forward to tell the author to follow the “one sentence per line” rule or hard-wrap the text at a specific line-width.
Flirt will support commenting on ranges of lines though, that’s commonly supported by backends and generally useful.
Commenting on the commit message and commit headers
This is something that GitHub straight-up does not support at all.
Gerrit on the other hand supports it as an explicit feature.
The mailing list supports it implicitly, since commit message and headers are simply part of the plaintext email that can be replied to anywhere.
Since I want to encourage code review on a per-commit basis, I definitely want to support this feature.
For sending such comments to GitHub, I’ll have to get creative.
I’m probably going to have to represent a comment on a commit message as some kind of free-standing comment which includes the quoted commit message.
Ideally, I can reliably detect this pattern when fetching comments from GitHub, so I can display them better in Flirt.
This is also an obviously important feature to support.
Most platforms like Gerrit and the mailing list support threaded replies to any comment.
Annoyingly, GitHub doesn’t.
Generally speaking, comments on a diff can be replied to in a thread, but free-standing comments cannot.
I might have to take a similar approach here as for commenting on commit messages and represent threads of free-standing comments with quoted text.
The mailing list allows comment threads to branch out arbitrarily.
This is a tough one, I’m not sure how to deal with that yet.
Marking files as viewed
GitHub and Gerrit support this, the mailing list doesn’t.
I have decided this feature is probably not important for Flirt to support at the beginning.
Flirt’s approach to incremental review should already avoid duplicate work effectively.
If users end up asking for this feature anyway, it should be easy to add later.
If the backend doesn’t support it, the information can just be stored locally, since it doesn’t necessarily need to be communicated to other people.
Marking threads as resolved
This feature is more important to support.
If there are many comments on a Spirit without a mechanism to keep track of what’s “done”, it will become unmanageable.
Like marking files as viewed, GitHub and Gerrit support it, while the mailing list doesn’t.
I’m even planning to expand on this feature slightly, at least compared to the implementation of GitHub.
My issue with GitHub is the fact that thread resolution state is shared for everyone.
I have done code review for code authors who used the thread resolution state themselves to hide reviewer suggestions they have addressed.
This robs me (the reviewer) from using the feature myself, in order to keep track of my suggestions which have been implemented to my satisfaction.
This is actually the preferred way of doing things in the Jujutsu repository, where I contribute from time to time.
It still feels wrong to me to mark threads as resolved as the code author.
With a local-first tool like Flirt, it should be very easy to implement two separate “resolved” toggles: a shared one and a local one.
If the backend doesn’t support thread resolution (e.g. the mailing list), the user is left with the local toggle, which is still very useful.
Adding a verdict to a review
This is important, but complicated to support.
All backends handle review verdicts differently.
GitHub has the hard-coded list of “Comment”, “Approve” and “Request changes”.
Gerrit has a “score” on the “Code-review” label, which ranges from -2 to +2.
(I am told that technically, this is even configurable.)
The mailing list only has loose conventions.
The Linux kernel, for example, uses some well-known commit message trailers:
“Reviewed-by”, “Tested-by”, “Acked-by” etc.
So, Flirt will need to support custom “verdicts” at least for each backend, and possibly even for different projects with the same backend.
This section is mostly about the inner workings of the native backend I’ve been working on.
If that’s not interesting to you, you can skip to the next section.
Sadly, the native backend is not yet ready for people to try it out.
When I posted my initial announcement of Flirt, I was pretty vague about how the native backend would work under the hood:
Flirt would store all of its data worthy of sharing in a custom file format and dump it in a commit.
That commit can be pushed to and pulled from a Git remote.
Later, I was made aware of git-appraise, which is basically a project with the exact same idea:
store code review information directly in the Git repository.
I took a closer look and determined that git-appraise doesn’t really focus on the user interface.
Its main aim seems to be to define a standard, such that different tools interacting with code review information would be compatible with each other.
I thought that’s a great idea, so I tried to find out if the git-appraise storage format would work for Flirt.
Unfortunately, the answer was no.
Why using git-notes is a bad idea
A fundamental design decision of git-appraise is to store its information using git-notes.
In my experience, this feature of Git is not widely known and used even less.
The canonical use-case for git-notes is to append to a commit message after a commit has already landed on a stable branch.
git-log can display the content of notes attached to a commit alongside the regular commit message.
The idea of git-appraise is to attach review information to commits instead of commit message extensions.
git-notes does support namespaces, so this data can be separate from whatever else you use git-notes for.
So far so good.
The problems start to appear when we think about commit-rewriting.
When a commit is rewritten, e.g. using git-rebase, its commit hash changes.
If a note was associated with the previous commit hash, it should be associated with the new one after the rebase.
Git does not do this by default!
It can be configured to, but it’s an opt-in situation.
And if you’re using Jujutsu (highly recommended), transferring git-notes when editing commits is not supported at all.
git-appraise has an (undocumented) “rebase” command, which takes care of this.
But you can’t rely on your users always using that for every commit-rewriting operation.
Consequently, there is a significant risk of notes becoming dangling.
I was able to write a pretty simple script that causes data loss using git-appraise:
git checkout -b whatever
git commit --allow-empty -m whatever
git appraise request
git appraise list # observe presence of new request
git commit --allow-empty --amend -m something-else # rewrite commit-under-review
git reflog expire --all --expire=now # make old commit unreachable from reflog
git gc --prune="$(date)" # trigger GC
git appraise list # observe the request being gone! 💥
The only weird thing I’m doing here is triggering Git’s garbage-collection immediately, rather than waiting for the normal reflog expiration time.
Clearly, this is no good.
We can’t risk losing review information due to commit rewirting.
Even if data isn’t lost, the storage format seems weirdly inefficient for how you’d want to access it.
You can’t take a commit, a change-id or a branch and determine where its associated code review information must be stored.
Instead, you’d have to traverse the list of existing git-notes, all of which are possibly associated with long-dead commits, and parse the JSON objects as defined by git-appraise, to find the review request associated with a branch.
No thanks!
After embarking on this detour, I asked myself why git-appraise decided to use git-notes in the first place.
I couldn’t come up with an answer.
As far as I know, the only benefit provided by git-notes is the integration with git-log, which is irrelevant.
In the context of git-appraise, the notes store JSON objects, which aren’t particularly human-readable.
A custom ref is all you need
With git-notes dismissed, I was back to my original vague notion of “dumping a custom data format into a commit”.
This is not enough though, you also need a ref pointing to the commit to protect it from garbage-collection.
A ref also provides a direct way to find your commit.
Otherwise, you’d have to remember its hash yourself.
Soo…
That’s what I use for Flirt.
A ref.
A ref is basically just a pointer to an object.
Usually it’s literally a file you can look at.
Its content is the hash of the object it points to.
You can even write an object hash of your choice to the file to make the ref point somewhere else.
Git may “pack” refs for efficient storage, in which case they’re not simple files anymore, so it’s more reliable to use Git commands to read and update refs.
Refs are stored in .git/refs/ and objects are stored in .git/objects/.
There are a few special kinds of refs, including branches, tags and notes.
Branches are stored in .git/refs/heads/, tags in .git/refs/tags/ and notes in .git/refs/notes/.
These have special meaning to Git and may be expected to respect some additional rules.
However, you can just create new refs in other directories.
.git/refs/flirt/, for example.
Such custom refs don’t have any special meaning to Git, but they work the same way.
The point to objects via their hash and prevent them from being garbage-collected.
Custom refs can also be pushed and fetched like branches.
That’s all I need in Flirt.
Taking advantage of the Git object model
My first attempt at storing review information using a custom ref was simple:
Take my data structure, serialize it to JSON, store that as a blob object in Git, create a ref pointing at that blob.
This works!
But we can do better.
Let’s say a Spirit author creates a bunch of submissions for the Spirit.
This is equivalent to sending several patch series versions to a mailing list or (force-)pushing to the PR branch on GitHub.
The reviewer then “pulls” the Spirit from the remote using Flirt.
Pulling the custom ref will download all of Flirt’s custom review information.
If the Spirit is associated with a branch (which may not be necessary), pulling the branch will also download the commits of the latest submission of the Spirit.
However, the intermediary submissions will not be downloaded!
That means, the reviewer cannot see how the Spirit evolved over time, unless they happened to pull every single submission at the right time.
The hashes of the commits in the previous submissions are stored in the Spirit.
But the commits hasn’t been downloaded, so they cannot be viewed locally.
Flirt would have to explicitly pull these commits, to make them available locally.
But Git has solved this problem already!
If you pull a commit, all of its ancestors which you don’t already have will be downloaded as well.
So, we can force Git to pull all commits that were part of any Spirit submission by making them reachable from our custom ref.
Thus, my current approach is for the custom ref to point to a commit, instead of a blob.
The commit has a parent header for each Git object that might be interesting in the context of the Spirit.
For now, these are the head commits of each Spirit submission.
It also has a tree, which stores a single blob called “spirit.json”.
This contains Flirt’s actual review information.
Finally, when the reviewer fetches a Spirit using Flirt, the commits of previous submissions will automatically be downloaded with it.
This ensures the reviewer can examine how the Spirit evolved over time and view the state of the codebase a previous comment was made on.
The native backend is basically working, but it’s missing critical features.
The most important one is robust handling of comment threads.
Once I’ve got that working, it might make sense to provide an alpha build for people to try out.
My next official milestone is to finish implementing two more backends by the end of March: GitHub and the mailing list.
I currently have little hope of hitting that milestone.
The native backend isn’t even complete yet and I expect a lot of “dirty work” to be necessary for the mailing list.
The other milestones should be less time-consuming though, so hopefully I can catch up later.
To conclude this post, I want to share my plan for handling comment threads and an idea I call “thread relocation”.
Flirt can “materialize” interdiffs and review comments in your local repository.
This means the Git HEAD points at the state of the Spirit you previously reviewed, while the worktree contains the current state.
If your editor has some amount of Git integration, it will be able to show you what’s new.
This system can’t handle comment threads yet.
If you read and write comments inline in your editor, how can Flirt know if a comment is supposed to be a new thread or a reply to an existing one?
A decent heuristic could be: “If a new comment comes right after an existing one, it’s probably a reply.”
But I think this doesn’t hold in every case.
I don’t want to build on such a shaky ground.
My current plan is to add some kind of marker around an existing thread, with a dedicated space to add replies.
For example:
fn main() {
println!("Hello world!");
// + // + at 2026-02-06T16:12
//
// I think this functionality should be spun out to a microservice for
// horizontal scalability.
//
// + at 2026-02-06T16:13
//
// How are we going to handle i13n, once our product becomes a global hit?
//
// + //
//
//
// + //
// [ ] mark as resolved for everyone
// [ ] mark as resolved for yourself
//
// +}
(+ is an idea I had for a logo)
It’s a little verbose and hard to read.
Maybe that can be improved.
(I think it should be possible to inject syntax highlighting for something like this, at least for editors using tree-sitter.)
I haven’t come up with a better idea that allows you to use your editor for review and still gives Flirt all the structural information it needs.
Let me know what you think!
Thread relocation
This is a feature I came up with while thinking about the above comment thread syntax.
You know how GitHub marks a thread as “outdated” when a force-push to a PR branch changes the commit the thread was started on?
It’s not ideal.
Usually, the thread still semantically belongs to some line in a new commit.
GitHub just doesn’t know where, so it gives up.
However, with these comment threads materialized in the repository, Flirt could track this information explicitly!
Imagine a new function is added at the top of a file.
A review comment says: “Please move this function to the end of the file.”
Flirt assigns the ID “ABCXYZ” to the new comment thread.
The code author uses Flirt to materialze this thread in their repository, including its ID.
Then, they implement the suggestion by moving the function.
In the same motion, they also move the comment thread.
Once they’re done, the author runs a Flirt command to submit their comment replies and actions (e.g. “mark as resolved”).
Flirt reads from the local repository to get that information, including the new location of the comment thread, identified by its ID.
This “thread relocation” is stored in the Spirit explicitly, sending it to all other collaborators.
At the start of the next review cycle, the reviewer will materialize the existing threads in their repository.
Now the thread “ABCXYZ” appears where it should: next to the function at the end of the file.
This makes it easier for reviewers to figure out how the code author implemented their suggestion within the context of the current state of the codebase.
I think this would be pretty useful and I don’t know of any other review tool that does this.
Unfortunately, only the native backend would support it.
Here are some places to share and discuss your thoughts about this post: