Mert Özen Further With Every Line
DevOps Git & GitHub — #27

Git 2.40–2.49: reftable, SHA-256, git replay and the New git config

Mert Özen Nov 6, 2026 9 min 2 views
Git 2.40–2.49: reftable, SHA-256, git replay and the New git config

We look at the 2.40–2.49 period, when the foundations of Git 3.0 were laid, with real output: the reftable ref store and git refs migrate, where SHA-256 repos stand today, git config's get/set/unset/list subcommands, worktree add --orphan, git replay, safe.directory prefixes, git backfill and Rust's first step.

Version note: This post covers the changes between Git 2.40 and 2.49; the commands were tested with Git 2.54.

Guess First

In the branch post we looked at the .git/HEAD file and saw ref: refs/heads/main in it. Now we create a new repo with the same bookstore content; the only difference is one option we pass to git init:

$ git init -q --ref-format=reftable bookstore-reftable
$ git rev-parse --show-ref-format
reftable

We made a commit in it and created a feature/search branch. What do you think cat .git/HEAD prints? Is there a file called .git/refs/heads/main?

$ ls .git/reftable
0x000000000001-0x000000000003-360f6241.ref
tables.list
$ cat .git/HEAD
ref: refs/heads/.invalid
$ cat .git/refs/heads
this repository uses the reftable format
$ git branch
  feature/search
* main

HEAD deliberately points somewhere invalid, and refs/heads is not even a folder; it is a file that tells older Gits "you can't understand this repo". Where the branches really live is known by the binary file under .git/reftable. Still, git branch works as always.

This post covers the Git 2.40–2.49 period. What makes it interesting is that the foundations of Git 3.0 were laid here: a new storage format for refs, SHA-256 support maturing, git config being reorganised, and Rust entering the code for the first time.

A New Ref Store: reftable (2.45)

In the classic "files" format every branch and tag is a separate file under .git/refs/; as their number grows they get packed into a single text file called packed-refs. This simple design has known problems. On case-insensitive file systems (the default on macOS and Windows), two branches named Feature and feature collide. In repos with hundreds of thousands of refs, deleting even a single ref means rewriting the whole packed-refs file. Updating several refs at once is not truly atomic either.

reftable is a binary format originally designed for JGit. It keeps refs in sorted, compressed tables, adds new updates as a new table and merges them from time to time. Reflogs go into the same tables. Git 2.45 brought git init --ref-format=reftable. There is a setting for it too: with init.defaultRefFormat reftable, every new repo is created with reftable.

The numbers in the file names may differ on every run; what matters is that you can no longer read refs with cat. You ask Git instead: git branch, git for-each-ref or git refs list from 2.52. If you have old scripts that read ref files directly, fix them before switching to reftable.

Moving an Existing Repo: git refs migrate (2.46)

Creating a new repo is easy; what about one you have used for years? 2.46 brought git refs migrate for that. Let's do a dry run first and then the real conversion:

$ git rev-parse --show-ref-format
files
$ git refs migrate --ref-format=reftable --dry-run
Finished dry-run migration of refs, the result can be found at '.git/ref_migration.q51NvX'
$ git refs migrate --ref-format=reftable
$ git rev-parse --show-ref-format
reftable
$ git refs list
b26b14b302b91e7a187d7e8a313124df92c6c4b3 commit	refs/heads/feature/search
b26b14b302b91e7a187d7e8a313124df92c6c4b3 commit	refs/heads/main
b26b14b302b91e7a187d7e8a313124df92c6c4b3 commit	refs/tags/v0.1.0
$ git reflog -2
b26b14b HEAD@{0}: commit: Add cart total
bfffdfa HEAD@{1}: commit (initial): Add bookstore skeleton

Branches, tag and reflog are all in place. Moving the reflog came later: the first version in 2.46 did not migrate reflogs, 2.48 started migrating them, and 2.49 added a --no-reflog option for those who don't want them. The conversion only affects the local repo; nothing happens to the remote, to clones or to GitHub. The ref format is each machine's own business; push and fetch speak the same protocol.

The one place to be careful is tooling. An IDE or CI tool that uses JGit, libgit2 or another Git library cannot open your repo if it does not understand reftable. That is exactly why Git 3.0's plan to change this default depends on "the ecosystem being ready".

SHA-256 Repos: Where Do We Stand?

A practical collision attack against SHA-1, which Git uses to name objects, was demonstrated in 2017. Git responded by switching to a hardened variant of SHA-1 and by starting to prepare for SHA-256. SHA-256 repository support was documented as experimental in 2.29, and in 2.42 the tone of that stern "experimental" warning was toned down:

$ git init -q --object-format=sha256 bookstore-sha256
$ git commit -m "Add README"
[main (root-commit) d5f0e71] Add README
 1 file changed, 3 insertions(+)
 create mode 100644 README.md
$ git rev-parse HEAD
d5f0e71ed8a1737eca1841c6e357e472406eeca7bfbe903904a6af89a36c3cf9
$ git rev-parse --show-object-format
sha256
$ git log --oneline
d5f0e71 Add README

The short hash is still seven characters; the full hash is 64 characters instead of 40. In day-to-day use that is the whole difference. The real problem is elsewhere: there is still no interoperability between SHA-1 and SHA-256 repos. You cannot push a SHA-256 repo to a SHA-1 remote, and you cannot fetch between the two. On the hosting side, according to an LWN article from September 2026, GitLab and Forgejo support SHA-256 repos, while GitHub's status is unclear. So you can try it locally today, but it is too early to move your team's repo to SHA-256.

The git config Subcommands (2.46)

For years git config was a single command whose behaviour changed with options: --get, --get-all, --get-regexp, --unset, --list. Without an option, what happened depended on the number of arguments. 2.46 turned this into subcommands and declared the old options deprecated. Both work today:

$ git config --get init.defaultBranch
main
$ git config --get-regexp "^core\.(bare|filemode)"
core.filemode true
core.bare false
$ git config get init.defaultBranch
main
$ git config get --all --show-names --regexp "^core\.(bare|filemode)"
core.filemode true
core.bare false

Reading is get, writing is set, deleting is unset, listing is list. To add a new value to a multi-valued setting there used to be --add; now it is set --append:

$ git config set pull.rebase true
$ git config set --global --append safe.directory /srv/git/bookstore
$ git config set --global --append safe.directory /srv/git/tools
$ git config get --global --all safe.directory
/srv/git/bookstore
/srv/git/tools
$ git config unset --global --all safe.directory
$ git config list --show-scope | tail -3
local	core.precomposeunicode=true
local	extensions.refstorage=reftable
local	pull.rebase=true
$ git config get --show-origin --show-scope pull.rebase
local	file:.git/config	true

Note the extensions.refstorage=reftable line in the list: the ref format of the repo we migrated a moment ago is recorded in this setting. In this series we have used the new form since the second post. It makes sense to keep the old form in scripts that have to run on Git older than 2.46; prefer the subcommands in everything new you write.

Worktree and Replay: --orphan (2.42), git replay (2.44)

You want to open a branch whose history is completely separate from the main code, like a project site, in its own folder. You used to create a detached worktree first, go into it and start a new history with git switch --orphan. Since 2.42 it is one command:

$ git worktree add --orphan -b gh-pages ../bookstore-pages
Preparing worktree (new branch 'gh-pages')
$ cd ../bookstore-pages
$ git status
On branch gh-pages

No commits yet

nothing to commit (create/copy files and use "git add" to track)
$ echo '<h1>Bookstore</h1>' > index.html
$ git add index.html
$ git commit -m "Start project site"
[gh-pages (root-commit) d13ad76] Start project site
 1 file changed, 1 insertion(+)
 create mode 100644 index.html
$ git log --oneline --all --decorate
d13ad76 (HEAD -> gh-pages) Start project site
4734313 (main) Add cart total
14693d0 Add bookstore skeleton

git replay from 2.44 is a cousin of rebase that never touches the working tree. It recreates commits on top of a new base but does not check anything out and does not touch the index. Its main target is therefore servers: hosting platforms that need to rewrite history without a working tree. It is still experimental:

$ git log --oneline --graph --all --decorate
* 7318512 (HEAD -> main) Add license note
| * 8699687 (feature/search) Note fuzzy search
| * e5517df Add search stub
|/
* ba82313 Add cart total
* e9375b7 Add bookstore skeleton
$ git replay --ref-action=print --onto main main..feature/search
update refs/heads/feature/search 3881e4ac3201fa1c262cae263243d0688b8f6a0e 8699687aa6eed33bfac8155b1a99e7538b73d1d8
$ git replay --onto main main..feature/search
$ git log --oneline --graph --all --decorate
* 3881e4a (feature/search) Note fuzzy search
* f024b7b Add search stub
* 7318512 (HEAD -> main) Add license note
* ba82313 Add cart total
* e9375b7 Add bookstore skeleton

In its first form git replay did not update refs itself; it printed update lines like the one above, and you fed them to git update-ref --stdin. Since 2.53 it updates refs itself, atomically; the old behaviour stays available with --ref-action=print. Meanwhile HEAD did not move at all; we are still on main. If there is a conflict, replay does not stop and let you resolve it; it exits with an error code and changes no refs. It does not replace rebase in everyday work.

safe.directory Prefixes, git backfill and Rust

The safe.directory we saw in the previous post had an annoying side: you had to add every repo one by one. Since 2.46 a value ending in /* covers everything under that folder:

$ export GIT_TEST_ASSUME_DIFFERENT_OWNER=1
$ git status -s; echo $?
fatal: detected dubious ownership in repository at '.../bookstore'
To add an exception for this directory, call:

	git config --global --add safe.directory .../bookstore
128
$ git config set --global --append safe.directory "$(dirname "$PWD")/*"
$ git status -s; echo $?
0
$ git -C ../bookstore-reftable branch --show-current
main

git backfill from 2.49 is for people who use partial clones. When you clone with --filter=blob:none, commits and trees arrive, while file contents are downloaded one by one as they are needed. A command that looks at the whole history, like git blame, can therefore send hundreds of small requests. backfill downloads the missing blobs in bulk:

$ git clone -q --filter=blob:none "file://$PWD/bookstore.git" partial
$ cd partial
$ git rev-list --objects --all --missing=print | grep -c "^?"
3
$ git backfill
$ git rev-list --objects --all --missing=print | grep -c "^?"
0

Since the remote here is a local bare repo, we turned on uploadpack.allowFilter on the server side ourselves; GitHub and GitLab already support partial clones.

Finally, 2.49 took a quiet but historic step: an interface usable from Rust was added to Git's source code. At that point it was entirely optional and did not affect anyone's build. In the next post we will see how that path leads to Rust on by default in 2.55 and to mandatory Rust in Git 3.0.

Common Pitfalls

The first is scripts that read files under .git/refs/heads/. In a repo that moved to reftable those files don't exist, and your script silently returns nothing. To read refs, always ask Git itself: git rev-parse, git for-each-ref, git refs list.

The second is switching to reftable and forgetting the IDE. Everything works on the command line while the IDE's Git panel may fail to open the repo. Check your tools' reftable support before switching; if you need to go back, there is git refs migrate --ref-format=files.

The third is creating a SHA-256 repo as the team repo. Everything works locally, and then you hit a wall on the first push to a SHA-1 remote. Today SHA-256 is for experimenting and preparing.

The fourth is forgetting old machines while moving away from the deprecated git config form. git config get does not work on Git older than 2.46; if your CI image is old, your script breaks there.

A Small Experiment

★☆☆ Migrate a throwaway repo on your machine with git refs migrate --ref-format=reftable, then find which line was added to the output of git config list --show-scope. Then migrate it back with --ref-format=files.

★★☆ Rewrite three git config commands you use often (like --get, --unset, --list) in the new subcommand form. Does the old form appear in your own aliases or dotfiles?

★★★ Try moving a branch onto main with git replay, but this time with a commit on main that changes the same line. What does replay do, and how is that different from rebase?

In the next post we go from Git 2.50 to today's 2.56: git history, config-based and parallel hooks, git last-modified, moving a stash to another machine, and git add --resolved.