Fundamentals

Git for Network Engineers, Part 1: From Zero to Your First Pull Request

Tony Mattke · 2026.05.28 · 17 min read

Last Tuesday you made changes to a playbook because the on-call engineer needed an ACL pushed in a hurry. This morning the same playbook is failing, and the version that worked last week is on a random jumphost reanmed to backup-configs.yml.bak, or backup-configs_FINAL_v2_tony.yml in a shared drive. Or it’s just gone… Every network engineer eventually has the moment where the “I’ll back this up later” workflow finally costs them.

If you’re reading this because Claude has been writing code for you and now you need somewhere to actually keep that code, welcome. You picked a good time to learn this. The version-control tooling has never been better, and the surrounding ecosystem is the closest thing our industry has to a universal exchange.

This post is part one of a multi-part series on git. By the time we’re done you will have a working understanding of the toolkit that almost every software project on the planet runs on. We’re starting from zero, so I’m going to spend a little time on the why before we get to the basics.

Why git matters

Git is a version control system. That phrase doesn’t mean much until you’ve been bitten, so here’s the version-control pitch translated for network ops

  • History. Every state of every file, going back to the day you started. You don’t have to remember what you changed three weeks ago, git remembers. With a real commit message, keeping the why you changed along side.
  • Undo. Not just Ctrl-Z undo. “Roll the playbook back to the way it looked before the change window from hell” undo. Cleanly, in seconds.
  • Blame, but the useful kind. Pick any line in any file, ask git who wrote it and when. Configuration drift becomes traceable instead of mysterious.
  • Sharing without playbook_FINAL_v2_tony.yml. Multiple people work on the same files without stomping on each other. There is exactly one source of truth, and it lives in the repository.
  • Audit trail. Every change is signed (by whoever’s name is on the commit), timestamped, and immutable. When your auditor asks “who changed the firewall ACL on 4/15,” you have an answer.

GitHub is the social layer on top of git: a place to host the repo, a way to invite collaborators, a code-review workflow, and (in Part 3) a place to run automated linting and tests against your changes. The two are different things. Git is the tool that runs on your laptop. GitHub is one of several services where you can publish what git produces.

The mental model

Most git confusion is people not understanding the three places a file can live. Get this in your head and the rest is mechanical.

  1. Working directory. The files on disk, the way you see them right now in your editor. This is where you make changes.
  2. Staging area (also called the index). A holding pen for changes you’ve decided you want to commit. You explicitly move things from working directory into staging with git add.
  3. Commit. A snapshot saved to the repository’s history. Once you commit, the staged changes become a permanent point in time that you can come back to forever.

Once you’ve committed locally, the snapshot exists on your laptop. To get it onto GitHub, you push. Going the other way, when somebody else’s changes are on GitHub and you want them locally, you pull.

That’s the whole thing. Working directory → staging → commit → remote. Everything else in git is built on top of those four ideas.

Setup

Install git if you don’t already have it.

bash
# macOS
brew install git gh

# Ubuntu / Debian
sudo apt update && sudo apt install git gh

# Windows
# Use Git for Windows (https://git-scm.com/download/win) or run inside WSL.
# `gh` from https://github.com/cli/cli/releases works on Windows too.

I’m installing gh (the GitHub CLI) alongside git because the rest of this post uses both. You’ll see why later.

Now tell git who you are. These commands set a global identity that gets attached to every commit you make from this machine. Use your real name and an email tied to your GitHub account.

bash
git config --global user.name "Your Name"
git config --global user.email "[email protected]"
git config --global init.defaultBranch main

The third one is small but worth doing on day one. The historical default for the first branch in a new git repo was master. The industry has been moving to main for years now, GitHub defaults to main on the server side, and you’ll save yourself a constant warning message if you set the local default to match.

Verify it stuck:

$ git config --global --list
core.editor=vim
[email protected]
user.name=Tony Mattke
init.defaultbranch=main

(core.editor is mine. It’s optional. Whatever your $EDITOR is will work fine.)

Now for gh. The first time you use it you have to log in:

bash
gh auth login

It’ll walk you through a few prompts: which host (GitHub.com vs an Enterprise one), HTTPS or SSH for git operations, and how you want to authenticate. The simplest path is “Login with a web browser.” gh prints a one-time code, you paste it into GitHub in your browser, done. We’ll setup SSH keys in Part 3.

Confirm it worked:

$ gh auth status
github.com
  ✓ Logged in to github.com account tonhe (/home/tonhe/.config/gh/hosts.yml)
  - Active account: true
  - Git operations protocol: https
  - Token: gho_************************************
  - Token scopes: 'gist', 'read:org', 'repo', 'workflow'

You want repo in those scopes. workflow will matter in Part 3. The others are fine to ignore for now.

Your first repo

Make a directory for the project and step into it. I’m calling mine runbooks-demo, you can call yours whatever you like.

bash
mkdir runbooks-demo && cd runbooks-demo

Turn it into a git repository:

$ git init
Initialized empty Git repository in /home/tonhe/_dev/runbooks-demo/.git/

Git just dropped a hidden .git directory into your folder. That directory is the repository: every commit you ever make, the entire history, configuration, everything. If you delete .git, you have an ordinary directory of files again. If you back up .git, you’ve backed up the whole history.

$ ls -la
total 12
drwxrwxr-x  3 tonhe tonhe 4096 May 22 02:37 .
drwxrwxr-x 11 tonhe tonhe 4096 May 22 02:36 ..
drwxrwxr-x  7 tonhe tonhe 4096 May 22 02:37 .git

Nothing else yet. Let’s add something. Create a README.md with a one-liner describing what this repo is:

markdown
# runbooks

Ansible playbooks and runbooks for the home lab and the day job.

Save it, then ask git what it sees:

$ git status
On branch main

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	README.md

nothing added to commit but untracked files present (use "git add" to track)

Two pieces of information there. First, “On branch main” confirms our default-branch setting from earlier worked. Second, README.md is untracked. Git can see it sitting in the working directory, but it isn’t watching it yet. This is the first of those three states: it’s on disk, but not staged and not committed.

Stage it:

bash
git add README.md

git add produces no output when it succeeds. (Get used to that. Git is one of those tools that prints nothing when things go well, on the theory that you shouldn’t have to scroll past success messages to find the errors.) Check status again:

$ git status
On branch main

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
	new file:   README.md

Now README.md is staged, the second state. Commit it:

$ git commit -m "Initial commit: add README"
[main (root-commit) 71f47d1] Initial commit: add README
 1 file changed, 3 insertions(+)
 create mode 100644 README.md

The string in square brackets is the commit hash, abbreviated. 71f47d1 is shorthand for a much longer SHA. Git uses these everywhere to identify specific points in history.

View the log:

$ git log
commit 71f47d1906b7a1861f5ec335a15b26f2f235cb2d
Author: tonhe <[email protected]>
Date:   Fri May 22 02:38:08 2026 +0000

    Initial commit: add README

One commit, with your name on it, a timestamp, and the message you gave it. The hash there is the full version of the 71f47d1 we saw above.

Now let’s add some actual content. Create playbooks/backup-configs.yml:

yaml
---
- name: Back up running-config from network devices
  hosts: routers
  gather_facts: false
  connection: network_cli

  vars:
    backup_dir: "./backups"

  tasks:
    - name: Ensure backup directory exists
      ansible.builtin.file:
        path: "{{ backup_dir }}"
        state: directory
        mode: "0755"
      delegate_to: localhost

    - name: Fetch running-config
      cisco.ios.ios_command:
        commands:
          - show running-config
      register: running_cfg

    - name: Save running-config to local file
      ansible.builtin.copy:
        content: "{{ running_cfg.stdout[0] }}"
        dest: "{{ backup_dir }}/{{ inventory_hostname }}.cfg"
        mode: "0640"
      delegate_to: localhost

And inventory.ini:

ini
[routers]
core-rtr-01 ansible_host=10.10.0.1
core-rtr-02 ansible_host=10.10.0.2

[routers:vars]
ansible_network_os=cisco.ios.ios
ansible_user=netops
ansible_connection=network_cli

Status:

$ git status
On branch main
Untracked files:
  (use "git add <file>..." to include in what will be committed)
	inventory.ini
	playbooks/

nothing added to commit but untracked files present (use "git add" to track)

Note that git shows the directory playbooks/ rather than the file inside it. When an entire directory is untracked, git assumes you want it all and skips listing the contents. (If you want the file-by-file view, git status -u will show them individually.)

Stage everything new and commit:

$ git add .
$ git commit -m "Add config backup playbook and inventory template"
[main 215a67b] Add config backup playbook and inventory template
 2 files changed, 37 insertions(+)
 create mode 100644 inventory.ini
 create mode 100644 playbooks/backup-configs.yml

git add . stages everything in the current directory (and below) that isn’t already tracked or ignored. For a small repo that’s fine. For a bigger repo with mixed changes you’ll often want to stage things selectively, but we’ll get there.

Log now shows two commits:

$ git log --oneline
215a67b Add config backup playbook and inventory template
71f47d1 Initial commit: add README

--oneline gives you the compact view. You’ll use this constantly. The full git log is great when you actually need the messages and authors, but for “where am I in history” the oneline view is faster.

Diffs: seeing what changed before you commit

Edit README.md to add a layout section:

markdown
# runbooks

Ansible playbooks and runbooks for the home lab and the day job.

## Layout

- `playbooks/` task-focused playbooks (config backups, OS upgrades, audits)
- `inventory.ini` host inventory, grouped by role
- `backups/` git-ignored, populated by playbook runs

Before you stage it, ask git what changed:

$ git diff
diff --git a/README.md b/README.md
index e277c6c..d0e0eb1 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,9 @@
 # runbooks
 
 Ansible playbooks and runbooks for the home lab and the day job.
+
+## Layout
+
+- `playbooks/` task-focused playbooks (config backups, OS upgrades, audits)
+- `inventory.ini` host inventory, grouped by role
+- `backups/` git-ignored, populated by playbook runs

The + lines are added, the unprefixed lines are unchanged context. If we’d removed something it would show as - lines. This is the same diff format you’ve seen in code review tools (because they’re rendering this exact thing).

git diff with no arguments shows you what’s changed in the working directory but isn’t yet staged. git diff --staged shows you what’s currently staged but not yet committed.

Commit the README change:

bash
git add README.md
git commit -m "Expand README with layout notes"

.gitignore: what NOT to track

Some files don’t belong in version control… Vault password files, runtime outputs, virtual environments, editor crud. Tell git to ignore them by adding a .gitignore file at the root of the repo

gitignore
# Ansible
*.retry
backups/

# Vault password files (never commit these)
*.vault_pass
vault_password.txt
.vault_pass*

# Python virtualenvs and caches
.venv/
__pycache__/
*.pyc

# Editor and OS noise
*.swp
.DS_Store

The backups/ line is important. Our backup-configs playbook writes per-device running-configs to ./backups/{hostname}.cfg. Running configs are sensitive (and noisy), and you don’t want a thousand of them landing in your commit history every time someone runs the playbook…

Vault password files are the other big one. If you commit a vault password, you’ve leaked the keys to whatever vault-encrypted secrets are in the same repo. – We’ll talk about how to recover from that in a future post.

Add and commit .gitignore:

bash
git add .gitignore
git commit -m "Add .gitignore"

Going to GitHub

You now have four commits sitting on your laptop. Nobody else can see them, and if you spill coffee on the laptop, the work is gone. Time to put this on GitHub.

$ gh repo create runbooks-demo --private --source=. --remote=origin --push
https://github.com/tonhe/runbooks-demo
To https://github.com/tonhe/runbooks-demo.git
 * [new branch]      HEAD -> main
branch 'main' set up to track 'origin/main'.

Three things just happened in one command:

  1. gh created a new repository on GitHub under your account.
  2. It registered the GitHub URL as a remote in your local repo, named origin. (You can have multiple remotes if you want, but origin is the conventional name for “the canonical remote.”)
  3. It ran git push to send your commits up.

Verify the remote:

$ git remote -v
origin	https://github.com/tonhe/runbooks-demo.git (fetch)
origin	https://github.com/tonhe/runbooks-demo.git (push)

If you open the URL gh printed, your README and playbooks are there. Same files, same commits, hosted.

Branches: don’t edit main directly

Now we make our first behavioral upgrade. Up to this point, every commit went straight onto main. That works for solo experimentation. It does not work the moment another human (or the future you, six months from now) needs to review what you changed before it ships.

The fix is branches. A branch is a parallel line of history that diverges from main, lets you commit freely without disturbing main, and can be merged back in (or thrown away) when you’re done.

The convention is: never commit directly to main. Every change goes onto a feature branch, gets reviewed, and merges back via a pull request. In a future article, we’ll make GitHub enforce that rule.

Create a branch:

$ git switch -c add-firewall-playbook
Switched to a new branch 'add-firewall-playbook'

git switch -c <name> creates a new branch off of wherever you currently are (in our case, main) and switches you to it. The -c means “create.” The name add-firewall-playbook describes what you’re going to do on this branch. Naming branches well is a small thing that pays back later.

(You may see older tutorials use git checkout -b instead. While that still works, git switch was added later as a cleaner alternative because checkout did about six different things depending on how you called it. I reccomend using switch going forward.)

Add a new playbook for edge firewall ACL pushes:

yaml
---
- name: Push baseline ACL policy to edge firewalls
  hosts: firewalls
  gather_facts: false
  connection: network_cli

  vars:
    allowed_management_cidrs:
      - "10.10.0.0/24"
      - "10.20.0.0/24"

  tasks:
    - name: Apply management-plane allow list
      cisco.asa.asa_acls:
        config:
          - name: mgmt-in
            aces:
              - sequence: 10
                grant: permit
                source:
                  any: true
                destination:
                  address: "{{ item }}"
                protocol_options:
                  tcp:
                    eq: ssh
        state: replaced
      loop: "{{ allowed_management_cidrs }}"

Save as playbooks/firewall-policy.yml. Status confirms you’re on the branch and there’s a new file:

$ git status
On branch add-firewall-playbook
Untracked files:
  (use "git add <file>..." to include in what will be committed)
	playbooks/firewall-policy.yml

nothing added to commit but untracked files present (use "git add" to track)

Commit it:

$ git add playbooks/firewall-policy.yml
$ git commit -m "Add edge firewall baseline ACL playbook"
[add-firewall-playbook 5333e08] Add edge firewall baseline ACL playbook
 1 file changed, 28 insertions(+)
 create mode 100644 playbooks/firewall-policy.yml

The commit is on your branch, not on main. Push the branch to GitHub:

$ git push -u origin add-firewall-playbook
remote: 
remote: Create a pull request for 'add-firewall-playbook' on GitHub by visiting:        
remote:      https://github.com/tonhe/runbooks-demo/pull/new/add-firewall-playbook        
remote: 
To https://github.com/tonhe/runbooks-demo.git
 * [new branch]      add-firewall-playbook -> add-firewall-playbook
branch 'add-firewall-playbook' set up to track 'origin/add-firewall-playbook'.

The -u origin add-firewall-playbook part says “from now on, this local branch tracks that remote branch.” Once it’s set, you can just say git push and git pull without the rest. The remote: lines are a friendly nudge from GitHub: “hey, looks like you might want to open a PR, here’s the URL.”

You can take that nudge or ignore it. We’re going to take it, but we’re going to do it from the terminal.

Your first pull request

A pull request is a GitHub-specific construct (other places might call it a “merge request”). It says: “I have a branch with some commits that I’d like to merge into main. Here’s the diff, please review.”

You can create one from the web UI by clicking the link GitHub printed above, or from the CLI:

$ gh pr create \
    --title "Add edge firewall baseline ACL playbook" \
    --body "Adds a new playbook under playbooks/firewall-policy.yml that applies our standard management-plane allow list to edge firewalls. CIDRs are parameterized so we can drop them into other sites later."
https://github.com/tonhe/runbooks-demo/pull/1

gh prints the PR’s URL. Anyone with access to the repo can now visit that URL to see your branch’s diff, leave comments, request changes, or approve it.

Let’s look at the PR from the terminal

$ gh pr view 1 --json number,title,state,author,baseRefName,headRefName,body \
    -q '. | "PR #\(.number): \(.title)\nState: \(.state)\nAuthor: \(.author.login)\nBase: \(.baseRefName)  Head: \(.headRefName)\n\n\(.body)"'
PR #1: Add edge firewall baseline ACL playbook
State: MERGED
Author: tonhe
Base: main  Head: add-firewall-playbook

Adds a new playbook under playbooks/firewall-policy.yml that applies our standard management-plane allow list to edge firewalls. CIDRs are parameterized so we can drop them into other sites later.

(The --json ... -q ... form is the version-stable way to ask gh for PR details. gh pr view 1 on its own works too and gives a richer terminal-formatted view, but the JSON form is what you’ll reach for when you start scripting things.)

On a solo repo, you can merge your own PR. On a team repo, you’d wait for a teammate to approve it first. We’ll wire up that requirement later on. For now, let’s merge it

$ gh pr merge 1 --squash --delete-branch
✓ Squashed and merged pull request tonhe/runbooks-demo#1 (Add edge firewall baseline ACL playbook)
From https://github.com/tonhe/runbooks-demo
 * branch            main       -> FETCH_HEAD
   e07f73e..3055aea  main       -> origin/main
Updating e07f73e..3055aea
Fast-forward
 playbooks/firewall-policy.yml | 28 ++++++++++++++++++++++++++++
 1 file changed, 28 insertions(+), 0 deletions(-)
 create mode 100644 playbooks/firewall-policy.yml

--squash collapses all the commits on your branch into a single commit on main. For a one-commit branch the result is identical to a regular merge, but the habit pays off the moment you start making branches with five or six work-in-progress commits. --delete-branch cleans up both the local and remote branch after the merge.

gh pr merge is helpful enough to switch you back to main and fast-forward your local copy. Take a look:

$ git branch -a
* main
  remotes/origin/main

* main means you’re on main. The feature branch is gone, locally and remotely. Log shows the merge:

$ git log --oneline
3055aea Add edge firewall baseline ACL playbook (#1)
e07f73e Add .gitignore
1ccd1f1 Expand README with layout notes
215a67b Add config backup playbook and inventory template
71f47d1 Initial commit: add README

That (#1) at the end of the merge commit is GitHub’s doing. It links the commit back to PR #1 in the web UI. Click any merge commit on GitHub and it’ll take you to the original PR.

That’s the whole loop. You wrote code on a branch, pushed it, opened a PR, merged it, came back to main, and your history is clean.

The daily loop

Once you’ve done that flow two or three times, your fingers start to know it. Here’s the condensed version for reference:

bash
# Start a new piece of work
git switch -c feature/short-description

# ... edit files ...

git add .
git commit -m "What this commit does, in one short line"
git push -u origin feature/short-description

gh pr create --fill        # Auto-fills title/body from the commit messages
gh pr view --web           # Open the PR in your browser to share or review

# After it's approved (or you're solo):
gh pr merge --squash --delete-branch

git switch main && git pull   # Sync local main with the merged result

That’s the rhythm. Branch, edit, commit, push, PR, merge, sync. The whole thing takes about ninety seconds once it’s habit.

Why gh, briefly

You may have noticed I’ve been leaning on gh for the GitHub-specific bits. That’s deliberate, but it’s worth being explicit about: gh is not git. It’s a separate tool that talks to GitHub’s API. Everything gh does for you, you could also do by visiting GitHub in a browser and clicking buttons. The CLI just keeps you in the terminal.

The underlying git commands work against any forge. Push a branch to GitLab, Bitbucket, Forgejo, or your company’s self-hosted Gitea, and it’ll work the same way. The PR/MR creation step is the only place where each forge has its own tooling. Worth knowing if you find yourself on a non-GitHub setup later.

What’s next

You can now do solo work in git, push it to GitHub, and use branches and PRs the way a professional engineer would. That covers the majority of what you’ll do day to day.

What it doesn’t cover is what to do when things go wrong. The next post in this series is the one I wish I’d had bookmarked the first time I committed credentials, the first time I lost work to a bad reset --hard, and the first time I hit a merge conflict in the middle of the night.

Part 2: The Oh-Shit Toolkit. Coming next.

More in Fundamentals
comments powered by Disqus

Related Posts

2011.03.07 Switching 2 min read

SVI Autostate

Switch Virtual Interfaces, or SVIs on Cisco IOS use a feature called autostate to determine the interface availability.

2012.04.24 Industry & Events 1 min read

CCIE Potential

INE published a great info-graphic on the earning potential of Cisco’s certifications and I felt the need to share it here.