Exploring History
Finding your way through a project's past: git log and its filters, show and diff between commits, blame to see who changed a line, and bisect to find the commit that broke something.
Why history matters
Every commit records a snapshot of the project, who made it, when, and why (see Commits for the basics). That makes the history a searchable record of every decision. When a router stops sending logs, a deployment script breaks, or a colleague asks "why is this timeout 30 seconds?", the answer is usually in the history — if you know how to search it.
The examples on this page use a small repository of network configuration files, maintained by Sam Rivera and Amara Okafor. The examples run with a fixed author and clock (2 March 2026, 09:00 Coordinated Universal Time, UTC), so the commit hashes on this page are exactly the ones you get when you type the same commands with the same name, email and dates. On your own machine the hashes will differ, because your name and the time are part of every commit.
Reading the log
The commit log is the list of
commits reachable from where you are now, shown by
git log. It starts at HEAD (your current
commit) and follows the parent links backward, so the newest commit
comes first. Each entry shows the full commit hash, the author, the
author date and the complete message, with the body indented under
the subject line:
git init -q netops && cd netops
printf 'hostname edge-01\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
printf 'ntp server 192.0.2.10\n' >> router.cfg
git commit -q -am "Add NTP server" -m "Clock drift broke log correlation during the last incident."
git log
commit 894563133ea529dcef999a91fa9a0e96a9e474ab
Author: Sam Rivera <sam@example.com>
Date: Mon Mar 2 09:00:00 2026 +0000
Add NTP server
Clock drift broke log correlation during the last incident.
commit aedb4dc3b766e460a09b5adc1ab430d0a1f674e5
Author: Sam Rivera <sam@example.com>
Date: Mon Mar 2 09:00:00 2026 +0000
Add edge-01 base config
In a terminal, git log opens a pager (usually
less) when the output is longer than the screen. Scroll
with the arrow keys or the space bar, search with /,
and press q to quit. git log -3 shows only
the three newest commits.
Compact formats
The default format takes six lines per commit. For an overview,
--oneline prints the abbreviated hash and the subject
line only. When you need specific fields,
--format takes placeholders: %h short
hash, %an author name, %ad author date
(made short with --date=short),
%s subject. The example below builds a history of five
commits on five different days, by two authors:
git init -q netops && cd netops
# commit DATE AUTHOR MESSAGE: commit everything staged, with a given date and author
commit() { GIT_AUTHOR_DATE="$1T10:00Z" GIT_COMMITTER_DATE="$1T10:00Z" git commit -q --author="$2" -m "$3"; }
printf 'hostname edge-01\ninterface ge-0/0/0\n' > router.cfg
git add router.cfg && commit 2026-03-02 "Sam Rivera <sam@example.com>" "Add edge-01 base config"
printf 'routers:\n - edge-01\n' > inventory.yml
git add inventory.yml && commit 2026-03-03 "Amara Okafor <amara@example.com>" "Add inventory"
printf 'ntp server 192.0.2.10\n' >> router.cfg
git add router.cfg && commit 2026-03-04 "Sam Rivera <sam@example.com>" "Add NTP server to edge-01"
printf 'logging host 192.0.2.20\n' >> router.cfg
git add router.cfg && commit 2026-03-05 "Amara Okafor <amara@example.com>" "Send edge-01 logs to syslog server"
printf ' - edge-02\n' >> inventory.yml
git add inventory.yml && commit 2026-03-06 "Sam Rivera <sam@example.com>" "Add edge-02 to inventory"
git log --oneline
git log --format='%h %ad %an %s' --date=short
570c3d3 Add edge-02 to inventory ef785b7 Send edge-01 logs to syslog server b8fdd26 Add NTP server to edge-01 68d5221 Add inventory 8974a4d Add edge-01 base config 570c3d3 2026-03-06 Sam Rivera Add edge-02 to inventory ef785b7 2026-03-05 Amara Okafor Send edge-01 logs to syslog server b8fdd26 2026-03-04 Sam Rivera Add NTP server to edge-01 68d5221 2026-03-03 Amara Okafor Add inventory 8974a4d 2026-03-02 Sam Rivera Add edge-01 base config
The small commit function at the top only exists to
give each commit its own date and author, so the later filters have
something to filter on. In daily work you just run
git commit, and Git fills in your name and the current
time. The abbreviated hash (seven characters here) is enough to name
a commit in any command; Git makes it longer when a repository grows
so large that seven characters become ambiguous.
Seeing what changed
--stat adds a summary of the files each commit changed
and how many lines were added (+) or removed
(-). -p (patch) adds the full diff. Both
combine with every other option:
git init -q netops && cd netops
# commit DATE AUTHOR MESSAGE: commit everything staged, with a given date and author
commit() { GIT_AUTHOR_DATE="$1T10:00Z" GIT_COMMITTER_DATE="$1T10:00Z" git commit -q --author="$2" -m "$3"; }
printf 'hostname edge-01\ninterface ge-0/0/0\n' > router.cfg
git add router.cfg && commit 2026-03-02 "Sam Rivera <sam@example.com>" "Add edge-01 base config"
printf 'routers:\n - edge-01\n' > inventory.yml
git add inventory.yml && commit 2026-03-03 "Amara Okafor <amara@example.com>" "Add inventory"
printf 'ntp server 192.0.2.10\n' >> router.cfg
git add router.cfg && commit 2026-03-04 "Sam Rivera <sam@example.com>" "Add NTP server to edge-01"
printf 'logging host 192.0.2.20\n' >> router.cfg
git add router.cfg && commit 2026-03-05 "Amara Okafor <amara@example.com>" "Send edge-01 logs to syslog server"
printf ' - edge-02\n' >> inventory.yml
git add inventory.yml && commit 2026-03-06 "Sam Rivera <sam@example.com>" "Add edge-02 to inventory"
git log --stat -2
commit 570c3d3c3fa2b85ea36021a6b6f595ee6dec9d1f
Author: Sam Rivera <sam@example.com>
Date: Fri Mar 6 10:00:00 2026 +0000
Add edge-02 to inventory
inventory.yml | 1 +
1 file changed, 1 insertion(+)
commit ef785b78d7958f535bfb359e62ca3e7165aabc7e
Author: Amara Okafor <amara@example.com>
Date: Thu Mar 5 10:00:00 2026 +0000
Send edge-01 logs to syslog server
router.cfg | 1 +
1 file changed, 1 insertion(+)
git log -p -- router.cfg is a quick way to read the
whole story of one file, change by change. How to read a diff is
explained on
Staging and Committing.
The commit graph
When branches have been merged, the history is not a straight line
but a graph. --graph draws it in text: each
* is a commit, and the lines on the left show which
commits are on which line of work. Add --all to include
every branch, not only the history of the current one. A common
everyday combination is
git log --oneline --graph --all; the next section shows
its output.
Filtering the log
In a repository with thousands of commits, scrolling does not work.
git log can filter on almost anything. The most useful
filters, on the same five-commit history as above:
git init -q netops && cd netops
# commit DATE AUTHOR MESSAGE: commit everything staged, with a given date and author
commit() { GIT_AUTHOR_DATE="$1T10:00Z" GIT_COMMITTER_DATE="$1T10:00Z" git commit -q --author="$2" -m "$3"; }
printf 'hostname edge-01\ninterface ge-0/0/0\n' > router.cfg
git add router.cfg && commit 2026-03-02 "Sam Rivera <sam@example.com>" "Add edge-01 base config"
printf 'routers:\n - edge-01\n' > inventory.yml
git add inventory.yml && commit 2026-03-03 "Amara Okafor <amara@example.com>" "Add inventory"
printf 'ntp server 192.0.2.10\n' >> router.cfg
git add router.cfg && commit 2026-03-04 "Sam Rivera <sam@example.com>" "Add NTP server to edge-01"
printf 'logging host 192.0.2.20\n' >> router.cfg
git add router.cfg && commit 2026-03-05 "Amara Okafor <amara@example.com>" "Send edge-01 logs to syslog server"
printf ' - edge-02\n' >> inventory.yml
git add inventory.yml && commit 2026-03-06 "Sam Rivera <sam@example.com>" "Add edge-02 to inventory"
echo "== by Amara"
git log --oneline --author=Amara
echo "== since 5 March"
git log --oneline --since="2026-03-05 00:00"
echo "== touching inventory.yml"
git log --oneline -- inventory.yml
echo "== adding or removing 192.0.2.20"
git log --oneline -S "192.0.2.20"
== by Amara ef785b7 Send edge-01 logs to syslog server 68d5221 Add inventory == since 5 March 570c3d3 Add edge-02 to inventory ef785b7 Send edge-01 logs to syslog server == touching inventory.yml 570c3d3 Add edge-02 to inventory 68d5221 Add inventory == adding or removing 192.0.2.20 ef785b7 Send edge-01 logs to syslog server
| Option | Keeps commits… |
|---|---|
--author=Amara |
whose author name or email matches the pattern (a regular expression, so part of the name is enough) |
--since="2026-03-05 00:00",
--until=…
|
made after or before a date; relative dates such as
--since="2 weeks ago" work too. These filter on
the committer date. Give a time as well: a date on its own
(--since=2026-03-05) means that day at the
current time of day, so the result changes depending on when
you run the command.
|
-- inventory.yml |
that changed this file or folder. The --
separates paths from branch names and options.
|
-S "192.0.2.20" |
that changed how many times the text occurs: commits that added or removed it |
-G "regex" |
whose diff has an added or removed line matching the regular expression |
--grep="syslog" |
whose commit message matches the pattern |
-S is nicknamed the pickaxe, because
it digs up the commit where a piece of text appeared or disappeared.
It is the fastest answer to "when did this IP address enter (or
leave) our configs?" — even if the line was later moved or the file
deleted. Filters combine: several conditions must all be true, so
git log --author=Amara --since=2026-03-04 -- router.cfg
shows only Amara's recent changes to router.cfg.
Relative references
Copying hashes is tedious. A
relative commit reference
names a commit by its position relative to another one, such as a
branch name or HEAD:
-
HEAD~1(orHEAD~) is the parent ofHEAD;HEAD~2its grandparent;main~3three commits back from the tip ofmain. The tilde always follows the first parent. -
HEAD^(orHEAD^1) is also the first parent. The caret only differs on a merge commit, which has two parents:HEAD^2is the second parent — the tip of the branch that was merged in.
In this example a branch acl-update is merged into
main, and git rev-parse --short translates
each reference into the commit it points to:
git init -q netops && cd netops
printf 'hostname edge-01\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
printf 'ntp server 192.0.2.10\n' >> router.cfg
git commit -q -am "Add NTP server"
git switch -q -c acl-update
printf 'ip access-list MGMT\n' >> router.cfg
git commit -q -am "Add management ACL"
printf ' permit 198.51.100.0/24\n' >> router.cfg
git commit -q -am "Allow NOC subnet in ACL"
git switch -q main
printf 'logging host 192.0.2.20\n' > logging.cfg
git add logging.cfg && git commit -q -m "Add syslog server"
git merge -q --no-edit acl-update
git log --oneline --graph
for rev in HEAD HEAD~1 HEAD^ HEAD^2 HEAD~2 main~3; do
echo "$rev = $(git rev-parse --short $rev)"
done
* f7a9c98 Merge branch 'acl-update' |\ | * e8cd318 Allow NOC subnet in ACL | * 7664007 Add management ACL * | dfa436e Add syslog server |/ * 5a75fd4 Add NTP server * aedb4dc Add edge-01 base config HEAD = f7a9c98 HEAD~1 = dfa436e HEAD^ = dfa436e HEAD^2 = e8cd318 HEAD~2 = 5a75fd4 main~3 = aedb4dc
Read the graph from the top: the merge commit
f7a9c98 has two parents. Its first parent,
dfa436e, is the commit main was on when
the merge happened; its second parent, e8cd318, is the
tip of acl-update. HEAD~2 follows first
parents twice and lands on 5a75fd4, skipping the branch
entirely. References combine too: HEAD^2~1 is
7664007.
Revision ranges
A revision range selects a set of commits by reachability: which commits can be reached from one reference but not from another. Two notations cover most needs:
-
main..acl-update(two dots): commits reachable fromacl-updatebut not frommain. In plain words: "what does the branch have that main does not?" — exactly what a merge would bring in. -
main...acl-update(three dots): commits reachable from either side but not from both — the symmetric difference. With--left-right, Git marks each commit with<(left side) or>(right side).
The same history as before, just before the merge:
git init -q netops && cd netops
printf 'hostname edge-01\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
printf 'ntp server 192.0.2.10\n' >> router.cfg
git commit -q -am "Add NTP server"
git switch -q -c acl-update
printf 'ip access-list MGMT\n' >> router.cfg
git commit -q -am "Add management ACL"
printf ' permit 198.51.100.0/24\n' >> router.cfg
git commit -q -am "Allow NOC subnet in ACL"
git switch -q main
printf 'logging host 192.0.2.20\n' > logging.cfg
git add logging.cfg && git commit -q -m "Add syslog server"
git log --oneline main..acl-update
git log --oneline acl-update..main
git log --oneline --left-right main...acl-update
e8cd318 Allow NOC subnet in ACL 7664007 Add management ACL dfa436e Add syslog server < dfa436e Add syslog server > e8cd318 Allow NOC subnet in ACL > 7664007 Add management ACL
The range is about reachability, not about dates or the order of
commits. Leaving out one side means HEAD:
git log origin/main.. shows your local commits that are
not on the server yet, and git log ..origin/main shows
what others pushed that you have not merged. (Remote-tracking
branches such as origin/main are explained on
Remotes and Collaboration.)
Show and diff between commits
git show displays one object. For a commit, that is its
log entry plus the patch it introduced (compared with its parent;
for a merge commit Git shows a condensed combined diff, often empty
— use git show --first-parent or -m to see
more). With rev:path it prints a file exactly as it was
in that commit, without changing your working tree.
git diff with two commits compares their snapshots,
however many commits lie between them:
git init -q netops && cd netops
# commit DATE AUTHOR MESSAGE: commit everything staged, with a given date and author
commit() { GIT_AUTHOR_DATE="$1T10:00Z" GIT_COMMITTER_DATE="$1T10:00Z" git commit -q --author="$2" -m "$3"; }
printf 'hostname edge-01\ninterface ge-0/0/0\n' > router.cfg
git add router.cfg && commit 2026-03-02 "Sam Rivera <sam@example.com>" "Add edge-01 base config"
printf 'routers:\n - edge-01\n' > inventory.yml
git add inventory.yml && commit 2026-03-03 "Amara Okafor <amara@example.com>" "Add inventory"
printf 'ntp server 192.0.2.10\n' >> router.cfg
git add router.cfg && commit 2026-03-04 "Sam Rivera <sam@example.com>" "Add NTP server to edge-01"
printf 'logging host 192.0.2.20\n' >> router.cfg
git add router.cfg && commit 2026-03-05 "Amara Okafor <amara@example.com>" "Send edge-01 logs to syslog server"
printf ' - edge-02\n' >> inventory.yml
git add inventory.yml && commit 2026-03-06 "Sam Rivera <sam@example.com>" "Add edge-02 to inventory"
git show HEAD~2
git show HEAD~3:router.cfg
git diff HEAD~4 HEAD -- router.cfg
git diff --stat HEAD~4 HEAD
commit b8fdd26006468d0b1a1a34fe0ef3a9a8a4808df2
Author: Sam Rivera <sam@example.com>
Date: Wed Mar 4 10:00:00 2026 +0000
Add NTP server to edge-01
diff --git a/router.cfg b/router.cfg
index 8f20631..90a65f0 100644
--- a/router.cfg
+++ b/router.cfg
@@ -1,2 +1,3 @@
hostname edge-01
interface ge-0/0/0
+ntp server 192.0.2.10
hostname edge-01
interface ge-0/0/0
diff --git a/router.cfg b/router.cfg
index 8f20631..83d087e 100644
--- a/router.cfg
+++ b/router.cfg
@@ -1,2 +1,4 @@
hostname edge-01
interface ge-0/0/0
+ntp server 192.0.2.10
+logging host 192.0.2.20
inventory.yml | 3 +++
router.cfg | 2 ++
2 files changed, 5 insertions(+)
The output contains four results: the commit
HEAD~2 with its patch; router.cfg as it
was three commits ago (two lines); the total change to
router.cfg over four commits as one diff; and a
--stat summary of everything that changed in that span.
Useful variations:
-
git show --stat HEAD: only which files the last commit touched. -
git show HEAD~3:router.cfg > router-old.cfg: save an old version next to the current one to compare or copy from. -
git diff main acl-update -- router.cfg: how one file differs between two branches. -
git diff --name-only HEAD~10 HEAD: only the file names, handy in scripts and pipelines.
Git blame: who changed this line?
Git blame (git blame)
annotates every line of a file with the commit, author and date of
the last change to that line. In the example below Sam creates the
config, then Amara configures her own identity with
git config and changes the syslog server:
git init -q netops && cd netops
printf 'hostname edge-01\nntp server 192.0.2.10\nlogging host 192.0.2.20\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
# Amara takes over: from now on her name goes into new commits
git config user.name "Amara Okafor"
git config user.email amara@example.com
sed -i 's/192.0.2.20/192.0.2.25/' router.cfg
GIT_AUTHOR_DATE=2026-03-04T14:30Z GIT_COMMITTER_DATE=2026-03-04T14:30Z git commit -q -am "Move syslog to new collector"
git blame router.cfg
git blame -L 3,3 --show-email router.cfg
^451c1e5 (Sam Rivera 2026-03-02 09:00:00 +0000 1) hostname edge-01 ^451c1e5 (Sam Rivera 2026-03-02 09:00:00 +0000 2) ntp server 192.0.2.10 20197247 (Amara Okafor 2026-03-04 14:30:00 +0000 3) logging host 192.0.2.25 20197247 (<amara@example.com> 2026-03-04 14:30:00 +0000 3) logging host 192.0.2.25
Each line starts with the abbreviated hash of the commit that last
changed it. The caret in ^451c1e5 marks a boundary
commit — here the very first commit, which has no parent. Then come
author, date, line number and the line itself.
-L 3,3 limits the output to a line range, and
--show-email
shows emails instead of names. The natural next step is
git show 20197247, to read the message and the full
change.
Despite the name, blame is not about guilt: its purpose is to find
the reason for a line through its commit message. That only
works when commit messages explain why. Two options help when the
last change was trivial: -w ignores whitespace-only
changes, and --ignore-rev <hash> skips a commit
such as a mass reformatting. GitHub shows the same information in
the file view under Blame.
Git bisect: which commit broke it?
Something worked last month and is broken now, and two hundred
commits lie in between.
Git bisect (git bisect) finds the first bad commit by binary search: you name one bad
commit and one good commit, Git checks out the commit halfway
between them, you report whether it is good or bad, and the search
space halves each round. Two hundred commits take about eight steps
(28 = 256).
You can answer by hand with git bisect good and
git bisect bad, but if a script can tell good from bad,
git bisect run does the whole search on its own. The
script must exit with status 0 for good, 1 to 127 (except 125) for
bad, and 125 for "cannot test this commit, skip it". In this example
the edge router's MTU (maximum transmission unit) must stay at 1500,
and somewhere in eight commits it was changed:
git init -q netops && cd netops
printf 'hostname edge-01\nmtu 1500\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
# seven more commits; the fourth one quietly changes the MTU as well
for vlan in 10 20 30 40 50 60 70; do
echo "vlan $vlan" >> router.cfg
if [ $vlan = 40 ]; then sed -i 's/^mtu 1500$/mtu 9000/' router.cfg; fi
git commit -q -am "Add VLAN $vlan"
done
git log --oneline
# the test: exit status 0 means good, 1 means bad
cat > ../check-mtu.sh <<'TEST'
#!/bin/sh
grep -q '^mtu 1500$' router.cfg
TEST
chmod +x ../check-mtu.sh
git bisect start HEAD HEAD~7 # bad commit first, then a known good one
git bisect run ../check-mtu.sh
fc53569 Add VLAN 70
0162864 Add VLAN 60
142d2c0 Add VLAN 50
e915737 Add VLAN 40
56cfdd5 Add VLAN 30
a0d2a3d Add VLAN 20
faab346 Add VLAN 10
8ec6448 Add edge-01 base config
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[56cfdd5f5ca5362c09bcb29d2993604d932657c6] Add VLAN 30
running '../check-mtu.sh'
Bisecting: 1 revision left to test after this (roughly 1 step)
[142d2c0098246134f372364c56d892c951c882c3] Add VLAN 50
running '../check-mtu.sh'
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[e915737ac3ba234c80c106b9acd7982a25c70bc9] Add VLAN 40
running '../check-mtu.sh'
e915737ac3ba234c80c106b9acd7982a25c70bc9 is the first bad commit
commit e915737ac3ba234c80c106b9acd7982a25c70bc9
Author: Sam Rivera <sam@example.com>
Date: Mon Mar 2 09:00:00 2026 +0000
Add VLAN 40
router.cfg | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
bisect found first bad commit
Git needed three test runs for seven candidate commits, and named
e915737: the commit called "Add VLAN 40", which also
changed the MTU — the kind of change a commit message hides and
bisect finds. The --stat line confirms it: one line
added and one changed. The test script lives outside the repository
(../check-mtu.sh), so it stays the same while bisect
checks out older commits.
During a bisect you are on a detached HEAD (see
Branches). When you are done, always end
the session; it returns you to the branch you started on:
git bisect reset # end the session, go back to the original branch
git bisect log # (during a session) the good/bad answers so far
git bisect skip # (manual mode) this commit cannot be tested
Tips for finding things
| Question | Command |
|---|---|
| What happened recently? | git log --oneline --graph --all -20 |
| When did this text appear or disappear? |
git log -S "text" --oneline, then
git show <hash>
|
| Which commits mention a ticket number? | git log --oneline --grep="CHG-1042" |
| What did this file look like last week? |
git log --oneline --until="1 week ago" -1 -- file, then git show <hash>:file
|
| Who last changed this line, and why? |
git blame -L 10,20 file, then
git show <hash>
|
| What is on my branch that main does not have? | git log --oneline main.. |
| Where is the word in the current files? |
git grep -n "192.0.2.20" (searches tracked
files only)
|
| Which commit broke it? |
git bisect start <bad> <good>, then
git bisect run ./test.sh
|
Good history makes all of this easier: clear commit messages that say why, ticket or change numbers in the message, and small commits that each do one thing. The search tools are only as useful as what was recorded.