KeyboardWarrior.co.ukclient-side utility arcade

Git

Git commits, branches, and the reflog

How a Git commit is recorded: the working tree against the index, branches as labels that move, and the reflog commands that put a dropped commit back.

9 min read

Git keeps three copies of your work. The working tree is the files you edit. The index is the snapshot the next commit will record. HEAD is the commit you are on, and a branch is a label on a commit, not a folder of files. status and diff report the gaps between them. The other commands move a copy.

This note builds a throwaway repository called shop in an empty directory. It needs Git 2.28 or newer. git switch and git restore arrived in 2.23, and checkout is the older spelling. The name stored in a commit is not a login. Set it, or Git invents an address.

shell
git config --global user.name "Ada Lovelace"
git config --global user.email "ada@example.com"
mkdir shop && cd shop
git init -b main

--global writes ~/.gitconfig for every repository you use as that user. Omit it to set the name only in .git/config for this one. Changing it later does not rewrite commits already made.

The working tree and the index

git init -b main creates .git and a branch name. There is no commit yet. A new file sits only in the working tree.

shell
printf 'name: shop\n' > README.md
git status
git diff

status lists README.md as untracked: the index has no entry for that path. git diff prints nothing, because a diff compares versions Git already has.

git add README.md hashes the bytes, stores that blob under .git/objects, and records the path, the mode, and the blob id in the index. The file you edit stays where it is. The index holds a copy of the bytes as they were at the moment of add.

shell
git add README.md
git diff
git diff --staged
git commit -m "Start the shop notes"

git diff compares the working tree with the index. Straight after add they match, so it is empty. git diff --staged (also --cached) compares the index with HEAD. Before the first commit there is no HEAD, and Git compares the index with an empty tree, so README.md shows up. After this commit, --staged is the preview of the next one. git commit writes a tree from the whole index, a commit pointing at that tree, and moves main.

Edit the file and the copies separate. Stage, edit again, and they separate a second time:

shell
printf 'currency: GBP\n' >> README.md
git add README.md
printf 'currency: EUR\n' >> README.md
git diff
git diff --staged
git commit -m "Note currency"
git status

git diff shows EUR, only in the working tree. git diff --staged shows GBP, the copy add made. The commit contains GBP, and status still lists README.md because EUR was never staged. git diff HEAD mixes both gaps, so it is the wrong preview of the next commit.

The EUR line was never committed. Drop it so the rest of this note starts clean:

shell
git restore README.md

git checkout -- README.md is the older spelling. git restore --staged README.md does the other half: it copies HEAD into the index for that path and leaves the working tree alone, which is how you unstage.

git commit -a stages modifications and deletions of tracked files, then commits. It does not add untracked files. git add -A stages every unignored path, including a secret or a build directory sitting beside the source. Read git status first.

.gitignore stops untracked paths being offered. A path already in the index stays tracked, and ignore rules do not rewrite older commits. A password that was committed is still in those objects, and in any clone that fetched them.

shell
printf 'secret\n' > config.local
git add config.local
git commit -m "Add local config"
printf 'config.local\n' >> .gitignore
git rm --cached config.local
git add .gitignore
git commit -m "Stop tracking local config"

git rm --cached drops the index entry and leaves the file on disk. The next commit records the absence. git check-ignore -v config.local names the rule. Git does not store empty directories.

What a commit points at

git show HEAD prints the commit and a diff against its parent. The diff is calculated when you ask. What was stored is a tree, a parent id, an author, a committer, and a message. git log --oneline --decorate prints the label and the short id.

git commit --amend replaces the tip, using the index as the tree. With nothing new staged, the tree stays and the message changes. The author stays unless you pass --reset-author. The new object has a new id; the old id stays in the reflog. Do not amend a commit you have pushed unless you mean to rewrite published history.

HEAD~1 is the first parent. HEAD~2 walks first parents again. On a merge, the first parent is the branch you were on, and HEAD^2 is the other parent. On an ordinary commit, HEAD^2 is an error.

A branch is a label

Creating a branch writes a second name for the commit you are on. It does not copy files.

shell
git switch -c vat
printf 'vat: 20\n' >> README.md
git add README.md
git commit -m "Note the VAT rate"

git switch -c vat is git checkout -b vat. .git/HEAD contains ref: refs/heads/vat, and the new commit moves vat only. Stay here. The reflog demo needs this commit to be the latest movement of HEAD.

The reflog

The reflog records where HEAD, and each branch, have pointed on this machine. Clone, fetch, and push do not copy it. git reflog is HEAD. git reflog show vat is that branch. Drop the commit you just made, and read the log before you run anything else:

shell
git reset --hard HEAD~1
git reflog

--hard moves the label, resets the index, and replaces tracked files. vat no longer names the VAT snapshot. The reflog does. Your hashes will differ. The subjects look like this:

text
06a81ae HEAD@{0}: reset: moving to HEAD~1
a28a4da HEAD@{1}: commit: Note the VAT rate

HEAD@{0} is where you are. HEAD@{1} is one movement of HEAD ago, not one commit ago. Read the subject. It has to say commit: Note the VAT rate before you restore it. Quote the braces. zsh treats @{ as special, and the quotes are harmless in bash:

shell
git reset --hard 'HEAD@{1}'

The label, the index, and the tracked files come back. git switch -c recover 'HEAD@{1}' adds a label instead of moving the branch you are on. Once later commands have shifted the number, use the hash in the left-hand column.

Only a commit is in the reflog. --soft moves the label and leaves both the index and the working tree. --mixed, the default, also resets the index. --hard replaces tracked files as well, and still leaves untracked files. git clean -nfd lists those; git clean -fd deletes them. Neither clean nor a hard reset of uncommitted edits can be undone from the reflog. Stash or commit first. Entries last 90 days while a label can reach the commit, and 30 days once none can. git gc drops them after that.

Switching, stashing, and a detached HEAD

With the VAT commit restored, main still names the older snapshot. Switching changes the working tree. It does not delete the other branch.

shell
git switch main
git switch vat

git switch main is git checkout main. The VAT line leaves README.md and comes back with vat, because it lives in that commit.

A switch is refused when it would overwrite uncommitted work. Stash or commit, then switch. From vat:

shell
printf 'draft\n' >> README.md
git stash push -m "half a note"
git switch main
git switch vat
git stash pop

stash push saves tracked changes and puts the files back to HEAD. Untracked files stay unless you pass -u. git stash show -p prints the patch. pop applies it and drops the entry when the apply is clean; a conflict leaves the entry in git stash list.

The draft is uncommitted again. Drop it, or the next checkout can refuse:

shell
git restore README.md
git switch --detach HEAD~1

status begins HEAD detached at and a short id. HEAD names the commit itself. A commit you make here is real, and no branch moves. Leave without a new name and git branch will not list it. The reflog still has it. Name it before you leave:

shell
git switch -c keep-vat-note
git switch vat

Checking out a tag, or origin/main when you meant main, detaches HEAD in the same way.

Merge and rebase

Merge and rebase both join two lines of commits. The ids that result are not the same.

On main, add a file vat does not have, so the two sides diverge without touching the same lines:

shell
git switch main
printf 'hours: 9-5\n' > HOURS.md
git add HOURS.md
git commit -m "Note opening hours"
git switch vat
git merge --ff-only main

--ff-only slides the label forward when your commit is an ancestor of the branch you name, and it writes no merge commit. Each side here has a commit the other lacks, so Git prints fatal: Not possible to fast-forward, aborting. and changes nothing.

shell
git merge --no-edit main

The new commit has two parents. git rev-parse HEAD^1 HEAD^2 prints the old tip of vat, then main. git log --oneline --decorate --graph --all draws it. git merge --no-ff writes a merge commit even when a fast-forward was possible.

The same lines on both sides stop the merge. The index stays unmerged, and the file gains markers:

text
<<<<<<< HEAD
vat: 20
=======
vat: 5
>>>>>>> main

Leave the text you want, delete the markers, then git add and git commit. git merge --abort restores the pre-merge index and working tree. Abort if you merged on the wrong branch. A commit that still contains the markers stores them as content.

Rebase replays commits onto a new base, and the replayed commits are new objects:

shell
git switch -c prices main
printf 'price: 5\n' > PRICE.md
git add PRICE.md
git commit -m "Add a price"
git switch main
printf 'closed: Sunday\n' >> HOURS.md
git add HOURS.md
git commit -m "Closed Sunday"
git switch prices
git rebase main

Commits on prices that main does not have are rewritten on top of main, and prices moves to the new tip. The old ids remain in the reflog. They change even when the patch text does not, so another branch or a review link can still name the history you replaced.

On a conflict, fix the file, git add it, and run git rebase --continue. Continue writes the replayed commit. git rebase --skip drops that commit. git rebase --abort restores the label, only until the rebase has finished.

Rebase commits nobody else has built on. A pushed branch that other people have extended will duplicate patches on their next update if you rewrite it. Merge instead, or agree the rewrite and use the lease push below.

git pull is fetch plus merge, unless pull.rebase is true, in which case your unpushed commits are replayed. A merge commit you did not ask for is the default meeting a remote that had moved.

Remotes

A remote is another repository. origin is the usual name of the one you cloned from, so the commands below belong to a clone, not to the shop directory above. git remote -v prints the URL. main and origin/main are different labels. origin/main moves on fetch, not when someone else pushes.

shell
git fetch origin
git status -sb
git log --oneline --decorate --graph --all

status -sb shows how far you are ahead of, or behind, the upstream. A clone sets that for the default branch. A local branch tracks nothing until you publish it:

shell
git push -u origin vat

-u records the upstream. Without one, git push stops and prints git push --set-upstream origin vat. push.autoSetupRemote does that recording for you, and it is off unless you set it.

A push that is not a fast-forward is rejected, because the remote has commits you do not. Fetch and look before you integrate:

shell
git fetch origin
git log --oneline HEAD..origin/vat

HEAD..origin/vat is what they have that you do not. Rebase or merge, then push. Pull would have merged without that list.

git push --force-with-lease replaces published commits after a rebase or an amend. It refuses, with stale info, when you have not fetched and the remote has moved. Once you have fetched, the lease passes and the force still overwrites whatever you downloaded. It does not check that those commits are yours. Force a branch only to replace commits you rewrote on purpose. --force skips the stale check too. Do not force main on a repository other people push to.

Pitfalls

  • git diff is not the next commit. git diff --staged is. Stage, then edit again, and the commit keeps the staged bytes.
  • git commit -a skips untracked files. git add -A will not skip a secret in the tree. .gitignore does not untrack, and does not erase older commits.
  • git switch selects a snapshot. Detached commits drop off git branch when you leave. Name them with git switch -c first.
  • --hard, git restore on a path, and git clean -fd drop bytes the reflog never stored. 'HEAD@{1}' is one movement ago. Read the subject.
  • Amend and rebase mint new ids. After you fetch, --force-with-lease still overwrites the remote. git pull merges unless pull.rebase is set.
  • Up to date means up to date with your copy of origin/main. That label moves when you fetch.

git status says which copies differ. The two diffs show the two gaps. The reflog is where a commit goes once its branch label has moved.