Automation & Tools

Git for Network Engineers, Part 3: Reading the Crime Scene

Tony Mattke · 2026.07.28 · 17 min read

Somebody asks in standup: “why did we change the ACL on edge-fw-02 last quarter? Who did it, and what else did they touch?” You know the answer lives in the repo. Every change is in there with an author, a timestamp, and the company it kept. What you don’t have yet is a fast way to pull it back out.

This post closes that gap. Part 1 made git your save button and Part 2 made it your undo button. This one turns it into a database you can interrogate. Answer the ACL question in seconds, trace any line of config to the commit that explains it, make git binary-search its own history for whatever broke the lint, and flatten a pile of “wip” commits into something you’d let a coworker review. We’re still in the runbooks-demo repo, running commands against history you built in the first two parts.

Interrogating history with git log

The default git log dumps every commit, full message and author block, in reverse-chronological order. Fine for a five-commit repo. Useless for anything serious… scrolling through it unfiltered is combing the desert with a giant novelty comb, and Spaceballs already filed that report: “We ain’t found shit!”

You met --oneline back in Part 1. It’s back for Part 3, supercharged with flags that filter and shape the view… cutting history down until the answer you’re after is the only thing left on screen. (And yes, that’s one-line. My brain has read it as “online” every day for years and shows no sign of stopping.)

the compact baseline
$ git log --oneline -10
bdce227 Add CODEOWNERS
e8d6453 Wire up pre-commit (yamllint, gitleaks, large-file guard)
3706744 ci: install required ansible collections
0e98918 ci: lint YAML and ansible playbooks on push and PR
00d97bb Revert "Add interface-description playbook"
a6862cc Add interface-description playbook
b20ef7c Add TODO comment for timestamped backup filenames
f4f2949 Add edge firewall baseline ACL playbook (#1)
e07f73e Add .gitignore
1ccd1f1 Expand README with layout notes
visualize branches and merges
$ git log --oneline --graph --all --decorate -10
* 686b872 (add-vlan-playbook) Add VLAN cutover playbook
* 51be4ae (main) Add Makefile for local lint and syntax checks
| * beb4ddc (HEAD -> improve-inventory) Fix indentation on backup-configs.yml
| * 9e4dbd8 Add backup_format var for future use
| * ea0de30 Add second edge firewall to inventory
| * 230bdf6 Document Makefile in README
| * dd7a372 Tweak backup playbook task formatting
| * c1ed8a4 Rename router service account to automation
| * ae97a6f Add firewalls group to inventory
| * 876dbe1 Add Makefile for local lint and syntax checks
|/

The | columns are the parallel branches, and --decorate hangs the names on them: (main) and (add-vlan-playbook) label the left column, (HEAD -> improve-inventory) marks the branch I’m standing on, and the |/ at the bottom is where they forked. --all means “show every branch, not just the one I’m on.” (Git decorates terminal output on its own these days, but I spell the flag out so the labels survive piping and scripts.) This is the view I reach for when I’m trying to figure out what’s happening in a repo I haven’t touched in a while.

filter by commit-message text
$ git log --oneline --grep="ci"
3706744 ci: install required ansible collections
0e98918 ci: lint YAML and ansible playbooks on push and PR

--grep searches the message. Handy when you remember roughly what a commit was about but not when it landed. The pattern is a basic regex by default, and -i makes it case-insensitive.

filter by file path
$ git log --oneline -- playbooks/firewall-policy.yml
f4f2949 Add edge firewall baseline ACL playbook (#1)

The -- separator says “the rest of this command line is path arguments, not branch names.” Without it, git resolves each bare argument as a revision first and a path second. When a name matches both (a file and a branch named release, say), git refuses outright: fatal: ambiguous argument 'release': both revision and filename. The sneakier case is a file that’s been deleted: the path check fails, so if any ref happens to match, git silently logs that instead, and you get a correct-looking answer to a question you didn’t ask. The -- ends the guessing, and it’s the only way to log a deleted file at all.

filter by author or date
git log --author="aaron"          # Only commits by someone whose name/email matches
git log --since="2 weeks ago"     # Within the last two weeks
git log --since="2026-05-01" --until="2026-05-15"   # Date range

-p shows the full diff for every matching commit. Combine with the path filter and you can see exactly how a file evolved.

show patches inline
$ git log -p -- playbooks/firewall-policy.yml
commit f4f29491e757e53bd951beac91e9d566eabaa40a
Author: tonhe <[email protected]>
Date:   Thu May 21 19:40:23 2026 -0700

    Add edge firewall baseline ACL playbook (#1)

diff --git a/playbooks/firewall-policy.yml b/playbooks/firewall-policy.yml
new file mode 100644
index 0000000..1d66fdb
--- /dev/null
+++ b/playbooks/firewall-policy.yml
@@ -0,0 +1,28 @@
+---
+- name: Push baseline ACL policy to edge firewalls
+  hosts: firewalls
+  gather_facts: false
+  connection: network_cli
...

Build the muscle memory for these. Once you know them, you stop scrolling through the GitHub web UI to find things.

Zoom in with git show

git log is for browsing. git show <sha> is for zooming in.

$ git show e8d6453 --stat
commit e8d6453276fe41ae052574084c8155c41545d959
Author: tonhe <[email protected]>
Date:   Fri May 22 03:04:37 2026 +0000

    Wire up pre-commit (yamllint, gitleaks, large-file guard)

 .pre-commit-config.yaml | 20 ++++++++++++++++++++
 1 file changed, 20 insertions(+)

--stat gives you the file-change summary. Drop --stat and you get the full diff of the commit. git show <sha>:path/to/file shows the contents of that file as it was at that commit. Useful when you need to look at an old config without checking out the whole repo.

Line level archaeology with git blame

You want to know who wrote this line and why. git blame is blood spatter analysis for configs… every line annotated with the commit that left it there, so you can reconstruct the scene. And like Dexter running the analysis on his own crime scene, in my repos the culprit is usually me.

$ git blame inventory.ini
215a67be (tonhe 2026-05-22 02:38:32 +0000  1) [routers]
215a67be (tonhe 2026-05-22 02:38:32 +0000  2) core-rtr-01 ansible_host=10.10.0.1
215a67be (tonhe 2026-05-22 02:38:32 +0000  3) core-rtr-02 ansible_host=10.10.0.2
215a67be (tonhe 2026-05-22 02:38:32 +0000  4) 
ae97a6f3 (tonhe 2026-05-22 14:42:19 +0000  5) [firewalls]
ae97a6f3 (tonhe 2026-05-22 14:42:19 +0000  6) edge-fw-01 ansible_host=10.10.5.1
ea0de30e (tonhe 2026-05-22 14:43:17 +0000  7) edge-fw-02 ansible_host=10.10.5.2
ae97a6f3 (tonhe 2026-05-22 14:42:19 +0000  8) 
215a67be (tonhe 2026-05-22 02:38:32 +0000  9) [routers:vars]
215a67be (tonhe 2026-05-22 02:38:32 +0000 10) ansible_network_os=cisco.ios.ios
c1ed8a44 (tonhe 2026-05-22 14:42:31 +0000 11) ansible_user=automation
215a67be (tonhe 2026-05-22 02:38:32 +0000 12) ansible_connection=network_cli
ae97a6f3 (tonhe 2026-05-22 14:42:19 +0000 13) 
ae97a6f3 (tonhe 2026-05-22 14:42:19 +0000 14) [firewalls:vars]
ae97a6f3 (tonhe 2026-05-22 14:42:19 +0000 15) ansible_network_os=cisco.asa.asa
ae97a6f3 (tonhe 2026-05-22 14:42:19 +0000 16) ansible_user=netops
ae97a6f3 (tonhe 2026-05-22 14:42:19 +0000 17) ansible_connection=network_cli

Each line is annotated with the commit SHA that introduced it, the author, the timestamp, and the line number. Look at line 11: the automation username was introduced in commit c1ed8a4. You can pull up that commit with git show c1ed8a4 and find out why the rename happened (the commit message, the PR, the related changes).

This is the “why does our config look like this?” workflow, formalized. On a real team, the authors on those lines are different people. The same investigation works.

git blame -L 11,12 inventory.ini restricts blame to specific lines, which keeps the output skim-friendly when you only care about one block.

GitHub’s web UI also has a “Blame” button on every file. Same data, prettier interface. Use whichever feels faster.

Moving a single commit with git cherry-pick

You have a useful commit on branch A, and you want that one commit on branch B without dragging along the rest of A’s history. Cherry-pick does exactly this.

I had a commit on a feature branch that adds a Makefile (lint and syntax shortcuts). The branch needs more work before it merges, but the Makefile alone is useful right now. Get the SHA from git log on the source branch, then:

$ git switch main

$ git cherry-pick 876dbe1
[main 51be4ae] Add Makefile for local lint and syntax checks
 Date: Fri May 22 14:42:09 2026 +0000
 1 file changed, 11 insertions(+)
 create mode 100644 Makefile

$ git log --oneline -3
51be4ae Add Makefile for local lint and syntax checks
bdce227 Add CODEOWNERS
e8d6453 Wire up pre-commit (yamllint, gitleaks, large-file guard)

Note the SHA changed (876dbe1 on the source branch became 51be4ae on main). Same content, new commit on the new branch.

(And yes, that’s me committing straight to main, two parts after telling you never to. Solo demo repo, so the only person I can hurt is me. On a team repo, the pick lands on a branch and rides a PR like everything else.)

If the cherry-pick conflicts with what’s on the target branch (because main has diverged), git stops mid-pick and asks you to resolve, the same way a merge conflict works. Resolve, git add, git cherry-pick --continue. Or git cherry-pick --abort to bail.

Real-world use for network ops: backporting a fix to a release branch. You’ve got release/2026-q2 running in production and a fix sitting on main… and nobody sane merges all of main into a release branch to get one fix. git cherry-pick <fix-sha> onto the release branch and you’re done.

Finding the bad commit with git bisect

Bisect might be my favorite tool in all of git, and it’s the one the fewest engineers seem to know exists.

Scenario: you’ve been heads-down on a branch all afternoon, small commits on a fresh clone. make lint passed when you started. Six commits later you run it again before pushing, and it fails. Before you object that the pre-commit hooks in this repo’s history should have caught this: hooks don’t survive a clone. They aren’t there until somebody runs pre-commit install on the new machine, and CI never judges commits that haven’t been pushed (wiring all of that up properly is Part 4’s whole agenda). So the break sat unnoticed, somewhere in six commits of YAML edits. Manually checking each one would take a while.

We haven’t actually run git clone in this series (you built your repo from scratch with git init back in Part 1). Clone is the other way repos begin: git clone <url> copies an existing remote repo onto your machine, full history and all branches included. What it does not copy is anything outside the tracked files, and installed hooks live outside the tracked files.

git bisect does a binary search across the commit range. You mark a known-good and a known-bad commit, then git checks out the midpoint for you to test. You tell it “good” or “bad” and it narrows the range. Log₂(n) steps to find the culprit. In real life, your known-good is usually the last commit you pushed or wherever main sits: anywhere you’re sure the test passed.

start the bisect, mark the endpoints
$ git bisect start
status: waiting for both good and bad commits

$ git bisect bad HEAD
status: waiting for good commit(s), bad commit known

$ git bisect good 876dbe1
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[dd7a37243d2d000ae0116f8296fbdc420ac9b93c] Tweak backup playbook task formatting

Git checked out dd7a372 for me to test. Worth knowing what just happened there: bisect parks you on that commit directly, not on a branch, and your working files now are that older snapshot. Your newer commits aren’t gone, they’re just not checked out at the moment. Look around all you want, run whatever tests you need, just don’t commit anything while a bisect is running. When it’s over, git bisect reset puts you and your files right back where you started.

round one
$ make lint
yamllint .
./playbooks/backup-configs.yml
  14:10     error    syntax error: expected <block end>, but found '<block mapping start>' (syntax)

make: *** [Makefile:4: lint] Error 1

Fails. Tell git:

mark it bad, git narrows the range
$ git bisect bad
Bisecting: 0 revisions left to test after this (roughly 1 step)
[c1ed8a44e7bbe3ce432d78ef4096de684f763db2] Rename router service account to automation

Git checked out an earlier commit.

round two
$ make lint
yamllint .

Passes (no errors). Tell git:

mark it good... and there's your culprit
$ git bisect good
dd7a37243d2d000ae0116f8296fbdc420ac9b93c is the first bad commit
commit dd7a37243d2d000ae0116f8296fbdc420ac9b93c
Author: tonhe <[email protected]>
Date:   Fri May 22 14:42:47 2026 +0000

    Tweak backup playbook task formatting

 playbooks/backup-configs.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

That’s the bad commit. Two iterations to find it across a range of six. git show dd7a372 tells you exactly what changed (a sneaky indentation slip), which then tells you how to fix it.

One warning: mark a single step wrong (typing good on a commit that was actually broken, or testing the wrong thing) and git narrows in on the wrong range, so the final answer is garbage. The fix is git bisect reset and start over. It goes fast the second time.

done, back to your branch
$ git bisect reset

The automated variant: if you have a script that exits zero on good and non-zero on bad, git bisect run <script> does the whole thing without you tapping good/bad between each step:

the hands-free version
git bisect start HEAD 876dbe1    # endpoints up front: bad first, then good
git bisect run make lint

That walks the entire range, runs make lint at each step, and reports the first bad commit. Great when the test takes longer than a second or two and you have anything else to do.

Cleaning up before the PR with git rebase -i

You’ve been on a feature branch for two hours. You have three commits. The messages read:

$ git log --oneline -3
7ed447f fixup the loop
67c6cde more wip
af500f5 Start VLAN cutover playbook

Nobody wants to review that. Interactive rebase lets you squash, reorder, drop, or rewrite commits before they ever leave your laptop.

bash
git rebase -i HEAD~3

Git opens an editor showing the last three commits as a plan:

pick af500f5 Start VLAN cutover playbook
pick 67c6cde more wip
pick 7ed447f fixup the loop

# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# d, drop <commit> = remove commit
# (truncated; the editor shows the full list)
Two things this screen doesn’t tell you. First, the list is upside down compared to git log: oldest commit on top, newest on the bottom, because it’s a script git replays from top to bottom. Second, the editor that just opened is whatever core.editor (or $EDITOR) points at, and if you’ve never set one, that’s probably vim: :wq saves the plan, :q! bails out with nothing changed. If you’d rather not learn vim mid-rebase, run git config --global core.editor nano before you start. Nano keeps its commands printed at the bottom of the screen. My own bias runs toward vim, but nano is great if you’re not looking to go hardcore, and it happens to be written by an old friend from my talker days, Chris “astyanax” Allegretta.

Edit the plan. To collapse all three into one clean commit, change two of the picks to squash. The first line stays pick because squash folds a commit into the line above it… the oldest commit is the landing pad, and git refuses a plan that opens with squash:

pick af500f5 Start VLAN cutover playbook
squash 67c6cde more wip
squash 7ed447f fixup the loop

Save and close. Git applies the plan, then opens another editor for the squashed commit message. Replace the concatenated mess with something sensible like:

Add VLAN cutover playbook

Save and close. The result:

$ git log --oneline -3
686b872 Add VLAN cutover playbook
51be4ae Add Makefile for local lint and syntax checks
bdce227 Add CODEOWNERS

Three commits became one. Notice af500f5 didn’t survive either: pick doesn’t mean untouched. Once the other two folded in, the result had new content and a new message, and in git that always means a new commit with a new SHA, the same rule you saw with cherry-pick. The original three aren’t destroyed, they’re just unreferenced, and the reflog from Part 2 can still reach them. The branch is now PR-ready. Push and open it. (Part 2’s force-push warnings apply if you’ve already pushed the original three. For unpushed local commits, no --force needed.)

Squashing your own fresh work usually applies cleanly, but a rebase can hit conflicts the same way a cherry-pick can. Same drill: fix the file, git add, git rebase --continue. And memorize the eject handle before you ever need it: git rebase --abort stops the whole operation and puts the branch back exactly as it was before you typed rebase. Nothing lost, nothing to clean up.

Other useful interactive-rebase moves:

  • reword to rewrite a commit message without changing its contents
  • edit to stop at that commit so you can git commit --amend to fix something inside it
  • drop to remove a commit entirely
  • Reordering the lines reorders the commits

You’ll mostly use squash and reword. The others are there when you need them.

Stop typing the same thing 200 times a day

If you type a command more than three times an hour, alias it. I’ve been preaching this since the IOS days, and the sermon ports to git unchanged.

bash
git config --global alias.st status
git config --global alias.sw switch
git config --global alias.br branch
git config --global alias.lg "log --oneline --graph --all -20"
git config --global alias.last "log -1 HEAD --stat"

Verify:

$ git config --global --get-regexp "^alias\."
alias.st status
alias.sw switch
alias.br branch
alias.lg log --oneline --graph --all -20
alias.last log -1 HEAD --stat

git st now runs git status, git sw main hops branches, git lg runs my favorite log view, and git last shows the most recent commit with file stats.

Aliases can take arguments too, and they can shell out:

bash
# Open the GitHub repo in a browser (anywhere gh is installed)
git config --global alias.web '!gh repo view --web'

The ! at the start of the value makes it a shell command. Useful for stitching together combos that aren’t pure git subcommands.

A handful of well-chosen aliases saves you a few minutes every day, and a few minutes a day compounds into real hours by the end of a quarter.

.gitattributes and the line-endings tax

This one is invisible until you work cross-platform, and then it’s a nightmare.

Background: Windows uses CRLF (\r\n) line endings. Linux and macOS use LF (\n). Git can convert between them on checkout and commit, controlled by the core.autocrlf setting. Git itself defaults that setting to false on every platform, but the Git for Windows installer commonly turns it on during setup. The result is that the same file checked out on two machines can have different bytes, and git diff will scream that every line changed even when none did.

The fix is a .gitattributes file at the repo root. It tells git, per-file-pattern, how to handle line endings (and a few other things):

gitattributes
# Normalize all text files to LF on commit, regardless of platform
* text=auto eol=lf

# Always treat these as text and convert line endings
*.yml    text eol=lf
*.yaml   text eol=lf
*.py     text eol=lf
*.sh     text eol=lf
*.md     text eol=lf

# Always binary (never touch line endings or attempt diffs)
*.png    binary
*.jpg    binary
*.pdf    binary

Commit that file. From that point on, everyone gets LF in their working copy (or whatever the rules say), git stops fighting with cross-platform contributors, and diffs are clean. One catch: existing files keep their old endings until they’re next touched. git add --renormalize . converts everything in one commit if you’d rather rip the band-aid off.

For a network-ops team with a mix of Windows laptops, mac users on the lab side, and Linux servers running the playbooks, this one file prevents a category of bug that’s painful to diagnose. (The symptom is usually a Linux server refusing to parse YAML that works fine on the Windows laptop, and the cause is almost always line endings.)

If you work solo on one OS, you may never need .gitattributes. The day a Windows laptop joins your repo, add it before that first commit lands.

Where to go from here

Next time somebody asks why the ACL changed last quarter, the answer takes three commands and about thirty seconds, and you get to look like a wizard while producing it. Build the reflexes before you need them: pick a file in your own repo and git log -p your way through its life, blame a line you don’t remember writing, and run one bisect on purpose so your first real one isn’t also your first attempt.

Part 4 goes pro: branch protection so nothing lands in main unchecked, automated linting on every push, and the gh CLI for driving all of it from the terminal you already live in.

More in Automation & Tools

Related Posts

Automation & Tools · 24 min read

Git for Network Engineers, Part 2: The Oh-Shit Toolkit

2026.06.30

You’ve had git in your daily workflow for a few weeks. You’ve got the basics down, until you do one of these… You committed vault_password.txt and pushed it before your second coffee.