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.
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.
printf 'name: shop\n' > README.md
git status
git diffstatus 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.
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:
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 statusgit 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:
git restore README.mdgit 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.
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.
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:
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:
06a81ae HEAD@{0}: reset: moving to HEAD~1
a28a4da HEAD@{1}: commit: Note the VAT rateHEAD@{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:
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.
git switch main
git switch vatgit 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:
printf 'draft\n' >> README.md
git stash push -m "half a note"
git switch main
git switch vat
git stash popstash 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:
git restore README.md
git switch --detach HEAD~1status 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:
git switch -c keep-vat-note
git switch vatChecking 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:
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.
git merge --no-edit mainThe 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:
<<<<<<< HEAD
vat: 20
=======
vat: 5
>>>>>>> mainLeave 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:
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 mainCommits 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.
git fetch origin
git status -sb
git log --oneline --decorate --graph --allstatus -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:
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:
git fetch origin
git log --oneline HEAD..origin/vatHEAD..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 diffis not the next commit.git diff --stagedis. Stage, then edit again, and the commit keeps the staged bytes.git commit -askips untracked files.git add -Awill not skip a secret in the tree..gitignoredoes not untrack, and does not erase older commits.git switchselects a snapshot. Detached commits drop offgit branchwhen you leave. Name them withgit switch -cfirst.--hard,git restoreon a path, andgit clean -fddrop 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-leasestill overwrites the remote.git pullmerges unlesspull.rebaseis 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.