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

Git 2.23–2.39: From checkout to switch and restore, Old Habits to New Ones

Mert Özen Nov 6, 2026 11 min 2 views
Git 2.23–2.39: From checkout to switch and restore, Old Habits to New Ones

We put old and new side by side for what arrived between Git 2.23 and 2.39: the move from checkout to switch and restore, init.defaultBranch, pull's divergent branches error, sparse-checkout, maintenance, fsmonitor, ort and remerge-diff, rebase --update-refs, force-if-includes, safe.directory and SSH signing.

Version note: This post covers the changes between Git 2.23 and 2.39; the commands were tested with Git 2.54. The behaviour of older releases is taken from the release notes.

Guess First

In the bookstore repo you added a line to src/cart.js that you haven't committed yet. Following an old StackOverflow answer, you type this command:

$ git checkout src/cart.js

Does this command take you to a branch called src/cart.js, or does it do something to the file? And whatever it does, will it ask you first?

$ git checkout src/cart.js
Updated 1 path from the index
$ git diff --stat
$ git switch src/cart.js
fatal: invalid reference: src/cart.js

checkout put the file back to its state in the index, and the line you added was gone without any confirmation. The empty git diff --stat is the proof. When you gave the same name to switch, Git said "no such branch" and stopped. These two lines sum up the spirit of the changes since Git 2.23: each job gets its own command instead of one command doing two jobs, and explicit choices replace dangerous defaults.

This is the first post of Block B. In the core structure we always used current commands; here we look back with "this is how we used to do it, and this is how Git 2.xx does it". I take the old behaviour from the release notes; all output comes from Git 2.54.

checkout's Two Jobs: switch and restore (2.23)

For years git checkout did two jobs at once: switching branches and restoring files. If the argument was a branch name it did one, if it was a path it did the other. If a branch and a file had the same name, you had to remember the -- separator to know what would happen. Git 2.23 split the two jobs:

$ git restore src/cart.js
$ git switch -c feature/discount
Switched to a new branch 'feature/discount'
$ git switch -
Switched to branch 'main'
$ git switch --detach HEAD~1
HEAD is now at 14693d0 Add bookstore skeleton
$ git switch main
Previous HEAD position was 14693d0 Add bookstore skeleton
Switched to branch 'main'

git checkout -b is now git switch -c, git checkout -- file is now git restore file, and git reset HEAD file is git restore --staged file. switch does not quietly put you in detached HEAD when you move to a commit; it asks for --detach. Its --guess behaviour is on by default: when you say git switch foo, there is no local foo and there is exactly one origin/foo, it creates a branch that tracks it. Since 2.30 you can turn that off with the checkout.guess setting.

The two commands arrived in 2.23 with an "EXPERIMENTAL" label in their man pages, and that label was only removed in 2.51. So they were officially experimental for years, while in practice everyone used them. git checkout is not being removed; the Git 3.0 preparation document says so explicitly. Your old scripts won't break; switch and restore are simply the safer habit going forward.

init.defaultBranch (2.28)

git init used to always name the first branch master, and the only way to change that was a git branch -m afterwards. GitHub made main the default for new repositories on 1 October 2020; Git 2.28 made the name configurable. On a machine with no settings at all, git init says this today:

$ git init fresh
hint: Using 'master' as the name for the initial branch. This default branch name
hint: will change to "main" in Git 3.0. To configure the initial branch name
hint: to use in all of your new repositories, which will suppress this warning,
hint: call:
hint:
hint: 	git config --global init.defaultBranch <name>
hint:
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
hint: 'development'. The just-created branch can be renamed via this command:
hint:
hint: 	git branch -m <name>
hint:
hint: Disable this message with "git config set advice.defaultBranchName false"
Initialized empty Git repository in .../fresh/.git/

That is why we set init.defaultBranch main right at the start, in the second post of this series. The second line of the hint is also the topic of the coming posts: the default is planned to become main in Git 3.0.

pull: From Warning to Error (2.27, 2.33.1)

git pull used to merge without asking anything when the local and remote branches had diverged, leaving "Merge branch 'main' of ..." commits in the history. Git 2.27 turned that into a warning: if pull.rebase was not set and you made no choice on the command line, it printed a warning and merged anyway. Since 2.33.1, if the branches have diverged, Git does not guess at all:

$ git pull
From ../bookstore
   4734313..5d4e711  main       -> origin/main
hint: You have divergent branches and need to specify how to reconcile them.
hint: You can do so by running one of the following commands sometime before
hint: your next pull:
hint:
hint:   git config pull.rebase false  # merge
hint:   git config pull.rebase true   # rebase
hint:   git config pull.ff only       # fast-forward only
hint:
hint: You can replace "git config" with "git config --global" to set a default
hint: preference for all repositories. You can also pass --rebase, --no-rebase,
hint: or --ff-only on the command line to override the configured default per
hint: invocation.
fatal: Need to specify how to reconcile divergent branches.

Note that the fetch happened and origin/main was updated; only the integration step stopped. Nothing is lost; you are asked to make a choice. We went through that choice in detail in the pull post.

For Big Repos: sparse-checkout, maintenance, fsmonitor, scalar

A good part of this period's changes came from the huge monorepos at Microsoft and GitHub. The first is the git sparse-checkout command in 2.25. Before it, the same job was done with the core.sparseCheckout setting and a hand-edited .git/info/sparse-checkout file. Now you can keep only the folders you want in the working tree:

$ git sparse-checkout set src
$ ls
README.md
src
$ git sparse-checkout list
src
$ git sparse-checkout disable
$ ls
README.md
docs
src

The docs folder disappeared but the files at the root stayed. This is "cone" mode: it works per folder and it is fast. Since 2.37 it is the default mode, and the old pattern-based mode is deprecated.

2.29 brought git maintenance. The release notes introduce it as "git gc's big brother". For scheduled maintenance there is git maintenance start: it registers the repo and adds jobs to the operating system's scheduler. Since 2.31 it can use launchd on macOS and Task Scheduler on Windows besides cron. To stay away from the scheduler, I only show the registration part here:

$ git maintenance register
$ git config list --show-scope | grep maintenance
global	maintenance.repo=.../bookstore
local	maintenance.auto=false
local	maintenance.strategy=incremental
$ git maintenance unregister

2.37 added a built-in file system watcher on Windows and macOS. With core.fsmonitor on, instead of scanning every file on each git status, Git asks a daemon "what changed since last time?":

$ git config set core.fsmonitor true
$ git status -s
$ git fsmonitor--daemon status
fsmonitor-daemon is watching '.../bookstore'
$ git fsmonitor--daemon stop
$ git config unset core.fsmonitor

You won't notice the difference in a small repo; in a repo with hundreds of thousands of files, git status gets noticeably faster. 2.38 then brought scalar, which bundles all of these settings into one command, into Git itself. It may not ship with every install; for example, it is not in the Apple Git on the macOS machine I used for this post.

On the Merge and Rebase Side: ort, remerge-diff, --update-refs

Since 2.34 the default merge strategy is ort. The previous default was recursive; you may remember older versions printing "recursive" on this line after a merge. ort was written from scratch to produce the same results much faster and to cope better with renames:

$ git merge --no-ff -m "Merge docs/readme" docs/readme
Merge made by the 'ort' strategy.
 README.md | 2 ++
 1 file changed, 2 insertions(+)

--remerge-diff from 2.36 is very handy in code review. It used to be hard to see how a conflict was resolved in a merge commit. Now Git redoes the merge in the background and shows the difference between the automatic result and what was committed:

$ git merge feature/vat
Auto-merging src/cart.js
CONFLICT (content): Merge conflict in src/cart.js
Automatic merge failed; fix conflicts and then commit the result.
$ git commit -m "Merge feature/vat"
[main 5ae3eea] Merge feature/vat
$ git log -1 --remerge-diff
commit 5ae3eeae85374b5a38401f24bbedeb80c2878a46
Merge: e95afaf b7165de
Author: Ada Lovelace <ada@example.com>
Date:   Sat Nov 7 10:28:00 2026 +0300

    Merge feature/vat

diff --git a/src/cart.js b/src/cart.js
remerge CONFLICT (content): Merge conflict in src/cart.js
index b9e845c..7c03358 100644
--- a/src/cart.js
+++ b/src/cart.js
@@ -1,7 +1,3 @@
 export function total(items) {
-<<<<<<< e95afaf (Merge docs/readme)
-  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
-=======
-  return items.reduce((sum, item) => sum + item.price * 1.2, 0);
->>>>>>> b7165de (Add VAT to cart total)
+  return items.reduce((sum, item) => sum + item.price * item.qty * 1.2, 0);
 }

The left side of the diff is the automatic result with conflict markers, the right side is your resolution. For a merge without conflicts this diff is empty; if someone slipped an extra change into a merge by hand, it stands out immediately.

--update-refs from 2.38 made life easier for anyone working with branches stacked on top of each other. feature/coupon-ui sits on top of feature/coupon-model, and you want to move both onto main:

$ git log --oneline --graph --all --decorate
* 040e476 (main) Add license note
| * 274649e (HEAD -> feature/coupon-ui) Add coupon form
| * 1e533fd (feature/coupon-model) Add coupon model
|/
* 5fc3d11 Add cart total
* 5f1daed Add bookstore skeleton
$ git rebase --update-refs main
Successfully rebased and updated refs/heads/feature/coupon-ui.
Updated the following refs with --update-refs:
	refs/heads/feature/coupon-model
$ git log --oneline --graph --all --decorate
* 49f3940 (HEAD -> feature/coupon-ui) Add coupon form
* e3fa032 (feature/coupon-model) Add coupon model
* 040e476 (main) Add license note
* 5fc3d11 Add cart total
* 5f1daed Add bookstore skeleton

The branch underneath used to stay on the old commit, and you had to move it to its new place by hand with git branch -f. With rebase.updateRefs true you can make this behaviour the default.

A Safer Force Push: --force-if-includes (2.30)

--force-with-lease means "overwrite the remote branch if it is where I think it is". The problem is that "where I think it is" means origin/main. If your editor runs git fetch in the background, Git acts as if you had seen Alan's new commit, and the lease loses its meaning. Ada amended her last commit; meanwhile Alan pushed a commit and a fetch ran on Ada's side:

$ git commit --amend -q -m "Note discount rules"
$ git fetch
From ../bookstore
   b96752d..466788a  main       -> origin/main
$ git push --force-with-lease --force-if-includes
To ../bookstore.git
 ! [rejected]        main -> main (remote ref updated since checkout)
error: failed to push some refs to '../bookstore.git'
hint: Updates were rejected because the tip of the remote-tracking branch has
hint: been updated since the last checkout. If you want to integrate the
hint: remote changes, use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
$ git push --force-with-lease
To ../bookstore.git
 + 466788a...87c5868 main -> main (forced update)
$ git log --oneline origin/main
87c5868 Note discount rules
5d4e711 Add Ulysses
4734313 Add cart total
14693d0 Add bookstore skeleton

--force-if-includes checks whether the remote tip appears in the reflog of your local branch. Alan's commit never made it into Ada's branch, so the push was rejected. --force-with-lease on its own went through, and Alan's "Add currency" commit was wiped from the history. That is why keeping push.useForceIfIncludes true turned on is a good habit.

Security: safe.directory (2.35.2) and SSH Signing (2.34)

2.35.2, released in March 2022, changed Git's behaviour for CVE-2022-24765. On a shared machine, another user could put a .git folder in one of your parent folders and run commands through the config in it. Since that release, Git refuses to work in a repo you don't own:

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

	git config --global --add safe.directory .../bookstore

You don't need to create another user to see this output: GIT_TEST_ASSUME_DIFFERENT_OWNER is a variable Git uses in its own tests that makes it act as if the repo had a different owner. In real life you will see this error most often in Docker volumes and on CI runners. The fix is written in the error itself; the value * trusts everything and only makes sense in single-user, throwaway environments. The fix was also backported to the maintenance releases from 2.30.3 to 2.34.2.

The SSH signing we used in the previous post is from this period too: gpg.format ssh arrived in 2.34. In the same period, the transfer.credentialsInUrl setting in 2.37 made it possible to warn or stop when a fetch or push uses a URL with a password in it.

Common Pitfalls

The first is taking git checkout file from an old guide for "I'm switching branches". It deletes your working tree change without asking. If you use restore for restoring files and switch for branches, the confusion goes away.

The second is silencing the pull error with git config --global pull.rebase false without knowing what you chose. All three options in the hint are valid; what matters is that the team makes the same choice.

The third is switching off the safe.directory error for good with safe.directory '*'. It looks harmless on a personal laptop, but shared machines are exactly why the error exists.

The fourth is thinking --force-with-lease alone is safe enough. If you use an IDE that fetches in the background, the lease protects you almost not at all without --force-if-includes.

The fifth is trying the commands in this post on an old server or CI image. If git --version is older than 2.23 there is no switch, and older than 2.34 SSH signatures cannot be verified. Checking the Git version in your CI once ends the "it worked on my machine" argument before it starts.

A Small Experiment

★☆☆ Rewrite these three commands from an old guide with their new equivalents and try them: git checkout -b hotfix, git checkout -- README.md, git reset HEAD README.md.

★★☆ While resolving a conflict, also change a line that has nothing to do with the conflict, then create the merge commit. Does git log -1 --remerge-diff show that extra change? Compare it with git show.

★★★ Build a three-level branch stack (part1, part2, part3), add a new commit to main, and rebase first without --update-refs, then with it. Compare the output of git log --oneline --graph --all --decorate each time and find which branches still point at the old commits.

In the next post we move on to Git 2.40–2.49: SHA-256 repos, reftable, git replay, the git config subcommands and git refs migrate. In other words, the years when the foundations of Git 3.0 were laid.