Git Fundamentals: Build a Clean, Recoverable History
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, andgit 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.23or newer (git --version;git switchneeds 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.nameanduser.emailset 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
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
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:
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
Step-by-Step Execution
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
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
"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
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"
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
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.
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"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
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"
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
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
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
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.
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.
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:
Confirm the remote is registered correctly:
VERIFY REMOTE CONFIGURATION
git remote -v
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
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.
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
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
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.
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
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
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
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.
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
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
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
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.
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-editRehearse 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"
Now fix the message. Without staged changes, --amend rewrites only the message:
AMEND THE MESSAGE
git commit --amend -m "Add draft login flow notes"
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
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.
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
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
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 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 statusYou 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
HEAD -> mainandorigin/mainnow 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
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: setuser.name/user.emailas in Environment Setup, then verify withgit config --global --list. If you already made commits with a wrong identity, fix the config first, then repair the most recent commit withgit commit --amend --reset-author --no-edit. For several older commits, usegit rebase -i --rootand mark themedit, runninggit commit --amend --reset-author --no-editon each, but only if they have not been pushed. - Push rejected: the symptom looks like
! [rejected] main -> main (non-fast-forward)followed byerror: 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. Rungit pull; if Git stops with a conflict, resolve the marked files,git addthem, andgit committo finish the merge; thengit pushagain. Repeating this loop a few times a day is normal collaboration. The only acceptable force isgit push --force-with-lease, and only on a branch you own, never on a shared branch, where plain--forcecan 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 examplepipx install git-filter-repo), then rungit filter-repo --path passwords.txt --invert-paths --forceto 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.gitignorehabit 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: runssh -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 withssh-add -l, generate one if needed (ssh-keygen -t ed25519 -C "[email protected]"), print the public half withcat ~/.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 mainis 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
| Symptom | Likely cause | Fix |
|---|---|---|
| commit fails: please tell me who you are | user.name/user.email were never configured | set 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 committed | check git status and git check-ignore -v on the file |
| push rejected: non-fast-forward | the remote has commits you do not have | git pull --rebase to replay your work on top, then push |
| detached HEAD state | you checked out a commit hash instead of a branch | create a branch where you are, or switch back to a branch |
| accidentally committed to main | worked on the default branch out of habit | create 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 mainto 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 statusandgit log --oneline --graph --allanswer 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 fetchis read-only,git pullmutates: 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-leaseat most). - The reflog is your safety net: "lost" work is usually just orphaned, and recovery starts with
git reflog.