Signal9Signal9 — home
online
all lessons

Git Essentials for Sysadmins: Commit, Branch, Share, Undo

Beginner40 min7 steps
What you'll learn
  • Explain the working tree, the staging area and the repository, and what moves a change between them
  • Create a repository, set an author identity, and record commits
  • Create a branch, commit on it, and merge it back into main
  • Keep build output and secrets out of history with a committed .gitignore
  • Clone a shared repository and push commits and annotated tags to it
  • Undo a published commit with git revert instead of rewriting history
  1. The mental model: a timeline of snapshots

    Picture what Git actually stores before you type a single git command.

    Git looks intimidating because it has a lot of commands, but underneath it does one simple thing: it takes snapshots of a directory and remembers them in order. Each snapshot is a commit. A commit records what every tracked file looked like at that moment, who made it, when, why (the message), and which commit came before it. Chain those together and you get a timeline of your project you can read, compare, and go back to.

    A useful picture is a save-game system. Every commit is a save point. You can look at any old save, start a side quest from one (a branch), and later fold the side quest back into the main story (a merge). Nothing you saved is ever overwritten by a new save; new saves are added on top.

    Three places matter, and almost every confusing Git moment comes from mixing them up:

    • Working tree: the files you see and edit in the directory.
    • Staging area (also called the index): the set of changes you've picked for the next commit.
    • Repository (the hidden .git directory): every commit ever made, plus labels such as branch names and tags that point at commits.

    You edit in the working tree, choose changes with git add (staging), and record them with git commit (repository). git status tells you, at any moment, what sits in each place.

    Check yourself

    You edited config.ini but haven't run git add yet. In which of the three places does that change live?

  2. Your first repository: init, identity, commit

    Create a repository, tell Git who you are, and record your first two commits.

    Let's make a repository for a small piece of server config. git init turns an ordinary directory into a repository by creating the hidden .git directory inside it. Nothing about your files changes; Git simply starts watching the directory. We'll name the first branch main, which is the modern default.

    mkdir -p /tmp/git-lesson/site && cd /tmp/git-lesson/site && git init -b main
    Initialized empty Git repository in /tmp/git-lesson/site/.git/
    -b main
    Name the initial branch main instead of relying on the system default.
    Git created /tmp/git-lesson/site/.git. That directory is the repository: delete it and the history is gone, but your files stay. Everything else you'll do talks to this directory.

    Before Git will record a commit it needs to know who is making it, because every commit carries an author name and email. You can set them for your whole account with --global (stored in ~/.gitconfig), or for just one repository by leaving --global off (stored in that repository's .git/config). On your own login, global is the convenient choice; on a shared account such as root on a team server, prefer per-repository so your name doesn't end up on everyone's commits.

    git config --global user.name "Sam Admin" && git config --global user.email sam@example.internal && git config --global --list
    user.name=Sam Admin
    user.email=sam@example.internal
    --global
    Read or write ~/.gitconfig (your account) instead of the current repository's .git/config.
    --list
    Print every setting as key=value.
    The identity is saved in ~/.gitconfig and applies to every repository you work in from this account, including the ones later in this lesson. --list prints the settings back so you can confirm exactly what was stored.

    Now create a file and watch how Git sees it. A brand-new file starts out untracked: Git notices it but won't include it in commits until you say so.

    cd /tmp/git-lesson/site && printf '[server]\ntimeout = 30\n' > config.ini && git status --short
    ?? config.ini
    ?? means untracked. The short format prints two columns per file: the left one is the staging area, the right one is the working tree. ?? in both means Git has never been told about this file.
    cd /tmp/git-lesson/site && git add config.ini && git status --short
    A  config.ini
    A in the left column means the file is now staged as added: it will be part of the next commit. git add copies the file's current content into the staging area. If you edit the file again afterwards, you'd need to git add it again to stage the new version.
    cd /tmp/git-lesson/site && git commit -q -m "Initial server config" && git log --oneline
    ad56688 Initial server config
    -m
    Give the commit message on the command line instead of opening an editor.
    --oneline
    Show each commit as short id plus subject.
    The staged snapshot is now a commit. git log --oneline shows one line per commit: a short commit id (a hash of the snapshot, so yours will differ) and the message. That id is how you'll refer to this exact snapshot from now on.
    cd /tmp/git-lesson/site && sed -i 's/timeout = 30/timeout = 45/' config.ini && git diff && git commit -q -am "Raise timeout to 45s"
    diff --git a/config.ini b/config.ini
    index f1df41d..92b663c 100644
    --- a/config.ini
    +++ b/config.ini
    @@ -1,2 +1,2 @@
     [server]
    -timeout = 30
    +timeout = 45
    -a
    Stage modifications and deletions of tracked files before committing.
    -q
    Quiet: don't print the commit summary.
    git diff shows working-tree changes that aren't staged yet: the - line is the old content, the + line the new. commit -a stages every change to files Git already tracks and commits in one step. It never picks up untracked files, so a brand-new file still needs git add.

    Check yourself

    You created a new file notes.txt and ran git commit -am "Add notes". Is notes.txt in that commit?

  3. Branches: movable labels for side quests

    Make a change on a branch and merge it back into main.

    A branch sounds heavy, but in Git it is just a label that points at a commit and moves forward each time you commit on it. main is one label. Creating a branch makes a second label at the same commit; switching to it means new commits move that label and leave main where it was. That's why branches are cheap: Git doesn't copy any files to make one.

    Teams use branches so an unfinished or unreviewed change never lands on main by accident. The usual loop is: branch, commit, review, merge.

    mkdir -p /tmp/git-lesson/br && cd /tmp/git-lesson/br && git init -q -b main && printf 'timeout = 30\n' > app.conf && git add app.conf && git commit -q -m base && git switch -c fix-timeout && git branch
    Switched to a new branch 'fix-timeout'
    * fix-timeout
      main
    switch -c NAME
    Create a new branch called NAME and switch to it.
    branch
    List local branches; * marks the current one.
    init -q
    Quiet: skip the 'Initialized empty repository' line.
    This builds a fresh one-commit repo, then git switch -c creates the branch fix-timeout at the current commit and moves you onto it in one step. git branch lists the branches and stars the one you're on. Older guides use git checkout -b, which does the same thing as switch -c.
    cd /tmp/git-lesson/br && sed -i 's/30/60/' app.conf && git commit -q -am "Raise timeout to 60s" && git log --oneline --all --graph --decorate
    * 74e55ac (HEAD -> fix-timeout) Raise timeout to 60s
    * 128761a (main) base
    --all
    Show commits from every branch, not just the current one.
    --graph
    Draw the history as an ASCII graph.
    --decorate
    Label commits with the branches, tags and HEAD that point at them.
    --graph --all draws every branch. The newest commit carries (HEAD -> fix-timeout): HEAD is "where you are", and the label moved forward with your commit. main still points at the older commit below it, untouched.

    To bring the fix into main, switch to main and merge the branch. You always merge into the branch you're on.

    cd /tmp/git-lesson/br && git switch -q main && git merge fix-timeout && cat app.conf
    Updating 128761a..74e55ac
    Fast-forward
     app.conf | 2 +-
     1 file changed, 1 insertion(+), 1 deletion(-)
    timeout = 60
    merge BRANCH
    Integrate BRANCH's commits into the current branch.
    Because main hadn't moved since the branch was created, Git simply slid the main label forward to the branch's commit. That's a fast-forward merge: no new commit is needed. If main had gained its own commits in the meantime, Git would create a merge commit with two parents to join the two lines of history.

    Check yourself

    You're on fix-timeout and run git merge main. Which branch moves?

  4. Keeping junk out: .gitignore

    Stop build output and secrets from ever being committed, and leave a clean working tree.

    Some files should never be in history: build artifacts, logs, editor swap files, and above all secrets. A .gitignore file lists patterns Git should treat as invisible. Ignored files don't show up as untracked and aren't picked up by git add .. The .gitignore file itself is committed, so everyone who clones the repository gets the same rules.

    mkdir -p /tmp/git-lesson/ig && cd /tmp/git-lesson/ig && git init -q -b main && echo app > app.py && git add app.py && git commit -q -m app && mkdir build && echo bin > build/app.bin && git status --short
    ?? build/
    Here's the problem in miniature: the build output shows up as untracked (?? build/). Sooner or later someone runs git add . and commits a binary, or worse, a .env file with passwords in it.
    cd /tmp/git-lesson/ig && echo 'build/' > .gitignore && git status --short && git check-ignore -v build/app.bin
    ?? .gitignore
    .gitignore:1:build/	build/app.bin
    check-ignore -v
    Report which ignore rule (file:line:pattern) matches a path.
    build/ vanished from the status and only .gitignore itself is new. A trailing slash means "this directory and everything in it". git check-ignore -v names the file, line number and pattern responsible, which is the quickest way to debug "why won't Git see my file?"
    cd /tmp/git-lesson/ig && git add .gitignore && git commit -q -m "Ignore build output" && git status
    On branch main
    nothing to commit, working tree clean
    "nothing to commit, working tree clean" is the state you want before you switch branches, pull, or hand the server to someone else. Notice build/ is still on disk: ignoring a path hides it from Git, it doesn't delete anything.

    Check yourself

    A file is already tracked, and you add its name to .gitignore. Does Git stop tracking changes to it?

  5. Sharing work: clone, push, and tags

    Publish commits and a release tag to a shared repository.

    So far everything has been local. Teams share work through a remote: another copy of the repository, usually on a server. A server copy is typically a bare repository: just the .git contents, with no working tree, because nobody edits files there directly. git clone copies a repository and remembers where it came from under the name origin.

    It helps to picture this as a shared notebook in the office: you photocopy it (clone), write your pages at your desk (commit), then add your pages to the shared copy (push).

    mkdir -p /tmp/git-lesson/remote /tmp/git-lesson/remote/work && cd /tmp/git-lesson/remote && git init -q --bare -b main shared.git && git clone -q shared.git work && cd work && echo v1 > VERSION && git add VERSION && git commit -q -m "First release" && git push -q origin main && git log --oneline origin/main
    warning: You appear to have cloned an empty repository.
    0ed117b First release
    --bare
    Create a repository with no working tree, suitable as a shared remote.
    push origin main
    Send local main's new commits to the remote named origin.
    We made a bare shared.git to stand in for the server, cloned it, committed, and pushed. Cloning an empty repository prints a harmless warning, because there's nothing to copy yet. origin/main is your clone's record of where main is on the server; after the push it points at your commit.

    A tag is a permanent name for one commit, typically a release: v1.4.1. A lightweight tag is just a name. An annotated tag (-a) is a full object with its own message, tagger and date, and it's what release tooling expects. Tags behave differently from branches in one important way: git push does not send them unless you ask.

    cd /tmp/git-lesson/remote/work && git tag -a v1.0 -m "Release 1.0" && git push -q origin v1.0 && git -C ../shared.git cat-file -t v1.0
    tag
    tag -a NAME -m MSG
    Create an annotated tag with a message.
    push origin NAME
    Push one tag (or branch) by name.
    cat-file -t
    Print the type of a Git object: commit, tag, tree or blob.
    cat-file -t on the server copy prints tag, which proves two things: the tag reached the remote, and it's annotated (a lightweight tag would report commit). git push --follow-tags would have pushed main and any annotated tags on its commits in one go.

    Check yourself

    You ran git tag v2.0 and then git push origin main. Does the server now have the v2.0 tag?

  6. Undoing a change without rewriting history

    Choose between revert and reset, and know why it matters once commits are shared.

    Sooner or later a bad commit lands. Git gives you two very different ways to undo it, and picking the wrong one on a shared branch causes real pain.

    • `git revert <commit>` adds a new commit that applies the exact opposite of the bad one. History only grows. Everyone who already pulled just pulls again.
    • `git reset --hard <commit>` moves the branch label back, so later commits drop out of the branch. That's fine for commits nobody else has seen, but once they're pushed, putting the reset on the server needs --force, and everyone else's copy suddenly disagrees with the server.

    Think of a shared logbook: revert is writing a correction on a new line; reset is tearing the pages out after other people have copied them.

    mkdir -p /tmp/git-lesson/undo && cd /tmp/git-lesson/undo && git init -q -b main && printf 'debug = false\n' > app.conf && git add app.conf && git commit -q -m baseline && sed -i 's/false/true/' app.conf && git commit -q -am "Turn on debug" && echo 'contact: ops@example.internal' > README && git add README && git commit -q -m "Add contact" && git log --oneline
    444b12a Add contact
    0e68f01 Turn on debug
    0f17682 baseline
    Three commits: a baseline, a bad one in the middle that turned debug on, and a good one on top that we must keep. This is exactly where reset hurts, because resetting to the baseline would throw away the good commit as well.
    cd /tmp/git-lesson/undo && git revert --no-edit HEAD~1 >/dev/null && git log --oneline && cat app.conf
    c057ed8 Revert "Turn on debug"
    444b12a Add contact
    0e68f01 Turn on debug
    0f17682 baseline
    debug = false
    --no-edit
    Accept the default 'Revert "..."' message instead of opening an editor.
    HEAD~1
    The commit one step before HEAD. HEAD~2 is two steps back, and so on.
    HEAD~1 means "one commit before where I am", which is the bad commit. Revert recorded a fourth commit, Revert "Turn on debug", and app.conf is back to debug = false, while README from the later commit is still there. In real life you'd point revert at the commit id you found in git log.

    Check yourself

    A bad commit was pushed to main an hour ago and three colleagues have pulled since. Which command undoes it safely?

  7. Now do it — in your live terminal

    Run the whole loop yourself on the sandbox to your right.

    Reading Git is one thing; it sticks when your fingers have done it. Run this loop in the terminal and check the output at each step against what you learned above. It's the same sequence the LFCS Git labs grade: commit, branch, merge, ignore, tag, push and revert.

    mkdir -p ~/practice && cd ~/practice && git init -b main && git config user.name "Your Name" && git config user.email you@example.internal
    A fresh repository with an identity. Confirm with ls -a that .git exists.
    cd ~/practice && echo 'port = 8080' > app.conf && git add app.conf && git commit -m "Add app config" && git log --oneline
    Your first commit. Note its short id; you'll see it again in the graph.
    cd ~/practice && git switch -c change-port && sed -i 's/8080/9090/' app.conf && git commit -am "Move to port 9090" && git switch main && git merge change-port
    Branch, commit, switch back and merge. Run git log --oneline --graph --all afterwards and find both labels.
    cd ~/practice && git revert --no-edit HEAD && cat app.conf && git log --oneline
    Undo the port change with a new commit. app.conf should say 8080 again, and the history should now have three commits.
Recap
  • Git stores snapshots (commits) in a timeline. You edit in the working tree, choose changes with git add, and record them with git commit; git status shows what is where.
  • A branch is a movable label on a commit, so branching is cheap. Merge into the branch you're on: switch to main, then git merge feature.
  • A committed .gitignore keeps build output and secrets out of history, but it only affects untracked files. A secret that was already committed must be rotated.
  • git push origin main sends commits, not tags. Push tags by name or with --follow-tags, and use annotated tags (-a) for releases.
  • Once commits are shared, undo with git revert (history grows). reset --hard plus force-push rewrites history everyone else already has.
Git commands at a glance
CommandWhat it does
git init -b mainTurn the current directory into a repository
git status / git diffWhat's changed, staged, or untracked / the line-by-line changes
git add FILE · git commit -m MSGStage a change · record the staged snapshot
git switch -c NAME · git merge NAMECreate and switch to a branch · merge it into the current branch
git log --oneline --graph --allDraw the history of every branch
git clone URL · git push origin mainCopy a repository · publish commits to it
git tag -a v1.0 -m MSG · git push origin v1.0Create an annotated release tag · publish it
git revert COMMITUndo a commit with a new inverse commit (safe on shared branches)