Git
Everyone knows commit, pull and push. What follows is the rest: the commands I reach for
when something went wrong, when history needs cleaning before review, or when I need to find out
where a change came from. Then, at the end, the object model that explains why any of this works.
The snippets below were all replayed on git 2.56.0 (and 2.43.0); switch and restore need git
2.23 or later.
Cookbook
Get back a commit you thought you lost
A bad reset --hard, a rebase that went sideways, a branch deleted too fast: the commits are
still in the object database, and the reflog knows where HEAD has been.
git reflog # every position HEAD has been at, most recent first
git reset --hard 'HEAD@{1}' # go back to where HEAD was one move ago
git switch -c rescue <sha> # or park the lost commit on a new branch
Two limits. The reflog is local, it is never pushed, so a colleague’s clone will not save
you. And it expires: entries are pruned after 90 days, or 30 days for entries no longer reachable
from a branch (gc.reflogExpire and gc.reflogExpireUnreachable), which is precisely the case of
a commit you just dropped.
Undo at the right level
Three different things are called “undo” in git, depending on what you want to move back: the working tree, the index, or history.
git restore file.txt # throw away working-tree changes to that file
git restore --staged file.txt # unstage, keep the changes in the working tree
git restore --source=HEAD~1 file.txt # take that file back as it was one commit ago
restore and switch (git 2.23) split what checkout used to do. git checkout still works
and still does both jobs, which is exactly why it was split.
For history:
git reset --soft HEAD~1 # drop the commit, keep its changes staged
git reset --mixed HEAD~1 # drop the commit, keep its changes unstaged (default)
git reset --hard HEAD~1 # drop the commit and its changes (reflog still has it)
git revert <sha> # add a new commit that undoes <sha>, history untouched
Rule of thumb: reset for commits nobody has pulled yet, revert for anything already pushed.
Fix an old commit without a “fix typo” commit
You spot a mistake in a commit that is three commits back. Instead of appending a fixup commit that will pollute the branch:
git add -p # stage only the fix
git commit --fixup <sha> # commit titled "fixup! <subject of sha>"
git rebase -i --autosquash <sha>~1 # squash it into <sha>, in place
The rebase opens an editor with the fixup already moved under its target and marked fixup, so
in practice you save and quit. Two ways to shorten it further:
git config --global rebase.autosquash true # -i always autosquashes
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <sha>~1 # no editor at all
Same warning as reset: this rewrites commits, so only on history you have not shared.
Replant a branch on another base
You branched off the wrong place: feature sits on top of release, and it belongs on main. A
plain rebase does not fix that, it replays everything main does not already have, the release
commits included.
git switch feature
git rebase main
git log --oneline # feat1, release-only, main moved on, base
The three-argument form says where to replant and where to cut:
git rebase --onto main release feature
Read it as: new base, last commit to leave behind, branch to move. The middle argument is the upstream, and it is exclusive: itself and everything before it stays put, everything after it is replayed on the new base.
The same mechanism removes a commit from the middle of a branch:
git rebase --onto HEAD~3 HEAD~2 # replays the last two commits, dropping HEAD~2 itself
Still a rewrite, so still only on history you have not shared.
Find the commit that broke it
bisect does a binary search over a range of commits. With run, it is fully automatic: give it
a command that exits 0 when the commit is fine, non-zero when it is not.
git bisect start <known-bad> <known-good>
git bisect run ./test.sh
git bisect reset
Exit code 125 means “cannot test this one, skip it”, which matters when some commits do not even build.
The trap is the test, not the bisect. A check like grep -q ok state looks right until you
notice that broken contains the letters ok: every commit then tests good, and bisect calmly
blames HEAD. Run the test by hand on a commit you know is good and one you know is bad before
letting bisect loose.
Work on two branches at the same time
A worktree is a second working directory attached to the same repository: same objects, same branches, no second clone and no stashing to switch context.
git worktree add ../hotfix -b hotfix # new directory, new branch checked out in it
git worktree list
git worktree remove ../hotfix
The new directory holds a .git file (not a directory) pointing back into
.git/worktrees/. A given branch can only be checked out in one worktree at a time; use
git worktree add --detach ../review <sha> to just look at some commit.
Search the history, not just the files
git log -S "connect_timeout" --oneline # commits that change how many times that string appears
git log -G "regex" --oneline # commits whose diff matches a regex
git log --oneline -- path/to/deleted/file # a file's history, deletion included
-S answers “when did this appear or disappear”, -G answers “when was this line touched at
all”, including moves that leave the count unchanged.
Blame past the reformatting commit
A repository-wide reformat makes git blame useless: every line is credited to the person who
ran the formatter. Git can skip those commits.
git blame --ignore-rev <reformat-sha> file.py
To make it permanent, list the offending commits in a file and point git at it:
echo <reformat-sha> >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs
Commit that file at the root of the repository: GitHub’s blame view picks it up too.
Set work aside, new files included
git stash push -u -m "wip: trying the other adapter"
git stash list
git stash pop
-u also stashes untracked files. Without it they stay in the working tree, which is the usual
way to believe a stash contains a new file when it does not. -a goes further and includes
ignored files, which is rarely what you want.
Refuse your own bad commits
A hook is an executable script in .git/hooks, named after the event. Three earn their keep:
pre-commit to refuse a commit, commit-msg to refuse a message, pre-push to refuse a push.
Non-zero exit means refuse.
cat > .git/hooks/pre-commit <<'EOF'
#!/bin/sh
if grep -rn TODO --include='*.py' . ; then
echo "pre-commit: TODO left in the tree" >&2
exit 1
fi
EOF
chmod +x .git/hooks/pre-commit
commit-msg receives the path of the message file as its first argument, so it can read what you
wrote:
cat > .git/hooks/commit-msg <<'EOF'
#!/bin/sh
head -1 "$1" | grep -qE '^(feat|fix|docs|chore)(\(.+\))?: ' \
|| { echo "commit-msg: need a conventional subject" >&2; exit 1; }
EOF
chmod +x .git/hooks/commit-msg
Four things to know before relying on any of this:
- Without the executable bit, the hook is skipped, and the only sign is a hint on the way
past:
hint: The '.git/hooks/pre-commit' hook was ignored because it's not set as executable. Easy to scroll past. - Hooks are not cloned.
.gitis not versioned, and a fresh clone ships a handful of.samplefiles and nothing else. To share them, commit them in the tree and point git at that directory withgit config core.hooksPath .githooks. That setting replaces.git/hooksentirely: a local hook that used to block no longer runs at all. - They are one flag away from gone.
git commit --no-verifyskipspre-commitandcommit-msg,git push --no-verifyskipspre-push. Hooks that are not about verification still run,post-commitfor one. A hook is a favour you do yourself, not a rule you impose; enforcement lives on the server (pre-receive), in branch protection, or in CI. - They run from the top of the working tree, whatever directory you called git from, so relative paths inside a hook are relative to the repository root.
Pin another repository inside this one
git submodule add https://example.com/lib.git vendor/lib
git commit -m "add lib submodule"
That commit carries two things: .gitmodules, which holds the path and the url and is versioned
like any other file, and a gitlink, a tree entry with a mode of its own holding one commit id.
git ls-tree HEAD vendor/lib
# 160000 commit 5b33816... vendor/lib
So the parent pins one exact commit of the other repository. Not a branch, not a range.
Filling it in:
git clone --recurse-submodules <url> # at clone time
git submodule update --init --recursive # after a plain clone
A plain clone leaves vendor/lib empty, and git submodule status marks the line with a leading
dash.
Where it bites:
-
submodule updatechecks out the pinned commit as a detached HEAD. Commit there and the commit belongs to no branch; the nextgit submodule updateputs the checkout back on the pin, and your work is only reachable through the submodule’s reflog. Make a branch in the submodule before working in it, and the commit survives that reset. -
A commit inside the submodule shows in the parent as a modified
vendor/liband nothing else.git diff --submodule=lognames the commits behind that one line. Moving the pin isgit add vendor/libplus a commit in the parent. -
The one that hurts. Bump the pin, push the parent, forget to push the submodule: your push succeeds, and the next clone dies on
fatal: Fetched in submodule path 'vendor/lib', but it did not contain <sha>. Git can watch for it: withpush.recurseSubmodulesset tocheckthe push is refused instead (“The following submodule paths contain changes that can not be found on any remote”), and withon-demandthe submodule is pushed first.git config push.recurseSubmodules on-demand -
git submodule update --remotegoes the other way: it moves the submodule to the tip of its branch (submodule.<name>.branchin.gitmodules, the remote’s default branch otherwise) and leaves the parent dirty. The new pin still has to be committed.
A submodule is the right tool when what you want is a pin. For code you edit on both sides every
day, it is friction with a .gitmodules file attached.
Under the hood: the object model
Everything above is just moving pointers around a content-addressed store. Three object types
carry your work, commits, trees and blobs (annotated tags are a fourth, built the same way), and
one rule: an object is (sha1) -> (headers, body), where the sha1 is the hash of the whole thing,
headers included, so it signs both the object and everything it points to.
Look at it yourself, on any repository:
git cat-file -t HEAD # commit
git cat-file -p HEAD # the raw commit object
git cat-file -p HEAD^{tree} # its root tree
Commit. A snapshot of files, in a time-like topology that cannot determine “time” for sure, but that has a notion of ancestors and descendants, so one can follow the logical history of changes.
tree 0fdc764f4c477e55f9326b1340af175cd823518c
parent 723781bf7cc1a6eb8bdecd987975b8af7ed960bb
author Romain Dorgueil <romain@dorgueil.net> 1459760873 +0200
committer Romain Dorgueil <romain@dorgueil.net> 1459760873 +0200
commit message is the body
treeis the root tree describing this commit’s snapshot. There is one, and only one.parentis one of this commit’s ancestors. There can be 0, 1 or more of them (0 for the first commit of a history, 1 for a normal commit, more than one for a merge commit, a commit that makes two or more divergent histories converge).authorandcommittercarry who did what and when. Informational: they cannot and should not be trusted unless you trust whoever provided them.
Tree. No header, only a list of children, one per line: perms type sha1 separated by single
spaces, then a tab, then the filename. This is exactly what git cat-file -p <tree> prints. It is
what a filesystem would call a directory, with hashes instead of inodes, so children are
identified in a universal namespace rather than a locally determined one.
040000 tree 67a00caf3712f4a38c9da6d14a07420530714fb5 doc
100755 blob d22a068dc89ac8d25532b0fea8829832e278ee7a docker-compose.yml
Blob. File content, and only content: not the name, not the place in the tree. Two files with
identical content are stored once, and the two tree entries (in the same tree or in different
ones) point to the same sha1. Check it on two files holding the same line:
git rev-parse HEAD:doc/a.txt HEAD:b.txt prints the same hash twice.
The header is part of what gets hashed, and you can check that by hand:
printf 'same\n' | git hash-object --stdin # 1275430f1765c63e539cb0452565563bd6aef6a6
printf 'blob 5\0same\n' | sha1sum # same hash (shasum on macOS)
On forging
SHA-1 collisions are practical. In 2017 the SHAttered attack (Google and CWI Amsterdam) produced
two different files sharing the same SHA-1, so a blob can in principle be crafted to collide with
another. A commit still cannot be altered silently: changing it changes its hash, and because
every descendant stores its parent’s hash, rewriting one commit forces every later commit to
change too, which is what makes mid-history tampering visible. Modern git ships a hardened SHA-1
(the sha1dc collision detector, on by default since git 2.13) that aborts when it sees a SHAttered-style
collision attempt, and new repositories can opt into SHA-256 with git init --object-format=sha256. SHA-1 stays the default, and the two formats do not interoperate yet.
Config
Aliases:
git config --global alias.ci commit
git config --global alias.st status
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.lg "log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit"
User setup:
git config --global user.name "Romain Dorgueil"
git config --global user.email "romain@dorgueil.net"
Verified on git 2.56.0.
References
-
githooks and gitsubmodules, the reference pages for the two sections above
-
git-rebase, whose “Transplanting” examples are the
--ontoforms used here -
Shell script, the other half of the daily command line toolbox
-
Github flow: Understanding the GitHub Flow
-
Git-flow: A Successful Git Branching Model
-
An UML-like schema of the object model above: