Git

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. .git is not versioned, and a fresh clone ships a handful of .sample files and nothing else. To share them, commit them in the tree and point git at that directory with git config core.hooksPath .githooks. That setting replaces .git/hooks entirely: a local hook that used to block no longer runs at all.
  • They are one flag away from gone. git commit --no-verify skips pre-commit and commit-msg, git push --no-verify skips pre-push. Hooks that are not about verification still run, post-commit for 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 update checks out the pinned commit as a detached HEAD. Commit there and the commit belongs to no branch; the next git submodule update puts 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/lib and nothing else. git diff --submodule=log names the commits behind that one line. Moving the pin is git add vendor/lib plus 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: with push.recurseSubmodules set to check the push is refused instead (“The following submodule paths contain changes that can not be found on any remote”), and with on-demand the submodule is pushed first.

    git config push.recurseSubmodules on-demand
  • git submodule update --remote goes the other way: it moves the submodule to the tip of its branch (submodule.<name>.branch in .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
  • tree is the root tree describing this commit’s snapshot. There is one, and only one.
  • parent is 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).
  • author and committer carry 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

Related

Keyboard shortcuts

/Open search
CtrlKOpen search (Cmd+K on macOS)
?Show this cheat sheet
Ctrl/Show this cheat sheet (Cmd+/ on macOS)
EscClose search or this cheat sheet

Turns off the single-key shortcuts (/, ?). Ctrl/Cmd+K and Ctrl/Cmd+/ stay.