You will initialize a repository, stage and commit changes, branch, and push to a remote. By the end you will have a clean, recoverable history and understand the staging-area model that makes Git powerful.

Git is the version control system behind almost every professional workflow in security and IT: code, infrastructure definitions, documentation, and lab notes all live in repositories. Unlike older tools that tracked file-by-file revisions, Git records each commit as a snapshot of your files plus a pointer to its parent commit, which means you can answer three questions at any moment: what changed, who changed it, and why. That audit trail is exactly what you lean on during incident reviews and collaborative work.

This lab is deliberately hands-on. You will create a small project from nothing, build a short but real history on a feature branch, merge it back, and publish everything to GitHub. Each command below is run in a terminal, and each step includes the output you should expect so you can verify you are on track before moving on. Work through the steps in order; later steps assume the earlier ones succeeded.

By the end you will have done two things. First, the full collaboration loop: init → add → commit → branch → push → merge → pull, ending with two commits on main that exist both locally and on GitHub. Second, you will rehearse the two recovery moves that separate confident Git users from nervous ones: amending a commit that has not been pushed yet, and finding "lost" work again with the reflog. The project itself is trivial, three small text files, so you can focus entirely on the mechanics.

A few conventions used throughout: lines starting with $ are commands you type (your prompt may look different); everything under a command is the output you should expect, though commit hashes and byte counts will differ on your machine; and all commands assume a macOS or Linux shell, on Windows, run them inside Git Bash or WSL, which ship with Git and behave identically for everything in this lab.

Learning Objectives

  • Track changes with the staging area and meaningful commits.
  • Branch and switch context safely.
  • Push to and pull from a remote.
  • Inspect history with git log, git status, and git diff, and recover from a staged-file mistake.
  • Merge a feature branch back into the default branch and keep the local and remote copies in sync.
  • Amend the most recent commit before it is published, and locate "lost" commits again with the reflog.
  • Verify the whole setup end to end by cloning the finished repository into a fresh folder.
  • Time: ~2 hours · Difficulty: Beginner · Prereqs: Git installed and a GitHub account.

Architecture Overview

Everything in Git moves between four areas. Your working tree is the folder you edit files in. When you run git add, you copy selected changes into the staging area (also called the index), a draft of the next commit. git commit freezes that draft into a permanent snapshot inside the local repository, the hidden .git directory. Finally, git push copies commits from your local repository to the remote (here, GitHub), and git pull brings remote commits back down. The staging area is the piece beginners skip and then regret: it is what lets you commit only the changes that belong together, instead of whatever happens to be on disk.

Under the hood, each commit is a small object containing four things: a pointer to a snapshot of your files (logically a complete tree of file blobs for that commit; Git later compresses storage with packfile delta encoding, but the object model is snapshots, not diffs), the author and timestamp, your message, and, crucially, the hash of its parent commit. That parent pointer is what turns a pile of snapshots into an ordered history you can walk backwards through. It is also why commits are immutable: change even one byte of any commit and every descendant's hash changes, which is the entire basis of Git's integrity checking.

Two more pointers do the day-to-day work. A branch is nothing more than a movable sticky note pointing at a commit; creating one writes a roughly 40-byte file, which is why branches are considered free. HEAD is a second sticky note that says which commit your working tree currently reflects, normally it sits on a branch, and the branch moves forward as you commit. Nearly every Git command you will learn is just a controlled way of moving these pointers.

Why does the staging draft matter so much in practice? Because real edits are rarely tidy. Suppose you fix a typo in a config file and, in the same editing session, start a half-finished feature. Without staging you face an all-or-nothing commit mixing both. With staging you run git add config.yml (or even git add -p to stage individual hunks), commit just the typo fix, and leave the unfinished work out of history until it is ready. Small, focused commits make git log, code review, and incident archaeology dramatically easier.

Notice that the working tree has no direct arrow to the remote. Nothing reaches GitHub unless it first went through staging and became a commit. Keep that mental model and most confusing Git output starts to make sense.

How It Works

How It Works

Git stores snapshots, not diffs: every commit points to a tree object describing the full file state, plus parent pointers forming the history graph. The staging area (index) is a proposed next commit, which is why you can craft exactly what each commit contains.

The three areas (working tree, index, HEAD) are the mental model behind every command: add moves changes into the index, commit snapshots the index into history, and checkout/restore move things between areas. Most 'Git lost my work' stories are just confusion about which area holds what.

Remotes are just named URLs with branch pointers. Push sends your commits and updates the remote's pointer; pull fetches theirs and merges. Because history is a graph of hashes, Git can usually reconcile both sides automatically.

Environment Setup

Prerequisites

  • Git 2.23 or newer (git --version; git switch needs 2.23+)
  • A terminal and a scratch directory for the lab repository
  • A GitHub account and an empty remote repository for the push steps
  • git config --global user.name and user.email set before committing
  • Assumed skill: basic terminal commands

You will need: Git 2.4x and a remote (GitHub) repository.

First confirm Git is installed and check the version. Any 2.x release from the last few years works for everything in this lab; the git switch command used later requires Git 2.23 or newer.

CHECK GIT VERSION

git --version
$ git --version
git version 2.46.0

If the command is not found, install Git first: on macOS run xcode-select --install or brew install git; on Debian/Ubuntu run sudo apt install git. Then check whether any identity is already configured, this matters on shared or pre-imaged machines where someone else's settings may already exist:

CHECK EXISTING CONFIGURATION AND ITS SOURCE

git config --global --list --show-origin
$ git config --global --list --show-origin
file:/home/taki/.gitconfig user.name=Taki Sadik file:/home/taki/.gitconfig [email protected]

The --show-origin flag prefixes every setting with the file it came from, which is invaluable when a value surprises you: Git reads system config, then ~/.gitconfig (global), then the repository's own .git/config, and later entries win. If the output above shows a name and email you do not recognize, fix them with the two commands below before doing anything else.

Before you begin: set your identity with git config --global user.name/user.email. Git stamps this identity onto every commit you make. Use the same email as your GitHub account so GitHub attributes the commits to you.

CONFIGURE YOUR IDENTITY

git config --global user.name "Taki Sadik"
git config --global user.email "[email protected]"

Verify the configuration was saved before continuing, a typo here follows every commit you ever make:

$ git config --global --list
user.name=Taki Sadik [email protected]

Common mistake: writing -global with a single dash. Git silently treats it as an unknown option or splits it into short flags, and your identity is never set. Long options in Git always take two dashes. If git config --global --list does not show your name and email, go back and re-run the two commands above.

Optionally, set the default branch name to main so new repositories match GitHub's convention instead of the older master:

SET DEFAULT BRANCH NAME

git config --global init.defaultBranch main

One more optional but recommended setting: your editor. When you commit without -m or run an interactive rebase, Git opens this editor for the message. The default (vim) is fine if you know it; otherwise point Git at an editor you are comfortable with, for example VS Code:

SET YOUR COMMIT MESSAGE EDITOR

git config --global core.editor "code --wait"

Before moving on, sanity-check the whole setup with one command per setting. Each should echo the value you intended, if any line prints nothing, that setting was never saved:

VERIFY EVERY SETTING

git config --global user.name
git config --global user.email
git config --global init.defaultBranch
$ git config --global user.name
Taki Sadik
$ git config --global user.email
$ git config --global init.defaultBranch
main

Step-by-Step Execution

01
Initialize and make the first commit

The staging area lets you craft exactly what goes into each commit. Here you create a project folder, turn it into a repository, add two files, and commit them. git init creates the hidden .git directory that stores the entire history, delete it and the folder becomes plain files again, which makes experimenting risk-free.

git init && git add . && git commit -m "initial commit"

For this lab, break that one-liner into explicit steps so you can see each area change state. First create the project and a couple of files:

CREATE THE PROJECT

mkdir lab-notes && cd lab-notes
echo "# Lab Notes" > README.md
echo "passwords.txt" > .gitignore
git init
$ git init
Initialized empty Git repository in /home/taki/lab-notes/.git/

The .gitignore file above tells Git to never track passwords.txt, a small habit worth building early, because committing a secret is one of the classic mistakes covered in Troubleshooting. Now check the repository state:

INSPECT STATUS BEFORE STAGING

git status
$ git status
On branch main No commits yet Untracked files: (use "git add <file>..." to include in what will be committed) .gitignore README.md nothing added to commit but untracked files present (use "git add" to track)

"Untracked" means Git sees the files but has never been told to record them. Stage them, then commit. In this lab git add . stages everything in the folder, which is safe because you created exactly two files; in real projects prefer staging files explicitly (git add README.md config.yml) or interactively (git add -p), so an unrelated half-finished edit never sneaks into the commit:

STAGE THE FILES

git add .

Before committing, review the draft, this ten-second habit catches staging mistakes more reliably than any other:

REVIEW WHAT IS STAGED

git diff --cached --stat
$ git diff --cached --stat
.gitignore | 1 + README.md | 1 + 2 files changed, 2 insertions(+)

Only the two intended files appear. If anything else showed up here, unstage it with git restore --staged <file> before continuing. Now freeze the draft into history:

COMMIT

git commit -m "Initial commit: README and .gitignore"
$ git commit -m "Initial commit: README and .gitignore"
[main (root-commit) 3f9a1c2] Initial commit: README and .gitignore 2 files changed, 2 insertions(+) create mode 100644 .gitignore create mode 100644 README.md

The output confirms the commit hash (yours will differ), the branch, and exactly what was recorded. Read the message like a changelog entry: short imperative subject of about 50 characters ("Add", "Fix", "Remove", not "Added"), then specifics if needed. If you omit -m, Git opens your configured editor so you can write a subject plus a longer body; pressuring yourself into one-liners is not the goal, clarity for a future reader (often you, in six months) is.

Confirm the commit exists where you expect it:

CONFIRM THE FIRST COMMIT

git log --oneline
$ git log --oneline
3f9a1c2 (HEAD -> main) Initial commit: README and .gitignore

Exactly one commit, and HEAD -> main confirms you are on the default branch pointing at it.

How to verify: run git status again, it should report "nothing to commit, working tree clean". If it still lists untracked files, you staged before creating them; run git add . and commit again. Also confirm the ignore rule worked: touch passwords.txt && git status should show a clean tree with no mention of the file, if passwords.txt appears as untracked, your .gitignore has a typo. Remember that .gitignore only ignores files that are not already tracked, so the rule must exist before the file ever gets added.

What this teaches: The .git directory is the entire repository: init creates it, add stages changes, and commit snapshots them.

02
Branch for a change

A branch is a movable pointer to a commit. Working on a branch means your half-finished experiment never touches main, which should always stay in a state you could show someone. git switch -c creates the branch and moves you onto it in one command (the older spelling is git checkout -b).

git switch -c feature/login && echo "- Draft: login flow notes" >> login-notes.md && git add login-notes.md && git commit -m "Add draft login flow notes"
-a is deliberately absent: it never stages brand-new files, so the new file is staged explicitly.

In this lab the "feature" is a notes file, the mechanics are identical to real code. Create the branch first and confirm where you are:

CREATE AND SWITCH TO A FEATURE BRANCH

git switch -c feature/login
git branch --show-current
$ git switch -c feature/login
Switched to a new branch 'feature/login'
$ git branch --show-current
feature/login

Now make the change and commit it. The -a flag stages modifications to files Git already tracks, so you can skip a separate git add, but note it does not pick up brand-new files, which is why the file below is created before the commit, and staged explicitly first:

MAKE A CHANGE ON THE BRANCH

echo "- Draft: login flow notes" >> login-notes.md
git add login-notes.md
git commit -m "Add draft login flow notes"
$ git commit -m "Add draft login flow notes"
[feature/login 7c2e4b8] Add draft login flow notes 1 file changed, 1 insertion(+) create mode 100644 login-notes.md

Note the branch name in the output brackets, the commit landed on feature/login, not main. See both pointers at once with:

LIST ALL BRANCHES WITH THEIR TIPS

git branch -vv
$ git branch -vv
* feature/login 7c2e4b8 Add draft login flow notes main 3f9a1c2 Initial commit: README and .gitignore

The * marks the branch you are on; each line shows the branch's tip commit. As for naming: short, lowercase, hyphenated, and prefixed by intent, feature/..., fix/..., docs/..., so a glance at git branch reads like a to-do list. Avoid spaces (they complicate every command that takes a branch name) and avoid names like final or new, which say nothing after a week.

Prove the isolation is real: switch back to main and the new file disappears from the working tree, because that commit only exists on feature/login.

SWITCH BACK AND CHECK

git switch main
ls
$ git switch main
Switched to branch 'main'
$ ls
README.md

Switch back to the feature branch before continuing. The handy shortcut git switch - jumps to whichever branch you were on last, which saves real typing when you are toggling between two branches:

git switch feature/login
ls
$ ls
README.md login-notes.md

The file is back the instant you return, nothing was copied or restored from a backup; the file's presence simply reflects which commit HEAD points at. That disappearing-and-reappearing act is the whole point of branching: the experiment exists in parallel without touching main.

Common mistake: making commits on main because you forgot to create the branch first. Get in the habit of running git branch --show-current (or glancing at your shell prompt) before every commit. If you do slip, create the branch now, it points at the commit you just made, then reset main back one commit.

What this teaches: Branches are movable pointers to commits, so switching context is instant and safe.

03
Connect a remote and push

A remote is a named pointer to a copy of the repository on another server. The conventional name is origin. Create an empty repository on GitHub first (do not initialize it with a README, an existing commit there would create a divergent history and your first push would be rejected), then link it and push your branch.

The GitHub side takes about a minute: sign in, click the + menu → New repository, set the name to app, choose Public or Private, and leave every checkbox (Add a README, .gitignore, license) unticked. Click Create repository. GitHub then shows a "Quick setup" page with your remote URL in two flavors: an SSH URL ([email protected]:tyfsadik/app.git) and an HTTPS URL (https://github.com/tyfsadik/app.git). This lab uses SSH because it authenticates with a key and never asks for a password; HTTPS works identically for every Git command and simply prompts for credentials instead, you can switch flavors later with git remote set-url without redoing anything else.

$ git remote add origin [email protected]:tyfsadik/app.git && git push -u origin feature/login
branch 'feature/login' set up to track 'origin/feature/login'.

The -u (short for --set-upstream) flag links your local branch to the remote one so that future git push and git pull need no arguments. You only do it once per branch. A full push transcript looks like this:

$ git push -u origin feature/login
Enumerating objects: 7, done. Counting objects: 100% (7/7), done. Delta compression using up to 8 threads Compressing objects: 100% (4/4), done. Writing objects: 100% (7/7), 602 bytes | 602.00 KiB/s, done. Total 7 (delta 0), reused 0 (delta 0), pack-reused 0 To github.com:tyfsadik/app.git * [new branch] feature/login -> feature/login branch 'feature/login' set up to track 'origin/feature/login'.

Confirm the remote is registered correctly:

VERIFY REMOTE CONFIGURATION

git remote -v
$ git remote -v
origin [email protected]:tyfsadik/app.git (fetch) origin [email protected]:tyfsadik/app.git (push)

Two lines appear because fetch and push can technically point at different URLs; in practice they are always the same. Now confirm the upstream link that -u created, the branch.<name>.remote and branch.<name>.merge entries it writes into .git/config are the entire magic that makes a bare git push work afterwards:

CONFIRM THE UPSTREAM TRACKING LINK

git branch -vv
$ git branch -vv
* feature/login 7c2e4b8 [origin/feature/login] Add draft login flow notes main 3f9a1c2 Initial commit: README and .gitignore

The bracketed [origin/feature/login] next to your current branch is the upstream: your local branch knows its remote counterpart, so plain git push, git pull, and status messages like "up to date with 'origin/feature/login'" all work with no arguments. Without it, Git prints "no upstream branch" and makes you type the remote and branch name every time.

How to verify: the branch should also appear on the GitHub web page for the repository. If the push fails with a permission error, your SSH key is not set up, see Troubleshooting. Also push main while you are here, so both branches exist remotely:

git push -u origin main

What this teaches: Push publishes local commits; the remote becomes the shared source of truth for collaborators.

04
Inspect history and recover from a staged-file mistake

Before merging, learn the two inspection commands you will use daily. git log shows the commit chain; git diff shows line-level changes. Together they answer "what happened" and "what is about to happen", run them before every commit and every merge until it becomes reflex.

READ THE HISTORY

git log --oneline --graph --all
$ git log --oneline --graph --all
* 7c2e4b8 (HEAD -> feature/login, origin/feature/login) Add draft login flow notes * 3f9a1c2 (main) Initial commit: README and .gitignore

The graph shows both commits in a straight line because main has not moved since you branched. HEAD marks which commit your working tree is based on.

git diff has three everyday forms worth memorizing, because they answer three different questions. git diff alone shows changes in the working tree that are not yet staged; git diff --cached shows what is staged and waiting to be committed; and git diff main feature/login compares two commits, here, everything the feature branch would add to main. Right now the first two are empty and the third shows your one new line. For the contents of a single commit, git show 7c2e4b8 prints its message plus its full patch, the fastest way to answer "what exactly did that commit do?"

Now rehearse the most common recovery: you staged something that should not be committed. Stage a stray file, then unstage it without losing the file itself:

UNSTAGE WITHOUT LOSING WORK

echo "scratch" > scratch.tmp
git add scratch.tmp
git restore --staged scratch.tmp
git status --short
$ git status --short
?? scratch.tmp

Prove the staging area is empty again before taking the transcript at its word, git diff --cached should print nothing at all:

git diff --cached --stat

The ?? means the file is untracked again, back out of staging, but still on disk. (git restore --staged is the modern spelling; older tutorials use git reset HEAD <file>, which does the same thing.) Delete the scratch file when done. Warning: plain git restore <file> without --staged discards uncommitted changes in the working tree permanently, that is the destructive twin of this command. If you are ever unsure which of the two forms you are about to run, run git status first and read its hints: the "Changes to be committed" section tells you exactly what to unstage, and its parenthetical suggestions literally spell out the recovery command for your situation.

What this teaches: git status and git diff reveal staging mistakes before they become history; restore --staged undoes the staging.

05
Merge the feature branch back into main

Branching is only half the story; the change has to come home. Switch to main and merge. Because main never moved, Git performs a fast-forward: it simply slides the main pointer forward to the feature commit. No merge commit, no conflict.

First, one last review. The three-dot form git diff main...feature/login (note the three dots) shows only what the feature branch introduces since it diverged, the same set of changes the merge is about to bring into main:

PRE-MERGE REVIEW

git diff main...feature/login --stat
$ git diff main...feature/login --stat
login-notes.md | 1 + 1 file changed, 1 insertion(+)

One file, one line, exactly what you intended, and nothing from anyone else's work. If this listing ever surprises you, stop and investigate before merging; after a fast-forward there is no merge commit to mark the event, so the pre-merge check is your audit trail.

MERGE INTO MAIN

git switch main
git merge feature/login
$ git merge feature/login
Updating 3f9a1c2..7c2e4b8 Fast-forward login-notes.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 login-notes.md

With the merge done, the feature branch has served its purpose. Delete it locally and on the remote to keep the branch list clean, deleting a branch deletes only the pointer, never the commits, because they are now reachable from main:

CLEAN UP THE MERGED BRANCH

git branch -d feature/login
git push origin --delete feature/login
git push

git branch -d (lowercase) refuses to delete a branch that is not fully merged, a built-in safety check. The uppercase -D forces deletion and will silently discard unmerged work, so reach for it only when you are certain.

One loose end remains: your local list no longer shows the branch, but the remote-tracking copy (origin/feature/login) lingers in git branch -a output until Git prunes it. Fetch with --prune to tidy it immediately rather than waiting for a later fetch to do it by accident:

PRUNE STALE REMOTE BRANCHES

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

Only main remains, locally and remotely, the feature branch is gone everywhere, while its commit lives on as part of main's history.

How to verify: ls should now show login-notes.md on main, and git log --oneline on main should include the "Add draft login flow notes" commit. If the merge instead reported a conflict, both branches changed the same lines, that scenario has its own lab, Merge Conflicts.

What this teaches: Merging joins two lines of history; here main had not diverged, so Git fast-forwarded and no merge commit was created. A merge commit appears only when both sides have new commits, or when you force one with --no-ff.

06
Pull to sync with the remote

Collaboration means the remote moves without you: a teammate pushes, or you edit a file in the GitHub web UI. git pull fetches those commits and merges them into your current branch. Simulate it by fetching first, git fetch downloads remote state without touching your working tree, which makes it the safe way to look before you leap:

FETCH, INSPECT, THEN PULL

git fetch origin
git status
git pull
$ git fetch origin
$ git status
On branch main Your branch is up to date with 'origin/main'. nothing to commit, working tree clean
$ git pull
Already up to date.

Nothing happened, and that is the lesson: git fetch downloaded the remote's state into your local copy (origin/main moved or confirmed) but touched neither your working tree nor your branch. It is a purely read-only look. git pull is simply git fetch followed by git merge into your current branch, "Already up to date" means the merge half had nothing to do.

Now make the remote actually move. On the GitHub web page for your repository, click README.md, then the pencil (Edit) icon, add a line such as Status: in progress, and press Commit changes with the message "Add status line to README". That commit exists on GitHub but not on your laptop, precisely the situation a teammate's push would create.

FETCH AGAIN AND READ THE DRIFT

git fetch origin
git status
$ git fetch origin
remote: Enumerating objects: 5, done. From github.com:tyfsadik/app 7c2e4b8..a1b2c3d main -> origin/main
$ git status
On branch main Your branch is behind 'origin/main' by 1 commit, and can be fast-forwarded. (use "git pull" to update your local branch) nothing to commit, working tree clean

The fetch output shows origin/main advancing from 7c2e4b8 to a1b2c3d (hashes abbreviated), and status translates that into English: behind by 1, safe to fast-forward. Your working tree is untouched, you could keep working, or even decide to handle it later. When ready, pull:

git pull
$ git pull
Updating 7c2e4b8..a1b2c3d Fast-forward README.md | 1 + 1 file changed, 1 insertion(+)

The README on disk now contains the status line, and git status reports "up to date with 'origin/main'" again. No editor opened, no conflict appeared, because your local main had no commits of its own, the merge was a pure fast-forward. The day it is not, you are in merge-conflict territory, which has its own lab.

Make git pull the first command of every work session; merging a small drift daily is painless, merging a month of drift is not. Some teams prefer git pull --rebase, which replays your unpushed commits on top of the fetched ones to keep history linear; it rewrites your local commits, so agree on it with your team before adopting it. The fetch-first discipline above applies either way.

Common mistake: pushing before pulling. If a teammate pushed first, your push is rejected as "non-fast-forward". The fix is simply git pull (resolve any conflicts if they appear), then push again, covered in Troubleshooting.

What this teaches: Pull fetches and merges, keeping local history aligned with the remote and collaborators.

07
Amend the last commit before it is published

Two seconds after committing, you notice a typo in the message, or that a file never got staged. git commit --amend replaces the tip commit with a new one carrying the fix. Why this is safe here but dangerous later: amending does not edit the old commit, it creates a brand-new commit hash and abandons the old one. Unpushed, no one else has seen the old hash, so replacing it costs nothing. Once the commit is pushed, teammates may have built on it, and replacing it rewrites shared history, which is why the one rule of amending is only amend commits that have not left your machine.

git add login-notes.md && git commit --amend --no-edit

Rehearse it with a deliberately mistyped message. Add another line and commit it, typo and all:

COMMIT WITH A TYPO (ON PURPOSE)

echo "- TODO: MFA notes" >> login-notes.md
git add login-notes.md
git commit -m "Add draft login flow ntoes"
$ git commit -m "Add draft login flow ntoes"
[main 9f3d21a] Add draft login flow ntoes 1 file changed, 1 insertion(+)

Now fix the message. Without staged changes, --amend rewrites only the message:

AMEND THE MESSAGE

git commit --amend -m "Add draft login flow notes"
$ git commit --amend -m "Add draft login flow notes"
[main 4e8c1a2] Add draft login flow notes Date: Sun Sep 21 14:32:07 2026 -0400 1 file changed, 1 insertion(+)

Compare the two outputs: the message is fixed, but the hash changed from 9f3d21a to 4e8c1a2. That hash change is the rewrite, the typo'd commit no longer exists anywhere, which is exactly why amending a pushed commit breaks everyone who pulled it. (If you want to fold a forgotten file into the commit instead, stage it and run git commit --amend --no-edit; the --no-edit keeps the existing message.)

VERIFY THE AMEND

git log --oneline -n 2
$ git log --oneline -n 2
4e8c1a2 (HEAD -> main) Add draft login flow notes a1b2c3d Add status line to README

One commit per change, correct message, no trace of the typo.

Common mistake: amending a commit that was already pushed, then force-pushing to make it accepted. On any branch someone else might use, resist, use a follow-up commit instead. If it truly is your own branch and you must, git push --force-with-lease is the only acceptable force: it refuses to overwrite if the remote has moved since your last fetch, which plain --force does not check.

What this teaches: Amend rewrites only unpublished history; once pushed, history is shared and must not be rewritten.

08
Recover "lost" work with the reflog

The reflog is Git's flight recorder: a local journal that records every movement of HEAD, commits, resets, pulls, branch switches, whether or not a branch still points at the commits involved. Why it matters: most "I lost my work" moments in Git are actually "no branch points at my work" moments, and the reflog almost always knows where it went. This is the skill that turns Git from an anxious tool into a confident one.

First, look at the journal. The newest entry is HEAD@{0}:

READ THE FLIGHT RECORDER

git reflog
$ git reflog
4e8c1a2 (HEAD -> main) HEAD@{0}: commit (amend): Add draft login flow notes 9f3d21a HEAD@{1}: commit: Add draft login flow ntoes a1b2c3d HEAD@{2}: pull: Fast-forward 7c2e4b8 HEAD@{3}: merge feature/login: Fast-forward 3f9a1c2 HEAD@{4}: commit (initial): Initial commit: README and .gitignore

Every event from this lab is there, including the abandoned typo'd commit 9f3d21a, which no branch references yet is still retrievable. Now simulate the classic accident: a reset --hard that appears to destroy your last commit. (A reset moves the branch pointer backward; --hard also rewrites the working tree to match. It never deletes commits, it just stops pointing at them.)

SIMULATE LOSING THE LAST COMMIT

git reset --hard HEAD~1
git log --oneline -n 1
$ git reset --hard HEAD~1
HEAD is now at a1b2c3d Add status line to README
$ git log --oneline -n 1
a1b2c3d (HEAD -> main) Add status line to README

The MFA-notes commit is gone from log, log only walks reachable history, and nothing points at 4e8c1a2 anymore. But the reflog saw the reset happen and still lists the commit. Find the entry for it and restore it by resetting back to that hash:

RESCUE THE COMMIT VIA REFLOG

git reflog -n 3
git reset --hard 4e8c1a2
git log --oneline -n 2
$ git reflog -n 3
a1b2c3d (HEAD -> main) HEAD@{0}: reset: moving to HEAD~1 4e8c1a2 HEAD@{1}: commit (amend): Add draft login flow notes 9f3d21a HEAD@{2}: commit: Add draft login flow ntoes
$ git reset --hard 4e8c1a2
HEAD is now at 4e8c1a2 Add draft login flow notes

git log --oneline -n 2 once again shows 4e8c1a2 at the tip, the commit was never lost, only orphaned, and the reflog remembered it. For a gentler rescue that keeps your current branch untouched, you can instead hang the commit on a new branch: git switch -c rescue 4e8c1a2, then merge or cherry-pick from there.

Common mistake: assuming the reflog follows you to other machines. It is strictly local, a fresh clone has a nearly empty one, so recovery via reflog works only on the machine where the work happened (entries also expire after roughly 90 days by default). Also, do not confuse "deleted the branch" with "deleted the commits": git branch -D removes a pointer; the commits survive until garbage collection, findable exactly this way.

What this teaches: The reflog records every HEAD movement, so 'lost' commits stay recoverable for weeks.

Progress So Far

Steps 01 through 03 are the core loop every Git user must be able to do from memory; steps 04 through 06 extend it into a complete daily workflow, inspect, merge, clean up, sync; and steps 07 through 08 bolt on the safety net: amend what you have not published, and recover what you thought was gone. The diagram tracks the core loop:

Testing & Validation

Run the two health-check commands below. They are the same pair you will run a dozen times a day in real work, and together they answer "where am I?" and "is anything unsaved?"

git log --oneline --graph --all && git status
$ git log --oneline --graph --all
* 4e8c1a2 (HEAD -> main) Add draft login flow notes * a1b2c3d (origin/main) Add status line to README * 7c2e4b8 Add draft login flow notes * 3f9a1c2 Initial commit: README and .gitignore
$ git status
On branch main Your branch is ahead of 'origin/main' by 1 commit. (use "git push" to publish your local commits) nothing to commit, working tree clean

You should see your commits on the branch and a clean working tree. The amended commit from step 07 has not been pushed yet, so main sits one commit ahead of origin/main, "ahead by 1" is a healthy, expected message, not an error. Sync it and check three things specifically:

git push
$ git push
To github.com:tyfsadik/app.git a1b2c3d..4e8c1a2 main -> main
$ git status
On branch main Your branch is up to date with 'origin/main'. nothing to commit, working tree clean
  • HEAD -> main and origin/main now point at the same commit, local and remote are in sync.
  • All four commits appear in the log, in order, nothing was lost in the merge, and the amend left no duplicate behind.
  • The working tree is clean and neither the feature branch nor the "lost" commit appears anywhere, cleanup and rescue both succeeded.

Final cross-check, and the strongest proof in this lab: verify the remote with a completely fresh clone, exactly as a new teammate would receive it. Clone into a separate folder and compare:

CLONE AS A NEW TEAMMATE WOULD

cd ..
git clone [email protected]:tyfsadik/app.git app-verify
cd app-verify
git log --oneline
ls
$ git clone [email protected]:tyfsadik/app.git app-verify
Cloning into 'app-verify'... remote: Enumerating objects: 15, done. Receiving objects: 100% (15/15), done.
$ git log --oneline
4e8c1a2 (HEAD -> main, origin/main) Add draft login flow notes a1b2c3d Add status line to README 7c2e4b8 Add draft login flow notes 3f9a1c2 Initial commit: README and .gitignore

The fresh clone's history is byte-for-byte your history: same four commits, same order, same messages, plus all three files on disk (README.md containing the status line, .gitignore, and login-notes.md with both notes lines). If the clone ever disagrees with your original repository, the one that did not push most recently is behind, run git push or git pull accordingly and re-check. When you are satisfied, delete the verification folder: cd .. && rm -rf app-verify.

Troubleshooting
  • Author unknown: the symptom is a commit that fails or misattributes with Please tell me who you are / fatal: unable to auto-detect email address. Fix it before committing: set user.name/user.email as in Environment Setup, then verify with git config --global --list. If you already made commits with a wrong identity, fix the config first, then repair the most recent commit with git commit --amend --reset-author --no-edit. For several older commits, use git rebase -i --root and mark them edit, running git commit --amend --reset-author --no-edit on each, but only if they have not been pushed.
  • Push rejected: the symptom looks like ! [rejected] main -> main (non-fast-forward) followed by error: failed to push some refs. It means the remote has commits you lack, almost always a teammate (or a web-UI edit) pushed first. Do not force anything. Run git pull; if Git stops with a conflict, resolve the marked files, git add them, and git commit to finish the merge; then git push again. Repeating this loop a few times a day is normal collaboration. The only acceptable force is git push --force-with-lease, and only on a branch you own, never on a shared branch, where plain --force can silently destroy a colleague's commits.
  • Committed a secret: the fix has two mandatory halves. First, rotate the secret immediately (change the password, revoke the key), assume it was seen the moment it was pushed. Second, remove it from history, because deleting the file in a new commit is not enough: the old commit still contains it and GitHub keeps it reachable. Install git-filter-repo (for example pipx install git-filter-repo), then run git filter-repo --path passwords.txt --invert-paths --force to excise the file from every commit, and push with --force-with-lease. Expect every collaborator to re-clone or hard-reset afterwards, coordinate first. Prevention beats all of this: keep secrets out of the working tree (as step 01's .gitignore habit does) and consider a secret-scanning pre-commit hook.
  • Permission denied (publickey) on push: the symptom is [email protected]: Permission denied (publickey). fatal: Could not read from remote repository, GitHub cannot authenticate your SSH key. Diagnose in order: run ssh -T [email protected]; success greets you (Hi tyfsadik! You've successfully authenticated...), failure confirms the key is missing from the agent or GitHub. Check which keys are loaded with ssh-add -l, generate one if needed (ssh-keygen -t ed25519 -C "[email protected]"), print the public half with cat ~/.ssh/id_ed25519.pub, and paste it under GitHub → Settings → SSH and GPG keys. If you would rather not deal with keys at all, switch the remote to HTTPS and let the credential helper handle login: git remote set-url origin https://github.com/tyfsadik/app.git.
  • Detached HEAD: the symptom is the banner You are in 'detached HEAD' state, you checked out a commit hash (or a tag) instead of a branch, so any commit you make here belongs to no branch and would be orphaned when you switch away. Recover by nailing your work to a branch where you stand: git switch -c rescue, then merge or cherry-pick it back onto your real branch. If you have made no commits and simply want to go back, git switch main is enough. And if you already switched away and "lost" the commits, they are still in the reflog (step 08), find the hash and reattach it. Nothing is permanently lost until garbage collection runs.

Troubleshooting

SymptomLikely causeFix
commit fails: please tell me who you areuser.name/user.email were never configuredset both with git config --global, then commit again
nothing to commit, working tree clean (but changes expected)the files are ignored by .gitignore or already committedcheck git status and git check-ignore -v on the file
push rejected: non-fast-forwardthe remote has commits you do not havegit pull --rebase to replay your work on top, then push
detached HEAD stateyou checked out a commit hash instead of a branchcreate a branch where you are, or switch back to a branch
accidentally committed to mainworked on the default branch out of habitcreate a feature branch at HEAD, then reset main back to the remote

Extension Ideas

  • Adopt a workflow in Branching & Merging.
  • Collaborate via Pull Requests.
  • Automate checks with GitHub Actions.
  • Repeat this lab with a second clone in another folder to simulate a teammate and force a real non-fast-forward push rejection.
  • Practice git stash: shelve half-finished work, switch branches to fix something urgent, then pop the stash and resume.
  • Install a secret-scanning pre-commit hook such as gitleaks, then try to commit a fake API key and watch it get blocked, the cheapest insurance against the costliest mistake.
  • Try git worktree add ../lab-notes-hotfix main to check out a second branch in a separate folder, so you can compare two branches side by side without stashing or cloning again.

Practice Challenges

Challenge 1: Bisect a bug

Goal: Introduce a bug across five commits, then use git bisect to find the culprit commit automatically.

Hint: Mark a known-good and known-bad commit; bisect binary-searches the rest.

Challenge 2: Clean history rewrite

Goal: On a private branch, squash three messy commits into one clear commit with an imperative message.

Hint: Use git rebase -i and mark the later commits as squash or fixup.

Key Results

  • Built a clean, recoverable commit history.
  • Used branches to isolate a change from main.
  • Pushed work to a tracked remote branch.
  • Verified state with log and status.
  • Rehearsed the two recovery moves: amending an unpublished commit and rescuing orphaned commits via the reflog.
  • Validated the remote end to end with a fresh clone, and re-synced local and remote after the amend.

Key Takeaways

  • Changes move in one direction: working tree → staging → local repo → remote. Nothing reaches the remote without a commit.
  • The staging area is a draft, not a formality: it is how you keep each commit focused on one logical change.
  • Branches are cheap pointers; create one per task and delete it after merging.
  • git status and git log --oneline --graph --all answer almost every "what is going on?" question: run them constantly.
  • Pull before you push, and never commit secrets: history is forever, so prevention beats cleanup.
  • git fetch is read-only, git pull mutates: fetch first whenever you are unsure what the remote has.
  • Amending and force-pushing rewrite history; safe on your own unpushed work, unacceptable on shared branches (--force-with-lease at most).
  • The reflog is your safety net: "lost" work is usually just orphaned, and recovery starts with git reflog.