Git 2.50–2.56: git history, Config-Based Hooks and git add --resolved
We go from Git 2.50 to the current 2.56: git history reword and split (fixup in 2.55, drop in 2.56), config-based and parallel hooks, stash export/import, pull.autostash and what happens on a conflict, git last-modified, git repo info, reflog drop, branch --delete-merged, add --resolved and the -h exit code change.
Version note: This post covers the changes between Git 2.50 and 2.56; the commands were tested with Git 2.54. Features that exist only in 2.55 and 2.56 are taken from the release notes, and no output is shown for them.
Guess First
Two commits ago in the bookstore you spelled "license" as "licence". There is one more commit on top of it, and a feature/later branch points at that commit:
$ git log --oneline
d0bd6b7 Add search and use it in cart
ab20775 Add licence note
ff3f4c5 Add cart total
9a41a96 Add bookstore skeleton
$ git history reword HEAD~1
The command opened the editor, and you fixed the message and saved it. You did not rebase, you did not see a todo list. Do you think any branch other than main changed? Did anything happen to the working tree?
$ git log --oneline --decorate
d89236c (HEAD -> main, feature/later) Add search and use it in cart
0318329 Add license note
ff3f4c5 Add cart total
9a41a96 Add bookstore skeleton
$ git status -s
Two commits got new hashes, main and feature/later moved to their new places together, and the working tree was not touched at all. This is git history, which arrived as experimental in 2.54. In this post we look at what came between Git 2.50 and today's current release, 2.56. Since I have 2.54 locally, I describe the commands specific to 2.55 and 2.56 from the release notes without running them.
git history: Fixing History Without a Rebase
To change the message of an old commit you used to open an interactive rebase, mark the line reword, save, and fix the message when the editor opened again. If a conflict came up along the way, or other branches sat on top of that commit, it took longer. git history does this kind of single-purpose fix directly. According to its man page it differs from rebase in three ways: it does not touch the working tree or the index, so it works even in a bare repo; it does not run hooks for now; and by default it updates every branch on top of the commit being fixed.
The second subcommand in 2.54 is split: splitting one commit into two. The last commit both adds a new file and changes a line in the cart; we want to separate them:
$ git history split HEAD
diff --git a/src/cart.js b/src/cart.js
index aee2863..b4a0a05 100644
--- a/src/cart.js
+++ b/src/cart.js
@@ -1,3 +1,4 @@
export function total(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
+// uses search
(1/1) Stage this hunk [y,n,q,a,d,?]? n
diff --git a/src/search.js b/src/search.js
new file mode 100644
index 0000000..2c9809a
--- /dev/null
+++ b/src/search.js
@@ -0,0 +1 @@
+export const search = (q) => q;
(1/1) Stage addition [y,n,q,a,d,?]? y
$ git log --oneline --stat -3
120cbf0 Wire search into cart
src/cart.js | 1 +
1 file changed, 1 insertion(+)
6a260b8 Add search module
src/search.js | 1 +
1 file changed, 1 insertion(+)
0318329 Add license note
README.md | 2 ++
1 file changed, 2 insertions(+)
The prompts work like git add -p. The hunks you answer "y" to go into a new commit, which becomes the parent of the original; the rest stay in the original commit. The editor asks for the messages of both commits. Choosing all hunks or none is invalid, because one of the commits would then be empty.
The rest of the family came in later releases. 2.55 added the fixup subcommand, which folds the changes in the staging area into an older commit; it is a one-step version of the git commit --fixup plus rebase --autosquash pair. 2.56 brought drop, which deletes a commit and replays the commits on top of it. squash also shows up in the development version. The limits are clear for now: it does not work on history that contains merges, and it refuses up front any operation that could produce a conflict. In those cases you go back to interactive rebase.
Hooks: Config-Based (2.54) and Parallel (2.55)
In the hooks post we tried 2.54's config-based hooks in detail: attaching several hooks to one event with hook.<name>.command and hook.<name>.event, seeing them with git hook list, and turning one off in a single repo with hook.<name>.enabled false. For the same job you used to either merge everything into one script or turn to a tool like the pre-commit framework or Husky.
2.55 added parallel execution on top of that. You mark a hook as safe to run in parallel with hook.<name>.parallel and set how many run at once with hook.jobs; git hook run also gained a -j option. Running independent lint, format and secret checks at the same time instead of waiting for each in turn is a new way to keep pre-commit fast.
Moving a Stash: export and import (2.51)
A stash has always been local; refs/stash is not pushed. To move half-finished work from your laptop to your desktop you made a temporary commit or produced a patch file. 2.51 brought export and import, which turn stashes into a chain of ordinary commits and back:
$ git stash list
stash@{0}: On main: Search draft
stash@{1}: On main: Coupon draft
$ git stash export --to-ref refs/stashes/ada
$ git log --oneline --first-parent refs/stashes/ada
c06de16 git stash: On main: Search draft
a98a124 git stash: On main: Coupon draft
73c9bab
Now there is an ordinary ref, and refs can be pushed and fetched. On the other machine:
$ git fetch -q origin refs/stashes/ada:refs/stashes/ada
$ git stash import refs/stashes/ada
$ git stash list
stash@{0}: On main: Search draft
stash@{1}: On main: Coupon draft
$ git stash pop -q stash@{1}; git status -s
M src/cart.js
Here the "other machine" is a clone on the same disk and origin is the first repo; with GitHub you would send it with git push origin refs/stashes/ada. The stashes arrived with their order and messages. The stash.index setting from 2.52 is small but useful too: when it is on, stash pop and stash apply always behave as if --index was given, so what you had staged comes back staged.
pull.autostash (2.51)
Pulling with rebase while you have uncommitted changes fails. The --autostash option has existed for a long time, and the setting went through rebase.autoStash and merge.autoStash. 2.51 added pull.autostash, which applies to pull only:
$ git pull
error: cannot pull with rebase: You have unstaged changes.
error: Please commit or stash them.
$ git pull --autostash
From ../bookstore
ba82313..aef586f main -> origin/main
Updating ba82313..aef586f
Created autostash: 54d5401
Fast-forward
README.md | 2 ++
1 file changed, 2 insertions(+)
Applied autostash.
$ git config set pull.autostash true
This convenience has a price. This time Alan added a line at the end of src/cart.js and pushed, while you were working at the end of the same file:
$ git pull
From ../bookstore
aef586f..05cae60 main -> origin/main
Updating aef586f..05cae60
Created autostash: eb26e42
Fast-forward
src/cart.js | 1 +
1 file changed, 1 insertion(+)
Applying autostash resulted in conflicts.
Your changes are safe in the stash.
You can run "git stash pop" or "git stash drop" at any time.
$ git status -s
UU src/cart.js
$ git stash list
stash@{0}: autostash
The pull finished, but applying the stash back produced a conflict. Your change is not lost; it is both in the working tree as a conflict and in the stash. After resolving the conflict you have to remove the copy in the stash yourself with git stash drop.
Querying the Repo: last-modified and repo info (2.52)
Have you ever wondered about the "last commit" column in GitHub's file list? Running a separate git log -1 -- file for every file is slow in big repos. git last-modified from 2.52 does it in one go:
$ git last-modified
e48390a0e516c2b0e8d9e2993d06af0c6aae5b4f README.md
ff3f4c51d3c44e197a4e99ff4c7f879d9e6a4f51 src
9a41a96098c29c9cc79f5213208d140d75a3cd0c .gitignore
$ git last-modified -r
e48390a0e516c2b0e8d9e2993d06af0c6aae5b4f README.md
ff3f4c51d3c44e197a4e99ff4c7f879d9e6a4f51 src/cart.js
9a41a96098c29c9cc79f5213208d140d75a3cd0c .gitignore
9a41a96098c29c9cc79f5213208d140d75a3cd0c src/books.js
git repo info from the same release is the proper way for scripts to ask questions about a repo. You used to collect this information with various git rev-parse options or by reading the config:
$ git repo info layout.bare object.format references.format
layout.bare=false
object.format=sha1
references.format=files
$ git repo info --keys
layout.bare
layout.shallow
object.format
references.format
$ git repo structure
| Repository structure | Value |
| ------------------------- | -------- |
| * References | |
| * Count | 1 |
| * Branches | 1 |
...
git repo structure gives ref and object counts, sizes and the largest objects as a table. When you are trying to understand why a repo has bloated, this is the first place to look.
Cleanup: reflog drop, --delete-merged, add --resolved
2.50 brought git reflog drop, which deletes a ref's whole reflog. You used to have to give git reflog expire a time and a ref for that:
$ git reflog show experiment
e183ba5 experiment@{0}: commit: Try something
4734313 experiment@{1}: branch: Created from HEAD
$ git reflog drop experiment
$ git reflog show experiment; echo $?
0
$ git reflog exists refs/heads/experiment; echo $?
1
The branch is still there; only its history record is gone. Remember that the reflog is a safety net; use this command knowing what it does.
Two more small but lovable commands come from 2.56. git branch --delete-merged deletes, in bulk, local branches that are already merged into their upstream; with --dry-run you can first see what it would delete. You used to write a fragile pipeline like git branch --merged | grep -v main | xargs git branch -d for that. git add --resolved, while you resolve conflicts, stages only the files you resolved and stops if a file still contains conflict markers. It prevents you from accidentally committing a half-resolved file with git add ..
What Changed for Scripts and Builds
Some changes of this period matter more to scripts and packagers than to everyday commands. The sneakiest one is the exit code of the help output:
$ git status -h >/dev/null; echo $?
129
$ git version
git version 2.54.0 (Apple Git-157)
In older releases, 2.54 included, -h exits with 129. Since 2.56, -h and --help return 0. A script that answers "does this subcommand exist?" with the exit code of git foo -h may behave differently depending on the version.
In 2.51 git switch and git restore officially stopped being experimental; the label is gone from the 2.54 man page. In 2.55 Rust was turned on by default in both of Git's build systems; packagers can still turn it off, but that option is planned to go away in Git 3.0. The same release brought fsmonitor to Linux via inotify and started masking terminal control characters in sideband messages from the remote, except ANSI colours. In 2.54 the default strategy of git maintenance became geometric; for the old behaviour there is maintenance.strategy=gc.
Common Pitfalls
The first is using git history on shared commits. Whatever it is called, it rewrites history; hashes change. If you reword a commit you have pushed, you still need a force push, and your teammates are still left with the old commits.
The second is forgetting that git history updates all branches. feature/later in Guess First moved silently. If you only want HEAD to move there is --update-refs=head; if you are not sure, look at which refs would change with --dry-run first.
The third is turning on pull.autostash and panicking at a conflict. The output says it clearly: the change is safe in the stash. If you forget to drop the stash after resolving, meaningless autostash entries pile up in your list.
The fourth is assuming the 2.55 and 2.56 features in this post exist on every machine. The Git that comes with Xcode on macOS, Linux distribution packages and CI images are often a few releases behind. That is the case on the machine I wrote this post on, too.
A Small Experiment
★☆☆ Find a commit with a typo in its message in your own repo, first check which refs would change with git history reword --dry-run, then really fix it.
★★☆ Create two stashes, export them to a ref with git stash export --to-ref, push the ref to a local bare repo, fetch it in another clone and bring them back with git stash import. Are the stash messages and their order preserved?
★★★ While splitting a commit with git history split, answer "y" to the first prompt and "y" to the second as well. What does Git say? Why?
The next post is the last one of the series for now: Git 3.0. What will change, what will be removed, what happens to git checkout, and how you can prepare from today.