Version control with Git

Listen to this lesson

Episode 39 · 63:24

This episode is a study companion for CompTIA Linux+ XK0-006 and is not produced by or endorsed by CompTIA.

Objective 4.4 · Automation, Orchestration, and Scripting · 17% of the exam

Why this matters

Everything in the next three lessons — infrastructure as code, CI/CD, configuration management — assumes git underneath it. A pipeline is triggered by a push. A Terraform state review is a diff. "Infrastructure as code" is mostly a claim about where the code lives.

For an administrator the immediate value is smaller and more concrete: a record of who changed /etc/nginx/nginx.conf, when, and what it looked like before. nginx.conf.bak, nginx.conf.bak2 and nginx.conf.working is a version control system too — just a bad one, with no history, no author and no way back.

The lesson

The three areas

Almost every git confusion comes from not holding this picture:

working tree  --add-->  staging area (index)  --commit-->  repository
                                                    <--checkout--

The working tree is your files as they are on disk. The staging area, also called the index, is what you have marked to go into the next commit. The repository is the committed history in .git/.

The staging area is the piece that looks like bureaucracy and is not: it lets you commit part of your work. Having fixed a bug and reformatted three files, you can commit the fix alone and leave the rest for a separate commit.

init, clone and config

git init                                  # a repository here
git init --initial-branch=main
git clone https://github.com/org/repo.git # a copy of a remote one
git clone git@github.com:org/repo.git     # over SSH -- keys, not passwords
git clone --depth 1 <url>                 # shallow: latest commit only

init creates .git/ in an existing directory. clone gets you a full copy of the entire history, not just the current files, plus a remote named origin already configured. That completeness is what makes git distributed: every clone is a full backup, and you can commit, branch and inspect history with no network at all.

config sets identity and behaviour, at three levels:

git config --global user.name "Your Name"
git config --global user.email "you@example.com"
git config --global init.defaultBranch main
git config --global core.editor vim
git config user.email "work@company.com"   # repository-only, overrides global
git config --list --show-origin            # what is set, and from which file

--system is /etc/gitconfig, --global is ~/.gitconfig, and no flag means .git/config in this repository. The most specific wins, which is how you use a work address in one repository and a personal one everywhere else.

Set user.name and user.email before your first commit. Git will refuse to commit without them, and a repository full of commits attributed to root@localhost is a history you cannot audit.

add, commit and log

git status                       # ALWAYS the first command
git add nginx.conf               # stage one file
git add .                        # stage everything below here
git add -p                       # stage selected HUNKS, interactively
git commit -m "Raise worker_connections to 4096"
git commit -am "message"         # stage tracked changes and commit
git commit --amend               # rewrite the LAST commit

git status tells you what is modified, what is staged and what is untracked. Run it constantly; it also suggests the command for whatever you are probably trying to do.

git add -p is the one worth learning properly. It walks you through each change and asks whether to stage it, which is how you keep an unrelated debugging tweak out of an otherwise clean commit.

Write commit messages for the person who runs git log in a year — which is you, at three in the morning, wondering why this line is here. A subject line under about 50 characters saying what changed, then a blank line, then why. "Fixed stuff" is worse than no message, because it consumes a line of history and tells you nothing.

--amend rewrites history. Amending a commit that only exists locally is fine and useful. Amending one you have already pushed changes its hash, so everyone else's history no longer matches and their next pull is a mess.

git log                          # full history
git log --oneline --graph --all  # the shape of the branches
git log -p nginx.conf            # every change to one file, with diffs
git log -5 --stat                # last five, with files changed
git log --since="2 weeks ago" --author="alice"
git show <hash>                  # one commit in full
git blame nginx.conf             # who last changed each LINE, and in which commit

git log -p <file> and git blame are the two that make this worth doing for configuration. "Why is this timeout 900?" becomes a question with an answer: the commit that set it, its message, and its author.

diff

git diff                         # working tree vs staged -- NOT yet added
git diff --staged                # staged vs last commit -- what you would commit
git diff HEAD                    # everything uncommitted
git diff main..feature           # between two branches
git diff HEAD~3 HEAD -- nginx.conf

The default git diff shows only what is not staged, which is why it prints nothing after git add . and people conclude their changes vanished. --staged shows the other half. git diff HEAD shows both together.

.gitignore

.gitignore lists what git should not track:

# secrets -- first, and non-negotiable
*.pem
*.key
.env
secrets.yml

# build output and dependencies
__pycache__/
.venv/
node_modules/
*.pyc

# editor and OS noise
.vscode/
.DS_Store
*.swp

# but keep this one
!config/example.env

Blank lines and # comments are ignored, a trailing / means a directory, * is a glob, and a leading ! re-includes something an earlier pattern excluded.

.gitignore only affects untracked files. Adding .env to it after committing .env does nothing at all — the file is already tracked and keeps being tracked. To stop that:

git rm --cached .env             # untrack it, KEEP it on disk
echo ".env" >> .gitignore
git commit -m "Stop tracking .env"

And the part that matters most: removing a secret from the latest commit does not remove it from the history. It is in every earlier commit and in every clone anybody has taken. A key that has been pushed is a key that has been disclosed — rotate it. Cleaning history with git filter-repo is worth doing afterwards, but rotation is the actual remediation and it comes first.

branch and checkout

A branch is a movable pointer to a commit, which is why creating one is instantaneous and costs nothing.

git branch                       # list local branches
git branch -a                    # including remote-tracking ones
git branch feature/tls           # create, without switching
git checkout feature/tls         # switch to it
git checkout -b feature/tls      # create AND switch -- the usual form
git branch -d feature/tls        # delete (refuses if unmerged)
git branch -D feature/tls        # delete anyway

checkout is overloaded — it switches branches, and it also discards changes to a file:

git checkout -- nginx.conf       # THROW AWAY uncommitted changes to this file

That second use is destructive and silent, and the overloading is exactly why newer git split it into two clearer commands:

git switch feature/tls           # switch branches
git switch -c feature/tls        # create and switch
git restore nginx.conf           # discard working-tree changes
git restore --staged nginx.conf  # unstage, keeping the changes

Learn switch and restore, recognise checkout. The exam and every existing document use checkout; your own hands are safer with the new pair.

merge and squash

git checkout main
git merge feature/tls            # bring the branch's work into main
git merge --no-ff feature/tls    # always make a merge commit
git merge --squash feature/tls   # combine it all into ONE staged change
git merge --abort                # back out of a conflicted merge

A fast-forward merge happens when main has not moved: git just slides the pointer forward, and there is no merge commit. --no-ff forces one anyway, which keeps the fact that a branch existed visible in the history.

Squash collapses a branch's commits into a single one. Twenty commits of "wip", "fix typo", "actually fix it" become one clean commit on main. The trade is that you lose the individual steps — good for a messy feature branch, bad when the individual commits are meaningful and you might want to revert one.

Conflicts happen when two branches changed the same lines. Git marks them in the file:

<<<<<<< HEAD
worker_connections 4096;
=======
worker_connections 2048;
>>>>>>> feature/tls

Edit the file into what you actually want, delete all three marker lines, then git add it and git commit. git merge --abort returns you to before the merge if you would rather start again. The classic failure is committing the markers themselves, which produces a configuration file that no longer parses.

fetch, pull and push

git remote -v                    # where origin points
git fetch origin                 # DOWNLOAD, change nothing locally
git pull                         # fetch + merge into the current branch
git pull --rebase                # fetch + rebase -- linear history
git push origin main
git push -u origin feature/tls   # push and set upstream tracking
git push --tags

fetch and pull differ in exactly one way that matters: fetch is safe. It downloads new commits and updates the remote-tracking branches, and touches nothing in your working tree. pull is fetch followed by a merge, so it can produce conflicts and change your files.

The habit worth forming is git fetch then git log HEAD..origin/main to see what arrived, then merge deliberately.

git push --force overwrites the remote branch, discarding whatever anyone else pushed in the meantime. If you must, use --force-with-lease, which refuses when the remote has commits you have not seen. On a shared branch, prefer neither.

rebase

git rebase main                  # replay this branch's commits on top of main
git rebase -i HEAD~5             # interactive: reword, squash, drop, reorder
git rebase --continue
git rebase --abort

Rebase replays your commits onto a new base, producing a straight line instead of a merge commit. Interactive rebase is where you tidy a branch before sharing it — squashing "fix typo" into the commit it fixes, rewording a bad message, dropping a debugging commit.

Every rebased commit gets a new hash. So the rule, which the exam likes: never rebase commits you have already pushed to a shared branch. Rebase your own unpushed work freely; leave shared history alone.

reset

git reset --soft HEAD~1     # undo the commit, KEEP changes staged
git reset HEAD~1            # undo the commit, keep changes unstaged (--mixed)
git reset --hard HEAD~1     # undo the commit and DESTROY the changes
git reset --hard origin/main

The three modes differ only in how far back they unwind, and it is worth learning them as a ladder:

Mode Commit Staging area Working tree
--soft undone kept kept
--mixed (default) undone cleared kept
--hard undone cleared destroyed

--hard deletes uncommitted work with no confirmation and no undo. It is the most destructive command in ordinary git use. --soft is the one you usually want: "I committed too early, let me redo the commit."

For undoing a commit that others already have, use git revert <hash> instead. It creates a new commit that reverses the change, so history is added to rather than rewritten — safe on a shared branch, where reset is not.

git reflog is the safety net: it records where HEAD has been, including commits that a reset appears to have destroyed, for about ninety days.

git reflog
git reset --hard HEAD@{2}        # back to where you were before the mistake

Committed work is very hard to lose. Uncommitted work is easy to lose. That asymmetry is the argument for committing early and often.

stash

git stash                        # shelve uncommitted changes, clean the tree
git stash -u                     # include untracked files
git stash push -m "half-done TLS config"
git stash list
git stash pop                    # reapply the most recent and drop it
git stash apply stash@{1}        # reapply, keeping it on the stack
git stash drop

Stash is for the interruption: you are halfway through an edit when an urgent fix is needed on another branch. Stash, switch, fix, switch back, pop.

Two things that bite. Plain git stash does not include untracked files, so a brand-new file stays in your working tree and follows you to the other branch — use -u. And a stash is easy to forget: git stash list after a couple of weeks routinely turns up work someone abandoned. Prefer a throwaway branch for anything you will not pop within the hour.

tag

git tag                          # list
git tag -a v1.2.0 -m "Release 1.2.0"     # annotated -- use this
git tag v1.2.0                            # lightweight
git tag -a v1.2.0 <hash>                  # tag an older commit
git show v1.2.0
git push origin v1.2.0
git push --tags
git tag -d v1.2.0

A tag is a permanent name for one commit, where a branch moves. Tags are how releases are marked, and how a CI pipeline knows to build and publish.

Annotated tags (-a) are full objects carrying a tagger, a date, a message and an optional GPG signature; lightweight tags are just a pointer. Use annotated for anything anyone else will see — a release you cannot date or attribute is not much of a release.

Tags are not pushed by git push. You have to push them explicitly, which is why a release is regularly tagged locally and invisible to everyone else until somebody asks where it went.

On the exam

  • Three areas: working tree, staging area (index), repository. add stages, commit records.
  • init creates a repository; clone copies the whole history and sets up origin.
  • config at --system, --global, or repository level; most specific wins.
  • git diff shows unstaged changes; --staged shows what you would commit.
  • .gitignore only affects untracked files — already-committed files need git rm --cached, and a pushed secret must be rotated, not just deleted.
  • branch creates, checkout switches (and, with --, discards changes); switch and restore are the safer modern split.
  • merge combines branches; --squash collapses them into one commit. Conflict markers must be removed by hand.
  • fetch downloads and changes nothing; pull is fetch plus merge.
  • rebase rewrites hashes — never on already-pushed shared branches.
  • reset --soft keeps changes staged, --mixed keeps them in the working tree, --hard destroys them. revert is the safe alternative on shared history, and reflog recovers most mistakes.
  • stash shelves work in progress; plain stash omits untracked files.
  • tag names a commit permanently; use -a, and push tags explicitly.

Practise what you just read

1. A secret was committed and pushed, then removed in a later commit. What is the correct remediation?

Select one

  1. Run git rm --cached, which purges it from the history
  2. Rotate the credential, because it remains in the history and in every clone
  3. Force-push the branch, which rewrites what everyone has
  4. Add the file to .gitignore, which removes it from earlier commits
Show answer

B. Removing a file in a later commit changes nothing about the earlier ones, and anyone who cloned or fetched already has it. A key that has been pushed is a key that has been disclosed. Rotation is the actual fix; cleaning the history with git filter-repo is worth doing afterwards, and .gitignore only ever affects untracked files.

2. What is the difference between git fetch and git pull?

Select one

  1. They are aliases for the same operation
  2. pull downloads and fetch additionally pushes local commits
  3. fetch downloads and changes nothing locally; pull is fetch followed by a merge
  4. fetch works only on the current branch; pull works on all of them
Show answer

C. fetch is safe: it updates the remote-tracking branches and leaves your working tree untouched. pull merges immediately, so it can produce conflicts and change your files. The habit worth forming is fetch, then git log HEAD..origin/main to see what arrived, then merge deliberately.

3. Which git reset mode discards uncommitted work irrecoverably?

Select one

  1. --mixed
  2. --merge
  3. --soft
  4. --hard
Show answer

D. --soft undoes the commit and keeps the changes staged, --mixed keeps them in the working tree, and --hard destroys them with no confirmation and no undo. It is the most destructive command in ordinary git use. Committed work is very hard to lose thanks to the reflog; uncommitted work is easy, which is the argument for committing often.

9 more questions on this objective are part of the full course.

Practise the full question bank in the exam simulator

Hands-on labs

All hands-on labs