Skip to main content

Git History: Log, Blame, Bisect, Revert and Reflog

Search Git history by message, file and content, find who changed a line, bisect a bug, undo with reset, revert and the reflog, and remove a leaked secret.

Every commit you make is a record you can search later: what changed, who changed it, when, and (if the message is good) why. This part shows how to get answers out of that record, how to find the commit that broke something with git bisect, and how to undo or rewrite commits safely. It ends with the one rewrite you hope you never need: removing a leaked secret from the whole history.

Prerequisites

  • The status-page repository from Part 7, with Sam's clone. Any repository with some history works for the search commands.
  • Comfort with commits and branches (Parts 3 to 5).

Sam spent the morning adding services and pushed seven small commits. To follow along, make Sam's commits in Sam's clone (the small add function adds one service per commit):

Then pull them into your clone:

Read the Log Your Way

git log takes dozens of options; a handful cover most needs:

Format placeholder Shows Placeholder Shows
%h, %H Short and full hash %an, %ae Author name and email
%s, %b Subject and body %ad, %ar Author date (absolute, relative)
%d Branch and tag names %cn, %cd Committer name and date

--date=short, --date=iso and --date=relative change how %ad is printed. Give git shortlog a revision such as HEAD in scripts: without one, it reads a log from standard input whenever that is not a terminal. Put a format you like in an alias (Part 9).

Search the History

By message, author and date

--grep searches commit messages (-i ignores case), --author matches name or email, and --since/--until accept dates and phrases such as "2 weeks ago". Different kinds of filters combine with AND. Several --grep options match commits whose message matches any of them (--all-match requires all of them), and several --author options match any of the authors.

By file

-- <path> limits the log to commits that changed that path. Without --follow, the history of template.html starts at the rename; with it, Git continues through the commits when the file was still index.html. Add -p to see each change. View the change history of a file has more variations, including GUI tools.

By content: the pickaxe

To find when a piece of text appeared or disappeared, search the diffs instead of the messages:

  • -S <text> (the pickaxe) finds commits that changed how many times the text occurs: the commit that added it and the one that removed it.
  • -G <regex> finds commits whose diff has an added or removed line matching the regular expression, including lines that were only edited.

By line: the history of a few lines

git log -L follows a range of lines through the history and shows each change to them, even when the lines moved:

The range can be line numbers (-L 9,12:file), a regular expression and an offset as above, or a function name in languages Git understands (-L :main:app.py).

Show Old Versions of Files

<commit>:<path> names a file as it was in that commit, and <commit>^ is that commit's parent: the last version before the deletion. To bring an old version back into your working tree, use git restore --source=<commit> -- <path> (reset or revert a file to a specific revision, find and restore a deleted file). git show --stat <commit> or git show --name-only <commit> lists the files a commit changed (more ways).

Compare Commits and Branches

git diff takes two commits, or a range:

git cherry compares changes rather than hashes: a commit on release/0.2 whose diff also exists on main is marked -, and one without an equivalent is marked +. The backported fix shows + even though it is the same fix. The comparison includes the unchanged lines around each change, and on main the li rule from Part 6 follows the block that was fixed, while on the release branch nothing does. That is why the -x line from Part 7 matters: searching for it finds backports reliably.

Two dots and three dots mean different things in git log and git diff, which confuses everyone at least once:

Command Shows
git log main..feature Commits in feature that are not in main (what a merge would bring in)
git log main...feature Commits in either branch but not in both (add --left-right to see which side)
git diff main feature or main..feature The difference between the two snapshots
git diff main...feature The changes on feature since it branched from main: what a pull request shows

Git diff between two branches has more examples.

Who Changed This Line?

git blame shows, for every line of a file, the last commit that changed it:

Useful options:

  • -w ignores whitespace changes, and -C -C -C looks for lines moved or copied from other files, so a refactoring does not hide the original author.
  • --ignore-rev <commit> skips a commit, such as a mass reformatting. List such commits in a .git-blame-ignore-revs file and set git config blame.ignoreRevsFile .git-blame-ignore-revs; GitHub's blame view reads that file too.
  • git blame <commit> -- <file> blames an older version.

Blame tells you who and which commit; read the commit message (git show <hash>) for why. Git 2.52 also added git last-modified, which prints the last commit that touched each file or directory, like the commit column in a hosting service's file browser:

Find the Commit That Broke Something with git bisect

After Sam's commits, the build fails:

The message points at a line, so git blame would find the culprit here. Often you have no such clue: a test fails, the page got slow, a number is wrong. git bisect finds the first bad commit by binary search: you give it one bad commit and one good one, and it checks out the commit in the middle for you to test, halving the range each time. Ten steps cover a thousand commits.

The last version you built successfully is where main was before the pull, which the reflog calls main@{1}. If you have a command that exits with 0 for good and non-zero for bad, git bisect run does the testing too:

Git tested two commits and named the first bad one. git bisect reset returns you to the branch you started from; Git 2.56's git bisect run --reset-when-found does that automatically. Without a script, you test each commit yourself and tell Git the result:

git bisect start
git bisect bad                 # the current commit is broken
git bisect good v0.2.0         # this one worked
# Git checks out a commit in the middle; test it, then:
git bisect good                # or: git bisect bad, or git bisect skip if you cannot test it
# repeat until Git prints "<hash> is the first bad commit"
git bisect reset

The exit code 125 from a bisect run script means "cannot test this commit" (like skip), which helps when some commits do not build. Git bisect for your data applies the same search to a database.

Undo Commits: reset, revert and the Reflog

git revert: undo a commit that others already have

The bad commit is on main and pushed, so you must not rewrite it. git revert makes a new commit that applies the opposite change:

:/<text> names the newest commit whose message matches the text, which saves copying a hash; a hash works just as well. This revert stopped with a conflict: Sam's next two commits added lines right after the legacy line, so the line Git wants to remove no longer has the neighbors it had in the original commit. A revert is a merge in reverse, and conflicts work the same way:

git revert --continue opens your editor with the prepared message ("Revert ..." and "This reverts commit ..."); save it to finish. The history now shows both the mistake and its reversal, which is honest and safe for everyone who already pulled. To revert a merge commit, tell Git which parent is the mainline: git revert -m 1 <merge> undoes everything the merge brought into the first parent (usually main). Note that Git then considers that branch merged; to merge it again later, revert the revert first.

git reset: move the branch back

git reset <commit> moves the current branch to another commit. The three modes differ in what happens to the changes in the commits you "remove":

Mode Branch pointer Staging area Working tree Use it to
--soft Moves Keeps the changes, staged Untouched Redo the last commits as one
--mixed (default) Moves Reset to the commit Untouched, changes unstaged Unstage, or regroup changes into new commits
--hard Moves Reset Reset: changes are discarded Throw away commits and local changes

git reset --hard is the one command in this part that destroys work that was never committed. Before using it, run git status and git stash anything you might want. Reset only commits you have not pushed; for pushed commits, revert. Undo the most recent local commits, reset to a previous commit and delete a commit from a branch go through more cases.

The reflog: Git's undo history

Every time a branch or HEAD moves (commit, reset, rebase, merge, switch), Git writes an entry in the reflog. Commits that no branch points to any more are still reachable through it, by default for 30 days:

HEAD@{2} means "where HEAD was two moves ago", and main@{1} (used for bisect above) means "where main was before its last move". ORIG_HEAD is set by reset, rebase and merge to where you were before them, so git reset --hard ORIG_HEAD undoes the most recent of them, as long as no other reset, rebase or merge ran since (each one overwrites it).

In Part 4 you deleted the unmerged branch experiment/search with -D. Its commit is still in the reflog, days later:

Reflogs are local: they record what happened in your clone and are never pushed. A fresh clone starts its own reflog and does not get the reflogs of the repository it was cloned from.

Rewrite Local History with Interactive Rebase

Before you open a pull request, you can clean up your branch: merge "fix typo" commits into the commits they fix, reword messages, drop debugging commits, reorder or split commits. Only do this for commits nobody else has. Here is a branch that needs it:

Reword any commit with git history (Git 2.54+, experimental)

The first message has a typo, and it is not the last commit, so --amend does not reach it. Git 2.54 added git history, a simpler command for single rewrites. git history reword <commit> opens your editor with the commit's message and rewrites that commit and everything after it:

git history has four subcommands: reword, fixup (put the staged changes into an older commit), split (split a commit in two, choosing hunks) and drop (remove a commit). It updates every branch that contains the rewritten commit, stops instead of leaving you with a conflict to resolve, and does not work across merge commits yet. The Git project marks it experimental: its behavior may still change, so for scripts and for anything complex, use git rebase -i.

Squash and drop with git rebase -i

git rebase -i HEAD~3 opens your editor with a todo list: one line per commit, oldest first, each starting with a command. Git opens this list (followed by a comment block that explains the commands):

pick 678a72b # Add a contact link to the footer
pick 4d6c548 # Fix the contact address
pick c179562 # Print debug output in build.sh

Change the words at the start of the lines, save and close:

pick 678a72b # Add a contact link to the footer
fixup 4d6c548 # Fix the contact address
drop c179562 # Print debug output in build.sh

The fix is now part of the first commit, and the debugging commit is gone. The commands you will use most:

Command Does
pick Keep the commit as it is
reword Keep the commit, edit its message
edit Stop after this commit, so you can amend it or split it (git reset HEAD~, then several commits), then git rebase --continue
squash Combine with the previous commit and edit the combined message
fixup Combine with the previous commit and keep the previous message (fixup -C keeps this one's)
drop Remove the commit (deleting the line does the same)
exec Run a shell command at that point; the rebase stops if it fails

Moving lines reorders commits. git rebase -i --autosquash puts fixup! commits in place for you (Part 7), and git rebase --exec scripts/check.sh main runs a check after every commit, so each commit on the branch passes, not only the last. If a rebase goes wrong, git rebase --abort stops it. After it finished, find the branch's previous tip in git reflog (the entry before rebase (start)), check it with git log, and git reset --hard to it. ORIG_HEAD points there too, but only until the next reset, merge or rebase, and an edit step that runs git reset HEAD~ already overwrites it.

The branch is ready. Merge it and push:

To change the author of commits, see change the author of a single commit and of multiple commits.

Remove a Secret from History

Sam pushes a deploy script on a branch, and with it a deploy.env file that contains a token:

Deleting the file in a new commit does not help: the token stays in the first commit, in every clone, and on the server. The order of the fix matters:

  1. Revoke or rotate the secret first. Assume it is compromised the moment it was pushed. Rewriting history does not undo that, and on public repositories, automated scanners find leaked keys within minutes.
  2. Remove the file from every commit with git filter-repo.
  3. Force-push the rewritten branches, and have everyone delete or reset their old copies.

git filter-repo is a separate tool (a single Python script) recommended by the Git project in place of the old git filter-branch. The current release is 2.47.0:

It refuses to run in a repository with local work, so start from a fresh clone. For a clone from a local path, add --no-local, or filter-repo does not accept it as fresh. It turns the clone's remote branches into local ones and removes origin, so you do not push by accident:

git log --all -- deploy.env prints nothing: no commit on any branch contains the file any more. Only feature/deploy was rewritten (its two commits got new hashes); the other branches had no deploy.env and kept their hashes. Sam's clone still has the old commits, so Sam replaces the branch with the rewritten one:

git reset --hard replaced the branch with the rewritten one, and it also deleted deploy.env from Sam's working tree, because the file was tracked in the commit Sam was on and is not in the new one. If such a file holds configuration you still need, copy it somewhere safe before the reset, put it back afterwards, and add its name to .gitignore.

Other useful forms:

  • git filter-repo --replace-text expressions.txt replaces text inside files in every commit (a line such as dt_live_4f8a1c9e7b2d==>REMOVED), for a secret in a file that must stay.
  • git filter-repo --strip-blobs-bigger-than 10M removes large files that should never have been committed.
  • On GitHub or GitLab, old commits can stay reachable through pull request refs, forks and caches after a force push. GitHub's guide to removing sensitive data explains when you need to contact support.

Part 9 shows how to stop secrets before the commit.

Common Mistakes

  • git reset --hard with uncommitted work. It is gone. git stash first, or use --keep, which refuses to throw away local changes.
  • Resetting or rebasing pushed commits on a shared branch. Use git revert.
  • Looking only at git log for a file that was renamed. Add --follow.
  • Mixing up .. and .... main...feature in git diff is what a pull request shows.
  • Removing a secret without revoking it. The rewrite is cleanup; the rotation is the fix.
  • Panicking after a bad rebase or reset. The reflog has the previous state for 30 days.

Summary

  • Filter git log by message, author, date, path (--follow across renames), content (-S, -G) or lines (-L).
  • git show <commit>:<path> reads old files; git diff and git cherry compare branches.
  • git blame and git last-modified find who changed what; git bisect run finds the commit that broke a test.
  • git revert undoes pushed commits; git reset --soft/--mixed/--hard reworks local ones; the reflog undoes almost anything.
  • git history (experimental) and git rebase -i rewrite local commits; git filter-repo rewrites the whole history after a leak.

Next: Git Best Practices, with commit message conventions, signing, hooks and the settings worth changing.

Keep Learning

Updated:

Tags

GitVersion ControlGitHubBranchingCollaborationDevOps

Found an issue?