3 - Git
Git & GitHub
I already know the basics (add, commit, push). But there’s a big difference between using Git and using Git well. Playbooks are infrastructure code. A bad commit that gets pushed and run in production can take down a network segment. A missing .gitignore can leak credentials to a public repository.
Why Version Control
Before Git, network changes worked like this: an engineer SSHs into a device, makes changes, types write mem, and hopes nothing breaks. If it does break, the rollback is “remember what you changed and undo it manually.” The config backup, if it exists, is a text file on a shared drive with a name like R1-config-final-FINAL-v3-USE-THIS-ONE.txt.
Ansible without version control isn’t much better. I have a playbook that works today. I change it. It breaks something in production. I can’t remember exactly what I changed or when.
Version control changes everything:
- Every change is recorded: who changed what, when, and why
- Every change is reversible: I can roll back to any previous state in seconds
- Changes are reviewed before they run: Pull Requests mean a second set of eyes before anything touches production
- The history is the documentation: commit messages explain decisions that comments in code never would
- Collaboration is structured: multiple engineers can work on the same codebase without overwriting each other
Installing and Configuring Git
First, I updated the packages and then install git.
sudo apt update
sudo apt install -y gitVerify:
git --version
# git version 2.34.xThe first thing I do after installing Git is tell it who I am. Every commit I make is stamped with this name and email, it’s how the team knows who made each change.
git config --global user.name "Ernesto Diaz"
git config --global user.email "[email protected]"Without this configuration, Git will either refuse to commit or use a default that looks unprofessional in the team’s commit history.
GitHub’s default branch name is main. Older Git versions default to master. I align them to avoid confusion:
git config --global init.defaultBranch mainWhen Git needs me to write a commit message in an editor (e.g., during a merge), it opens the default editor. I set it to nano since it’s what I’m used to:
git config --global core.editor nanoWhen I push to GitHub over HTTPS (before SSH keys are set up), Git will ask for my credentials. The credential helper caches them so I’m not asked every single time:
git config --global credential.helper storecredential.helper store saves credentials in plaintext at ~/.git-credentials. This is acceptable for a private VM on a home lab network, but not for a shared server or any machine others have access to. On shared machines, use credential.helper cache instead, it stores credentials in memory for a configurable timeout (default 15 minutes) and never writes them to disk.Verifying the Configuration
git config --global --listuser.name=First Last
[email protected]
init.defaultBranch=main
core.editor=nano
credential.helper=storeAll global Git settings are stored in ~/.gitconfig:
cat ~/.gitconfig[user]
name = First Last
email = [email protected]
[init]
defaultBranch = main
[core]
editor = nano
[credential]
helper = storeConnecting VM to GitHub via SSH
I want Git operations to work without typing a password every time. The cleanest way is SSH key authentication between my Ubuntu VM and GitHub. The same concept as SSH keys for server access, but this time the “server” is GitHub.
This key is specifically for GitHub. I generate it on the Ubuntu VM itself:
| |
- -t ed25519:
- Ed25519 is the modern, recommended key type for GitHub
- -C “[email protected]:
- the comment becomes a label in GitHub’s SSH key list, helping me identify which key is which
- -f ~/.ssh/github_key:
- saves the key pair as
github_key(private) andgithub_key.pub(public)
When prompted for a passphrase, I set one.
chmod 600 ~/.ssh/github_key
chmod 644 ~/.ssh/github_key.pubI edit create ~/.ssh/config on the Ubuntu VM to tell SSH which key to use when connecting to GitHub:
nano ~/.ssh/configI add the following:
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/github_key
IdentitiesOnly yesI set correct permissions on the config file:
chmod 600 ~/.ssh/configSince my key has a passphrase, I’d have to type it on every Git push without an SSH agent. The agent holds the decrypted key in memory for the session:
To start the SSH agent:
eval "$(ssh-agent -s)"Add my GitHub key to the agent (will prompt for passphrase once):
ssh-add ~/.ssh/github_keyVerify it’s loaded
ssh-add -lTo make this automatic on every new shell session, I add it to ~/.bashrc:
| |
- Line 4:
- checks if the SSH agent is already running. Without this check, a new agent process would start every time I open a terminal, leaking memory over time.
Now I copy the public key and add it to my GitHub account.
cat ~/.ssh/github_key.pubThe output looks like:
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... [email protected]I copy this entire line, then:
- Go to GitHub.com → click my profile picture → Settings
- In the left sidebar, click SSH and GPG keys
- Click New SSH key
- Title:
ansible-ubuntu-vm - Key type: Authentication Key
- Key: paste the public key
- Click Add SSH key
Testing the Connection
ssh -T [email protected]Hi myusername! You've successfully authenticated, but GitHub does not provide shell access.That message confirms SSH authentication to GitHub is working. The “does not provide shell access” part is normal. GitHub only accepts Git operations over SSH, not interactive shell sessions.
Initializing the Project Repository
Now I set up the Git repository for the Ansible project I’ll build throughout this lab.
Creating the Project Directory Structure
Create the project directory
mkdir -p ~/projects/ansible-network
cd ~/projects/ansible-networkCreate the initial directory structure
mkdir -p {playbooks,inventory/{group_vars,host_vars},roles,collections,templates,files,vars}Verify the structure
tree .ansible-network/
├── collections/
│ └── requirements.yml
├── files/
├── inventory/
│ ├── group_vars/
│ ├── host_vars/
│ └── hosts.yml
├── playbooks/
├── roles/
├── templates/
└── vars/cd ~/projects/ansible-network
git initInitialized empty Git repository in /home/ansible/projects/ansible-network/.git/Git creates a hidden .git/ directory that contains the entire history of the repository. I never manually edit anything inside .git/.
Check the current state of the repository:
git statusOn branch main
No commits yet
Untracked files:
(use "git add <file>..." to include in what will be committed)
collections/
inventory/
playbooks/
nothing added to commit but untracked files present“Untracked files” means Git sees these files exist but isn’t tracking changes to them yet. Nothing is version-controlled until I explicitly git add it. This is by design, Git never assumes I want to track a file. I choose what gets tracked.
Creating the .gitignore File
Before I make a single commit, I create the .gitignore file. This is non-negotiable since it prevents secrets, temporary files, and regeneratable artifacts from ever entering the repository.
nano ~/projects/ansible-network/.gitignore | |
- Line 1:
- The venv is outside this project directory (~/venvs/) but adding these patterns protects against accidental venv creation inside the project.
- Line 12:
- Retry files are created when a playbook fails mid-run. They contain hostnames and should never be committed.
- Line 15:
- Ansible fact cache can contain sensitive device information.
- Lines 17-20:
- Secrets, never commit these.
- Lines 22-24:
- Environment variable files containing tokens/passwords.
- Lines 26-28:
- Any file named secrets.
- Lines 30-35:
- SSH private keys, just in case.
- Line 37:
- VS Code, individual user settings are not committed.
- Lines 40-42:
- VIM swap files.
- Lines 44-47:
- Logs and temporary files.
- Lines 49-52:
- Test and cover reports.
- Lines 54-55:
- AWX / Tower export files. These can contain credentials if exported carelessly.
.gitignore file only prevents untracked files from being added to Git. If I accidentally commit a secret file before adding it to .gitignore, the secret is now in the Git history. Even if I delete the file afterward. Git history is permanent. The correct remediation is git filter-repo to rewrite history (which destroys all commit SHAs and requires every team member to re-clone), plus immediately rotating the compromised credential. The lesson: set up .gitignore before the first commit, every single time.I left these two lines commented out intentionally:
# host_vars/
# group_vars/If I uncomment them, Git ignores the entire host_vars/ and group_vars/ directories, which means my variable files never get committed. That’s too aggressive. Most content in these directories (VLAN lists, interface names, routing configs) is not sensitive and should be in version control.
The correct approach is to use Ansible Vault to encrypt only the sensitive values within those files. I commit the encrypted vault files. Git stores the ciphertext, which is safe. The vault password itself goes in .vault_pass, which is in .gitignore.
The Basic Git Workflow
With the .gitignore in place, I’m ready to start tracking files. The core Git workflow I use daily is: modify → stage → commit → push.
Working Directory → Staging Area (Index) → Repository (.git/)
(modified) (git add) (git commit)- Working Directory - files on my filesystem as I edit them
- Staging Area - files I’ve marked as ready to be included in the next commit
- Repository - committed snapshots permanently stored in Git history
Understanding the staging area is the key to Git. It lets me commit only specific changes even if I’ve modified multiple files. I stage exactly what I want in each commit.
cd ~/projects/ansible-network- Check current state
git status- Stage the .gitignore file first
git add .gitignore
git status- Commit it
git commit -m "Initial commit: add .gitignore for Ansible project"- Stage the collections requirements file
git add collections/requirements.yml
git commit -m "Add Ansible collections requirements file"- Stage the entire project structure at once
git add .
git status
git commit -m "Add initial project directory structure"git add .- stages all untracked and modified files in the current directory and below. The.means “here and everything beneath.”git add .gitignore- stages only that specific file. Precise staging like this produces cleaner commits.
See unstaged changes (what I’ve modified but not yet staged)
git diffSee staged changes (what will go into the next commit)
git diff --stagedSee changes in a specific file
git diff playbooks/site.ymlgit diff output reads like this:
diff --git a/playbooks/site.yml b/playbooks/site.yml
index a1b2c3d..d4e5f6g 100644
--- a/playbooks/site.yml
+++ b/playbooks/site.yml
@@ -5,6 +5,8 @@
hosts: cisco_ios
gather_facts: false
+ connection: network_cli
+ become: false
tasks:
- Lines starting with
+are additions (shown in green in VS Code) - Lines starting with
-are deletions (shown in red) - The
@@line shows which line numbers are affected
If I staged something by mistake:
git restore --staged filename.ymlThe file goes back to “modified but unstaged”. The change is still there, it just won’t be in the next commit.
Discard all changes to a file since the last commit (CANNOT BE UNDONE):
git restore filename.ymlgit restore filename.yml permanently discards uncommitted changes to that file. There is no undo. This is one of the few Git operations that loses work irreversibly. I always run git diff filename.yml first to confirm what I’m about to lose before running restore.A commit message is a letter to my future self and my teammates explaining why a change was made. The code shows what changed and the commit message explains why.
<type>(<scope>): <short summary — 50 chars or less>
<body — optional, wrap at 72 chars>
Explain the motivation for the change. What problem does this solve?
What was the previous behavior, and why was it wrong?
<footer — optional>
Refs: CHG0012345
Closes: #42Commit Types
These types follow the Conventional Commits specification.
| Type | When to Use |
|---|---|
feat | A new playbook, role, or feature |
fix | A bug fix in an existing playbook |
refactor | Restructuring code without changing behavior |
docs | README, comments, or documentation changes |
chore | Maintenance tasks (updating requirements, .gitignore) |
test | Adding or updating test playbooks |
revert | Reverting a previous commit |
git commit -m "fix"
git commit -m "update playbook"
git commit -m "changes"
git commit -m "WIP"git commit -m "fix(ios): correct interface description task to use ios_config not raw"
git commit -m "feat(nxos): add VLAN provisioning playbook for datacenter fabric"
git commit -m "chore: update ansible from 9.7.0 to 9.8.0, pin in requirements.txt"
git commit -m "fix(bgp): add missing neighbor activate under address-family for R3"git commitThis opens nano for a multi-line message
fix(ospf): increase dead interval to prevent flapping on WAN links
The OSPF dead interval was set to the default 40 seconds. WAN links
between HQ and Branch sites experience periodic latency spikes that
cause hello packets to be dropped, triggering OSPF neighbor drops
and route reconvergence.
Increased dead interval to 120 seconds and hello interval to 30
seconds on all WAN-facing interfaces. Verified no change to LAN
interfaces (still using defaults).
Tested against: R1, R2, R3 in staging environment.
Change window: CHG0019823
Refs: #87Connecting to GitHub and Pushing
- Log in to GitHub.com
- Click the + in the top right → New repository
- Configure:
- Repository name:
ansible-network - Description:
Network automation playbooks and roles for Cisco IOS/NX-OS, Juniper, and Palo Alto - Visibility: Private (always private for infrastructure code)
- Do NOT initialize with README, .gitignore, or license (I already have these locally)
- Repository name:
- Click Create repository
GitHub shows the “Quick setup” page with instructions. I’ll use the SSH URL.
cd ~/projects/ansible-networkAdd GitHub as the remote repository (named “origin” by convention)
git remote add origin [email protected]:myusername/ansible-network.gitVerify the remote was added
git remote -vorigin [email protected]:myusername/ansible-network.git (fetch)
origin [email protected]:myusername/ansible-network.git (push)origin is the conventional name for the primary remote. I can name it anything, but origin is what every tool expects.
Push the main branch to GitHub and set it as the upstream tracking branch
git push -u origin main-u origin main - sets the upstream tracking relationship. After this first push, I can just type git push and Git knows where to push to.
Enumerating objects: 12, done.
Counting objects: 100% (12/12), done.
Writing objects: 100% (12/12), 1.23 KiB | 1.23 MiB/s, done.
To [email protected]:myusername/ansible-network.git
* [new branch] main -> main
Branch 'main' set up to track remote branch 'main' from 'origin'.I can now go to github.com/myusername/ansible-network and see my files online.
Git Workflow
These are the commands I run every day.
- Start the session (activate the virtualenv and navigate to the lab)
source ~/venvs/ansible-network/bin/activate
cd ~/projects/ansible-network- Pull the latest changes from GitHub before starting work
git pullDo my work (edit playbooks, add roles, update inventory)
Check what changed
git status
git diff- Stage specific files
git add playbooks/deploy_vlans.yml- Commit with a meaningful message
git commit -m "feat(vlans): add VLAN deployment playbook for IOS access switches"- Push to GitHub
git pushgit pull is actually two operations in one: git fetch (download changes from GitHub) followed by git merge (apply them to my local branch). For solo work, git pull is fine. On a team, some engineers prefer git pull --rebase which replays my local commits on top of the fetched commits, keeping a cleaner linear history.
Branching Strategy
On a team with a formal review process, nobody pushes directly to main. Changes go through branches and Pull Requests. Here’s the lightweight strategy I use, tailored specifically for network automation work.
main # Production-ready code only. Protected branch.
│ # All changes enter via Pull Request and peer review
├── feature/add-bgp-role
├── feature/nxos-vlan-playbook
├── fix/ospf-dead-interval-wan
├── hotfix/acl-blocking-monitoring
└── chore/update-ansible-9.8| Branch Type | Naming Convention | Purpose |
|---|---|---|
| Feature | feature/short-description | New playbooks, roles, or capabilities |
| Fix | fix/short-description | Bug fixes in existing playbooks |
| Hotfix | hotfix/short-description | Urgent fixes that need to bypass normal review timelines |
| Chore | chore/short-description | Dependency updates, documentation, .gitignore changes |
| Test | test/short-description | Experimental changes, not intended for merge |
I always start from an up-to-date main
git checkout main
git pullCreate a new branch and switch to it in one command
git checkout -b feature/add-ios-vlan-playbookVerify which branch I’m on
git branch
# * feature/add-ios-vlan-playbook
# maingit checkout -b- creates the branch AND switches to it. Without-b, I’d switch to an existing branch.- The
*ingit branchoutput marks the current branch.
I then do my work on this branch
Stage and commit as normal. Commits go to the feature branch, not main
git add playbooks/deploy_vlans.yml
git commit -m "feat(vlans): add IOS VLAN deployment playbook with trunk/access support"Push the feature branch to GitHub
git push -u origin feature/add-ios-vlan-playbookSwitch back to main (e.g., to pull updates or start a different task)
git checkout mainSwitch back to my feature branch
git checkout feature/add-ios-vlan-playbookList all branches (local and remote)
git branch -aBefore switching branches, I always commit or stash my work-in-progress. Git will refuse to switch branches if I have uncommitted changes that conflict with the target branch. If I’m mid-task and need to switch:
| |
- Line 1:
- Temporarily shelve uncommitted changes
- Line 5:
- Restore my shelved changes
Switch back to main and pull the merged changes
git checkout main
git pullDelete the feature branch locally (it’s been merged, no longer needed)
git branch -d feature/add-ios-vlan-playbookDelete the remote branch on GitHub
git push origin --delete feature/add-ios-vlan-playbookPeer Review Process
A Pull Request (PR) is a formal request to merge a branch into main. On a team with a formal review process, no code reaches main without at least one approval.
After pushing a feature branch:
- Go to the repository on GitHub.com
- GitHub usually shows a yellow banner: “feature/add-ios-vlan-playbook had recent pushes” → click Compare & pull request
- Fill in the PR template:
PR Title:
feat(vlans): Add IOS VLAN deployment playbook with trunk/access supportPR Description:
## Summary
Adds a new playbook `playbooks/deploy_vlans.yml` for deploying VLAN
configurations to Cisco IOS access layer switches.
## Changes
- New playbook: `playbooks/deploy_vlans.yml`
- New vars file: `inventory/group_vars/cisco_ios_access.yml`
- Updated: `collections/requirements.yml` (added cisco.ios 8.0.1)
## Testing
- Tested against 3x CSR1000v nodes in Containerlab
- Ran with `--check` first, then applied
- Verified VLAN database and trunk configurations post-apply
## How to Test
`ansible-playbook playbooks/deploy_vlans.yml --limit lab_switches --check`
`ansible-playbook playbooks/deploy_vlans.yml --limit lab_switches`- ansible-lint passes with no violations
- yamllint passes with no violations
- No secrets or credentials in this PR
- requirements.txt updated if new Python packages added
- collections/requirements.yml updated if new collections added
- Assign a Reviewer at least one other engineer
- Click Create pull request
.github/pull_request_template.md in the repository root and GitHub will use it for every new PR automatically. This standardizes what every reviewer expects to see and makes the checklist a habit rather than an afterthought.The reviewer:
- Reads through every changed file in the Files changed tab
- Looks for logic errors, missing error handling, hardcoded values that should be variables
- Verifies no secrets are present
- Checks that the playbook follows the project’s naming and structure conventions
- Leaves inline comments on specific lines if something needs changing
- Either Approves, Requests changes, or Comments without a verdict
Make the requested changed on my feature branch
git add playbooks/deploy_vlans.yml
git commit -m "fix: address review feedback — parameterize VLAN range, add error handling"
git pushThe PR on GitHub automatically updates with the new commit. The reviewer can see the changes and re-review. Once approved, the PR is merged.
Branch Protection Rules
For a team with formal review, I configure branch protection on main in GitHub:
- Go to repository → Settings → Branches
- Click Add branch protection rule
- Branch name pattern:
main - Enable:
- Require a pull request before merging
- Require approvals set to
1minimum (or2for higher-risk repos) - Dismiss stale pull request approvals when new commits are pushed
- Require status checks to pass before merging (for CI/CD in Part 32)
- Restrict who can push to matching branches (only senior engineers or automation service accounts)
- Click Create
git log and git diff
Basic log:
git logCompact one-line format:
git log --onelineCompact with branch graph:
git log --oneline --graph --allLast 5 commits:
git log -5 --onelineCommits by a specific author:
git log --author="First Last" --onelineCommits that touched a specific file:
git log --oneline -- playbooks/deploy_vlans.ymlCommits in a date range:
git log --oneline --after="2024-01-01" --before="2024-12-31"Search commit messages for a keyword:
git log --grep="bgp" --onelineSample git log --oneline --graph --all output:
* a3f2b1c (HEAD -> main, origin/main) fix(bgp): add missing neighbor activate for R3
* 9d4e5f2 feat(vlans): add IOS VLAN deployment playbook
* 7c8b3a1 chore: update ansible from 9.7.0 to 9.8.0
* 4f1d9e8 feat(ospf): add multi-area OSPF role for IOS
* 2a3c7b0 Initial commit: add .gitignore for Ansible projectShow the full diff of a specific commit:
git show a3f2b1cShow just the files that changed in a commit:
git show a3f2b1c --statShow what changed in a specific file in a specific commit:
git show a3f2b1c -- playbooks/bgp.ymlWhat changed between two commits:
git diff 4f1d9e8 a3f2b1cWhat changed between a commit and the current working state:
git diff a3f2b1cWhat changed between two branches:
git diff main feature/add-ios-vlan-playbookWhat changed in a specific file between two branches:
git diff main feature/add-ios-vlan-playbook -- playbooks/deploy_vlans.ymlIf a commit that was already pushed to main turns out to be wrong:
git revert a3f2b1c
git pushNever use git reset --hard or git push --force on the main branch on a shared repository. These commands rewrite history, which invalidates every team member’s local copy of the repository and can cause data loss. git revert is always the safe way to undo a change that’s already been pushed. Reserve git reset for cleaning up commits that have NOT yet been pushed.
Security Best Practices
- Passwords (device passwords, API tokens, RADIUS secrets)
- SSH private keys
- Ansible Vault passwords (.vault_pass)
- .env files with credentials
- AWS/cloud credentials
- Private IP addressing schemes of production networks (debatable, but cautious teams exclude this)
- Anything that would give an attacker a foothold if the repo were made public
Before pushing, I can scan for secrets using git-secrets or trufflehog:
Install trufflehog (a secrets scanner)
pip install trufflehogScan the entire repository history for secrets
trufflehog git file://. --only-verifiedA pre-commit hook runs automatically before every git commit and can block the commit if it finds problems:
Install pre-commit framework
pip install pre-commitCreate .pre-commit-config.yaml in the project root
| |
- id: check-yaml:
- Validates YAML syntax
- id:end-of-file-fixer:
- Ensures files end with newline
- id:trailing-whitespace:
- Removes trailing whitespace
- id: check-merge-conflict:
- Blocks commits with merge conflic markers
- id: detect-secrets:
- Scans for hardcoded secrets
- id: ansible-link:
- Runs ansible-lint before every commit
Install the hooks (runs once — creates hooks in .git/hooks/)
pre-commit installpre-commit install- installs the hooks into.git/hooks/pre-commit. Now everygit commitautomatically runs these checks first.- If any check fails, the commit is blocked until I fix the issue.
Test it manually
pre-commit run --all-filesIf a Secret Was Already Committed
This is the procedure if I accidentally committed a credential:
- Rotate the credential immediately - assume it’s compromised. Change the password, revoke the API token, generate a new SSH key. Do this first, before anything else.
- Remove it from history using
git filter-repo(notgit filter-branchwhich is deprecated):
pip install git-filter-repo
git filter-repo --path secrets.yml --invert-paths
git push --force-with-lease origin main- Notify the team - everyone must re-clone the repository because the history has changed.
- Add the file to
.gitignoreimmediately. - Conduct a post-incident review - how did this happen and what process change prevents it next time?
Every change I make from this point forward goes through Git. Playbooks, inventory files, variable files, roles, templates. All of it is version-controlled, reviewed, and traceable.