01

Mental modelhow the pieces fit together

Git is a content-addressable filesystem with a VCS UI bolted on top. Every command becomes intuitive once you internalize three trees, four object types, and one immutable history graph.

The three trees

Almost every Git command moves data between these three locations. Understanding which is which removes 90% of beginner confusion.

Tree 1 / Local
Working Directory
Files on disk you edit in your editor. Untracked, modified, or matching the index.
Tree 2 / Local
Staging Area Β· Index
A proposed next snapshot. Lives in .git/index. git add writes to it; git commit seals it.
Tree 3 / Local
Repository Β· HEAD
The committed object database under .git/objects. Immutable. HEAD points at the current branch tip.

The four object types

blobFile contents. No name, no metadata. Addressed by SHA-1/SHA-256 of contents + header.
treeDirectory listing β€” points to blobs and other trees with names and modes.
commitSnapshot pointer to one tree, plus author, committer, message, and parent commit(s).
tagAnnotated tag: a named pointer to any object with its own message and signature.

Branches and lightweight tags are not objects β€” they are refs: tiny text files in .git/refs/ containing a single SHA. Cheap to create, cheap to delete.

The agentic Git loop

  1. Modify files in the working directory.
  2. Stage a logical chunk with git add.
  3. Commit the snapshot to the repository.
  4. Branch, merge, rebase to organize history.
  5. Push to a remote so others can pull.

Git vs. GitHub

Git is the engine β€” runs locally, no network needed, written by Linus Torvalds in 2005. GitHub is a hosting service built around Git that adds pull requests, issues, code review, and CI. You can use Git without GitHub. You cannot use GitHub without Git.

02

Install & identityfirst ten minutes on a new machine

Pick the right installer for your OS, set your identity once globally, and configure a sane default editor and branch name. Skipping these steps now means fixing wrong-author commits later.

macOS / Linux / Windows

# macOS β€” Homebrew (recommended)
brew install git
brew upgrade git

# macOS β€” built-in via Xcode CLT
xcode-select --install

# Linux β€” Debian/Ubuntu
sudo apt update && sudo apt install git

# Linux β€” Fedora
sudo dnf install git

# Linux β€” Arch
sudo pacman -S git

# Windows β€” winget
winget install --id Git.Git -e

# Windows β€” Chocolatey / Scoop
choco install git
scoop install git

# Verify
git --version

Optional β€” GitHub CLI

# macOS
brew install gh

# Windows
winget install --id GitHub.cli

# Linux β€” Debian/Ubuntu
sudo apt install gh

# First-time auth (HTTPS, browser flow)
gh auth login

One-time identity setup

# Set globally β€” applies to all repos
git config --global user.name "Your Name"
git config --global user.email "you@example.com"

# Verify
git config --global --list

# Per-repo override (work vs. personal)
cd /path/to/work-repo
git config user.email "you@company.com"

Sane defaults worth setting once

# Use 'main' for new repos (matches GitHub default)
git config --global init.defaultBranch main

# Default editor
git config --global core.editor "code --wait"     # VS Code
git config --global core.editor "vim"             # Vim
git config --global core.editor "nano"            # Nano

# Pull = fast-forward only (avoid noisy merge commits)
git config --global pull.ff only

# Auto-prune deleted remote branches on fetch
git config --global fetch.prune true

# Color in terminal output
git config --global color.ui auto

# Better diffs (zdiff3 shows base + ours + theirs)
git config --global merge.conflictStyle zdiff3

# Reuse recorded conflict resolutions
git config --global rerere.enabled true
Pro tip Run git config --global --edit to open ~/.gitconfig directly. Easier than typing git config --global x.y z for every setting.

SSH keys for GitHub

# Generate ed25519 key (modern; preferred over RSA)
ssh-keygen -t ed25519 -C "you@example.com"

# Start agent and add key
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519

# Copy public key to clipboard
pbcopy < ~/.ssh/id_ed25519.pub        # macOS
xclip -sel clip < ~/.ssh/id_ed25519.pub  # Linux
cat ~/.ssh/id_ed25519.pub | clip       # Windows

# Add to GitHub via CLI (no browser needed)
gh ssh-key add ~/.ssh/id_ed25519.pub --title "laptop"

# Test the connection
ssh -T git@github.com
03

Repo essentialscreating, cloning, and the .git folder

Three ways to land in a repo: init a new one, clone someone else's, or cd into a folder that already has a .git. Every other command fans out from there.

Create or clone

# Initialize an empty repo in current directory
git init

# Initialize with a specific default branch
git init --initial-branch=main
git init -b main

# Clone over HTTPS
git clone https://github.com/owner/repo.git

# Clone over SSH (preferred for write access)
git clone git@github.com:owner/repo.git

# Clone into a custom directory name
git clone <url> my-folder

# Shallow clone β€” fetch only last N commits (faster)
git clone --depth 1 <url>

# Single-branch shallow (CI-friendly)
git clone --depth 1 --single-branch --branch main <url>

# Partial clone β€” defer blob downloads
git clone --filter=blob:none <url>

# Clone with submodules in one step
git clone --recurse-submodules <url>

Repo health checks

# Where is the repo root? (works from any subdir)
git rev-parse --show-toplevel

# Are we in a repo at all?
git rev-parse --is-inside-work-tree

# Working tree status
git status              # full
git status -s           # short / porcelain
git status -sb          # short + branch info

# Verify object database integrity
git fsck --full

# Pack and prune unreachable objects
git gc                   # normal
git gc --aggressive      # slower, deeper packing

# Repo size on disk
git count-objects -vH

What's inside .git/

HEADPointer to the current branch ref
configPer-repo configuration
indexThe staging area
objects/All blobs, trees, commits, tags
refs/heads/Local branch pointers
refs/remotes/Remote-tracking branches
refs/tags/Tag pointers
logs/Reflog history
hooks/Sample hook scripts
Never edit .git/ by hand The contents are managed by Git plumbing commands. Manual edits can corrupt the repo. Use git update-ref, git symbolic-ref, and git config instead.

The .gitignore file

# Logs & runtime
*.log
*.pid

# Dependencies
node_modules/
vendor/
.venv/

# Build output
dist/
build/
*.exe

# OS noise
.DS_Store
Thumbs.db

# Editor / IDE
.vscode/
.idea/
*.swp

# Secrets β€” NEVER commit these
.env
.env.local
*.key
*.pem

Already-tracked files are not retroactively ignored. To ignore something already committed:

# Stop tracking but keep the file on disk
git rm --cached path/to/file
git commit -m "stop tracking file"

For machine-local ignores that shouldn't be shared with the team, edit .git/info/exclude instead of .gitignore.

04

Staging & committingcrafting atomic, intentional snapshots

A commit should be one logical change. Stage with surgical care; commit messages outlive the code.

Staging files

git add <path>Stage a specific file or directory
git add .Stage everything in the current directory and below
git add -AStage all changes in the entire working tree (incl. deletions)
git add -uStage modifications + deletions of tracked files only
git add -pPatch mode β€” interactively pick hunks to stage
git add -iFull interactive staging menu
git add -N <path>Track an empty/new file without staging contents (intent-to-add)

Committing

git commitOpen editor for a commit message
git commit -m "msg"Inline commit message
git commit -m "subject" -m "body"Subject + body (two paragraphs)
git commit -a -m "msg"Stage all tracked changes and commit in one step
git commit --amendReplace last commit (message + new staged changes)
git commit --amend --no-editRe-commit without changing the message (e.g. add forgotten file)
git commit --fixup <sha>Auto-targeted fix commit for later rebase --autosquash
git commit --squash <sha>Same as --fixup but keeps the message editable
git commit --allow-emptyEmpty commit (useful as a CI trigger)
git commit -S -m "msg"GPG/SSH-signed commit

Patch-mode workflow Pro

# Step into hunks one at a time
git add -p

# Inside the prompt:
#   y - stage this hunk        n - skip it
#   s - split into smaller     e - edit hunk by hand
#   q - quit                   ? - help

Patch mode is how you turn a messy day's worth of changes into a clean sequence of atomic commits without abandoning work.

Conventional Commits β€” recommended message format

type(scope)!: subject

[optional body]

[optional footer(s)]
featNew user-facing feature
fixBug fix
docsDocumentation only
styleFormatting, no code change
refactorCode change that neither fixes a bug nor adds a feature
perfPerformance improvement
testAdding or fixing tests
buildBuild system / dependency change
ciCI configuration change
choreTooling / housekeeping
revertReverts a previous commit

Append ! after the type/scope to flag a breaking change: feat(api)!: drop /v1 endpoints. The Conventional Commits spec drives changelog generators (commitizen, standard-version, release-please) and semver automation.

Commit message β€” the seven rules

Atomic commits A commit should answer one question: "what does this change do?" If the answer needs an "and", split it. Atomic commits make git bisect, git revert, and code review dramatically more useful.
05

History & inspectionreading the story your repo tells

git log alone is configurable enough to replace half of what most people use GUIs for. Learn five flags and you'll never need a separate tool.

The essential log incantations

# Default β€” newest first, full message
git log

# One-liner per commit
git log --oneline

# Branchy ASCII graph (worth aliasing)
git log --oneline --graph --all --decorate

# Show files changed in each commit
git log --stat

# Show full patches
git log -p

# Limit number of commits
git log -n 5

# Filter by author
git log --author="Dimitris"

# Filter by message content
git log --grep="fix login"

# Filter by content change (pickaxe)
git log -S "function deprecated_thing"

# Filter by date
git log --since="2 weeks ago" --until="yesterday"

# Files changed only in a specific path
git log -- path/to/file
git log --follow -- path/to/file   # follow renames

# Pretty custom format
git log --pretty=format:"%h %ad | %s%d [%an]" --date=short

Inspect a single commit

git show <sha>Show metadata + full patch for a commit
git show <sha> --statJust the file-change summary
git show <sha>:path/to/fileShow a file's contents at that commit
git show HEAD~3Three commits before HEAD
git show HEAD^^Two commits before HEAD (alt syntax)
git show <branch>:<path>File content from another branch β€” no checkout needed

Who changed what β€” blame and friends

# Line-by-line authorship
git blame path/to/file

# Limit to a line range
git blame -L 50,80 path/to/file

# Ignore whitespace-only changes
git blame -w path/to/file

# Detect moved/copied lines (slower, more accurate)
git blame -C -C path/to/file

# Ignore noisy reformatting commits
git config blame.ignoreRevsFile .git-blame-ignore-revs
git blame --ignore-revs-file=.git-blame-ignore-revs path/to/file

Refspec shorthand cheat-sheet

HEADCurrently checked-out commit
HEAD~3Three commits back along first parent
HEAD^First parent of HEAD (= HEAD~1)
HEAD^2Second parent (the merged-in branch tip)
main..featureCommits in feature but not in main
main...featureSymmetric difference (either side, but not both)
@{-1}Previously checked-out branch
@{u} or @{upstream}Upstream tracking branch
@{push}The branch this would push to
main@{yesterday}main as of yesterday (uses reflog)

Bisect β€” binary-search for the breaking commit

git bisect start
git bisect bad                # current commit is broken
git bisect good v1.4.0        # this older tag was fine

# Git checks out the midpoint commit. Run your test, then mark:
git bisect bad        # or  git bisect good

# Repeat until Git prints "<sha> is the first bad commit"
git bisect reset      # return to where you started

# Fully automated with a test script (exits 0/1 = good/bad)
git bisect run ./test.sh
Bisect reality check Bisect's power is in bisect run + atomic commits. With a 1-line test script and clean history, you can find a breaking commit out of 1,000 in ~10 steps automatically.
06

Diff & comparisonwhat changed, where, and against what

Every git diff answers one question: what's different between A and B? A and B can be the working tree, the index, a commit, a branch, or a tag. Knowing which two things you're comparing is the whole game.

The four canonical diffs

CommandCompares
git diffWorking tree vs. index (unstaged changes)
git diff --stagedIndex vs. HEAD (staged changes β€” a preview of the next commit)
git diff HEADWorking tree vs. HEAD (everything not yet committed)
git diff <A> <B>Any two commits, branches, or tags

--cached is an alias for --staged. Both work the same way; modern Git prefers --staged.

Useful narrowing

# Diff a single file
git diff -- path/to/file

# Diff two branches
git diff main..feature

# Just the file names that differ
git diff --name-only main..feature

# File names + add/delete/modify status
git diff --name-status main..feature

# Per-file change summary
git diff --stat HEAD~5

# Word-level instead of line-level diff
git diff --word-diff

# Ignore whitespace
git diff -w
git diff --ignore-all-space

# Show inter-hunk context (default 3 lines)
git diff -U10            # 10 lines of context

Reading a diff

diff --git a/file.txt b/file.txt
index e69de29..d95f3ad 100644
--- a/file.txt          // 'a' = old version
+++ b/file.txt          // 'b' = new version
@@ -1,3 +1,4 @@         // hunk header: -old_start,old_count +new_start,new_count
 unchanged line
-removed line
+added line
+another added line
 unchanged line

Range-diff β€” "what changed about my changes?" Modern

# Compare two versions of the SAME branch (e.g. before/after rebase)
git range-diff main..feature@{1} main..feature

# Compare two patch series
git range-diff <base1>..<tip1> <base2>..<tip2>

git range-diff is invaluable for code review when a PR has been force-pushed: it shows what changed between iterations, not just the latest version.

Diff tools β€” visualizing in your editor

# Configure VS Code as your diff tool
git config --global diff.tool vscode
git config --global difftool.vscode.cmd 'code --wait --diff $LOCAL $REMOTE'

# Run the configured diff tool
git difftool

# Skip the "are you sure?" prompt
git difftool --no-prompt
07

Undoing changesrestore Β· revert Β· reset Β· the modern way

Modern Git splits "undo" into three commands with three clear domains: restore for files, revert for committed history that's been shared, reset for committed history that's still local. Pick the right one and you'll never lose work.

Decision tree Want to throw away uncommitted work? β†’ git restore. Need to undo a published commit safely? β†’ git revert. Need to rewind local history before pushing? β†’ git reset. Already pushed and need to rewind anyway? β†’ git reset + git push --force-with-lease (and tell your team).

git restore β€” undo file-level changes Modern

Replaces the file-restoring half of legacy git checkout. Cleaner, less overloaded, harder to misuse.

git restore <file>Discard unstaged changes β€” restore from index
git restore --staged <file>Unstage a file (keep working-tree changes)
git restore --staged --worktree <file>Unstage and discard working-tree changes
git restore --source=HEAD~3 <file>Restore file's content from a specific commit
git restore --source=<branch> <file>Restore from another branch β€” no switching
git restore -p <file>Patch mode β€” pick which hunks to discard
git restore .Restore everything in the current directory
Destructive git restore <file> overwrites uncommitted changes in the working tree. Once gone, they are not in the reflog. Stash first if uncertain: git stash push -m "before restore".

git revert β€” undo a commit by adding a new one

Safe for shared/pushed history. Creates a new commit that undoes the changes of the target commit, leaving original history intact.

# Revert a single commit (opens editor for message)
git revert <sha>

# Revert without committing β€” leaves changes staged
git revert -n <sha>

# Revert a range (newer first, in reverse)
git revert HEAD~3..HEAD

# Revert a merge commit β€” must specify which parent's history to keep
git revert -m 1 <merge-sha>

# Abort an in-progress revert
git revert --abort
git revert --continue
git revert --skip

git reset β€” rewind local history Local only

Three modes β€” same target, different effect on your working tree and index:

ModeHEADIndexWorking tree
--softmovesuntoucheduntouched
--mixed (default)movesresetsuntouched
--hardmovesresetsresets data loss
# Unstage a file (mixed reset of one file)
git reset HEAD path/to/file
# —or, more clearly, the modern equivalent —
git restore --staged path/to/file

# Undo last commit, keep changes staged
git reset --soft HEAD~1

# Undo last commit, keep changes in working tree (unstaged)
git reset HEAD~1
git reset --mixed HEAD~1     # same thing, explicit

# Nuke last commit AND its changes — gone from working tree
git reset --hard HEAD~1

# Reset current branch to match remote exactly
git fetch origin
git reset --hard origin/main
--hard reset A hard reset deletes uncommitted work in the working tree with no warning. The reflog will save committed work for ~90 days, but uncommitted changes are gone forever. Stash first if there's any doubt.

The decision matrix

I want to…Use
Discard one file's unstaged editsgit restore <file>
Unstage a filegit restore --staged <file>
Undo last commit, keep changesgit reset --soft HEAD~1
Throw away last 3 commits entirelygit reset --hard HEAD~3 (local only)
Undo a commit that's been pushedgit revert <sha>
Fix the message of the last commitgit commit --amend
Add forgotten file to last commitgit add <file> && git commit --amend --no-edit
Rewrite older commits in seriesgit rebase -i <base>
Recover a "lost" commitgit reflog + git reset --hard <sha>
08

Branchingcheap pointers, infinite parallel timelines

A branch is a 41-byte text file containing a SHA. Creating one is instant. Deleting one is reversible (within reflog window). This cheapness is why Git workflows revolve around them.

Modern branching with git switch Modern

git switch handles only branch movement β€” clearer and safer than legacy git checkout.

git switch <branch>Switch to existing branch
git switch -c <new-branch>Create & switch to new branch from HEAD
git switch -c <new> <start-point>Branch from any commit/branch/tag
git switch -C <branch>Force-create (resets if it already exists)
git switch -Toggle to previous branch (like cd -)
git switch --detach <sha>Detached HEAD β€” inspect a commit
git switch --guess <name>Auto-create local branch tracking a remote of the same name
git switch -m <branch>Switch and 3-way merge local changes
git switch --discard-changes <branch>Force-switch, throwing away local edits

Branch listing & management

git branchList local branches (current marked with *)
git branch -aList all branches β€” local + remote-tracking
git branch -rList remote-tracking branches only
git branch -vList with last commit SHA + subject
git branch -vvSame as -v + upstream tracking info
git branch --mergedBranches fully merged into current
git branch --no-mergedBranches with unmerged work β€” careful before deleting
git branch -d <name>Delete (refuses if unmerged)
git branch -D <name>Force-delete (loses unmerged commits β€” recoverable via reflog)
git branch -m <old> <new>Rename a branch
git branch -m <new>Rename current branch
git branch --set-upstream-to=origin/mainSet tracking branch
git branch --unset-upstreamRemove tracking

HEAD β€” the moving finger

HEAD is a pointer to the current branch (which itself points to a commit). When you commit, the branch advances and HEAD goes along for the ride.

A detached HEAD means HEAD points directly to a commit instead of a branch. New commits made there are orphaned β€” they belong to no branch and will eventually be garbage-collected. Always either return to a real branch or convert the detached state into a new branch:

# Detached state β€” exploring an old commit
git switch --detach v1.2.0

# Make some experimental commits...

# Save them by creating a branch HERE
git switch -c experimental

# Or discard and return to safety
git switch -          # back to where you were
git switch main

Renaming the default branch master β†’ main

# Rename locally
git branch -m master main

# Push the new branch and set its upstream
git push -u origin main

# Update HEAD on the remote (then change default in GitHub UI/CLI)
gh repo edit --default-branch main

# Delete the old remote branch
git push origin --delete master
Naming convention Use slash-separated prefixes: feature/auth-oauth, fix/login-500, chore/upgrade-deps. Most CI systems and GitHub branch protection patterns assume this. Avoid spaces, special chars, and uppercase.
09

Mergingfast-forward Β· 3-way Β· squash Β· the conflict dance

Merging integrates one branch's history into another. The result depends on the shape of the history and the strategy you pick.

Three merge shapes

StrategyWhen it happensHistory shape
Fast-forwardTarget is a direct ancestor of sourceLinear β€” branch pointer just slides
3-way mergeBoth branches have new commitsCreates a merge commit with two parents
Squash mergeExplicitly requested with --squashOne new commit, no merge parent β€” original branch unrelated

The merge commands

git merge <branch>Merge branch into current
git merge --ff-only <branch>Fast-forward only β€” fail if a merge commit would be needed
git merge --no-ff <branch>Always create a merge commit, even when ff is possible
git merge --squash <branch>Combine all changes into one staged change (you commit separately)
git merge --no-commit <branch>Merge but don't auto-commit β€” review first
git merge --abortAbandon a merge in progress, restore pre-merge state
git merge --continueResume after resolving conflicts

Typical feature-branch merge

# Update main
git switch main
git pull

# Merge feature into main
git merge feature/auth-oauth

# Push the result
git push

# Delete the feature branch (locally and remotely)
git branch -d feature/auth-oauth
git push origin --delete feature/auth-oauth

Resolving merge conflicts

When the same lines of the same file change on both sides, Git stops mid-merge and asks you to resolve. Files containing conflicts get markers:

<<<<<<< HEAD
this is what's on the branch you're merging INTO
||||||| merged common ancestor (with merge.conflictStyle=zdiff3)
this is the original
=======
this is what's on the branch you're merging IN
>>>>>>> feature/auth
# 1. See which files have conflicts
git status

# 2. Open each, edit to the desired final state, remove all markers

# 3. Stage resolved files
git add path/to/conflicted-file

# 4. Once everything is staged, finalize the merge
git merge --continue
# —or simply—
git commit

# Bail out at any point
git merge --abort

Conflict-resolution helpers

git diff --name-only --diff-filter=UList files with unresolved conflicts
git checkout --ours -- <file>Keep our version (target branch's)
git checkout --theirs -- <file>Keep their version (source branch's)
git mergetoolLaunch configured visual merge tool
git config merge.conflictStyle zdiff3Show base version in conflict markers β€” much more useful
git config rerere.enabled trueReuse Recorded Resolution β€” Git remembers conflict resolutions and replays them

Squash merge β€” when and why

Squashing turns a feature branch's many WIP commits into one tidy commit on the target. Useful for noisy branches but loses the granular history. GitHub's "Squash and merge" PR option does this remotely.

git switch main
git merge --squash feature/messy-branch
git commit -m "feat(api): add bulk export endpoint"
No-ff for visibility Some teams configure --no-ff as default for merges into long-lived branches: it keeps a visible "this came from a feature branch" marker in the graph. Set it per-branch with git config branch.main.mergeOptions --no-ff.
10

Stashingput work aside without committing

Stashes save uncommitted work to a stack you can pop later. Perfect for "I need to switch branches right now and don't want to commit half-baked changes."

Core stash commands

git stash pushStash tracked, modified files (default)
git stash push -m "msg"Stash with a descriptive label
git stash push -uInclude untracked files
git stash push -aInclude untracked and ignored files
git stash push -pPatch mode β€” pick hunks to stash
git stash push -kStash unstaged changes only, keep index intact
git stash push -- <path>Stash only specific files
git stash listShow all stashes
git stash showSummary of latest stash
git stash show -pFull patch for latest stash
git stash show -p stash@{2}Patch for the 3rd stash
git stash popApply latest stash + remove from list
git stash applyApply latest stash, keep it on the list
git stash apply stash@{1}Apply a specific stash
git stash drop stash@{0}Delete one stash
git stash clearDelete all stashes β€” cannot be undone
git stash branch <new-branch>Create a branch starting from the stash's parent & apply it

Common stash recipes

# Quick switch β€” stash, branch, work, come back
git stash push -m "wip: refactoring auth"
git switch hotfix/login
# ...fix the hotfix, commit, push...
git switch -
git stash pop

# Stash including untracked .env.local you forgot
git stash push -u -m "wip with new env file"

# Apply a specific old stash by index
git stash list
#   stash@{0}: wip: third attempt
#   stash@{1}: wip: working version
#   stash@{2}: wip: original idea
git stash apply stash@{1}

# Recover a "lost" pop — conflict bailout
git stash apply stash@{0}     # try again

# Convert stash into a real branch (keeps the stash)
git stash branch feature/from-stash stash@{0}
pop vs. apply pop removes the stash from the list β€” and if you hit a conflict, the stash is still dropped and only its contents stay in your tree. Use apply when you might need a redo, pop when you're confident.
Stashes β‰  commits Stashes live in refs/stash and are technically commits, but they're not on any branch. They survive across sessions but are not pushed to remotes. To share WIP, push to a real branch instead.
11

Remotesnamed pointers to other copies of the repo

A remote is just a nickname for a URL. origin is the conventional name for the repo you cloned from. upstream is the conventional name for the original repo when you've forked. Every Git operation that touches the network names a remote (sometimes implicitly).

Managing remotes

git remoteList remote names
git remote -vList with URLs (for fetch + for push)
git remote show originDetailed info: tracked branches, push targets, prune state
git remote add <name> <url>Add a new remote
git remote rename <old> <new>Rename
git remote remove <name>Remove (also: git remote rm)
git remote set-url <name> <url>Change a remote's URL
git remote set-url --push <name> <url>Different URL for pushing only
git remote prune originDrop refs to deleted remote branches

Adding upstream after forking

# After cloning your fork:
git remote -v
# origin  git@github.com:you/project.git (fetch)
# origin  git@github.com:you/project.git (push)

# Add the original repo as 'upstream'
git remote add upstream https://github.com/original/project.git

# Verify
git remote -v

# Sync from upstream periodically
git fetch upstream
git switch main
git merge upstream/main
git push origin main

Switching protocols (HTTPS ↔ SSH)

# Currently HTTPS, switch to SSH
git remote set-url origin git@github.com:owner/repo.git

# Currently SSH, switch to HTTPS
git remote set-url origin https://github.com/owner/repo.git

Pushing β€” local β†’ remote

git pushPush current branch to its tracked upstream
git push origin mainPush main to origin
git push -u origin <branch>First push β€” creates remote branch + sets up tracking
git push --allPush every local branch
git push --tagsPush all tags
git push origin <tag>Push one tag
git push --follow-tagsPush commits + annotated tags reachable from them
git push origin --delete <branch>Delete the remote branch
git push --force-with-leaseSafer force-push β€” fails if remote moved unexpectedly
git push --forcePlain force-push (dangerous on shared branches)
git push --dry-runShow what would happen

Remote-tracking branches

When you fetch, Git updates remote-tracking branches: read-only local pointers like origin/main that mirror the remote's state at last fetch. They are not the same as your local main.

git branch -vvShow local β†’ upstream tracking config
git switch --track origin/featureCreate local feature tracking origin/feature
git switch featureOften the same β€” modern Git auto-creates tracking when names match
git push -u origin <branch>Set up tracking on first push
git branch --set-upstream-to=origin/main mainSet tracking after the fact
--force-with-lease Always prefer --force-with-lease over --force. It refuses to overwrite if the remote has new commits you haven't seen β€” protects against accidentally clobbering a teammate's push.
12

Fetch & pullremote β†’ local β€” the right way

fetch is the safe inspection step. pull is fetch + integrate in one command. When in doubt, fetch first.

git fetch β€” download without merging

git fetchFetch from default remote (usually origin)
git fetch originFetch from a named remote
git fetch --allFetch from every remote
git fetch origin mainFetch a specific branch
git fetch --tagsAlso fetch new tags
git fetch --pruneRemove tracking refs for branches deleted on remote
git fetch --prune-tagsAlso prune deleted tags
git fetch --depth=NLimit fetch depth (shallow)
git fetch --unshallowConvert a shallow clone to full

git pull β€” fetch + integrate

git pull is shorthand for two commands: git fetch then either git merge or git rebase depending on configuration.

git pullDefault behavior (depends on pull.rebase / pull.ff)
git pull --ff-onlyOnly allow fast-forward; abort otherwise
git pull --no-ffAlways create a merge commit
git pull --rebaseRebase local commits on top of remote, no merge commit
git pull --rebase=interactiveRebase + open editor to reorder/squash
git pull --autostashAuto-stash dirty tree before pulling, pop after

Recommended pull config

# Make pull fail loudly if a fast-forward isn't possible —
# forces an explicit merge or rebase decision.
git config --global pull.ff only

# Always auto-prune deleted remote branches
git config --global fetch.prune true

If you prefer rebase-by-default for cleaner history (and no merge bubbles on every pull):

git config --global pull.rebase true
git config --global rebase.autoStash true

Pull with conflicts

# Merge-style conflict
git pull
# <CONFLICT messages>
# Resolve files, stage, commit
git add <resolved-files>
git commit

# Rebase-style conflict
git pull --rebase
# <CONFLICT messages>
git add <resolved-files>
git rebase --continue
# —or bail out—
git rebase --abort

fetch vs. pull β€” the rule of thumb

git fetchRead-only inspection. Updates remote-tracking refs. Your branches don't move.
git pullFetch + integrate. Your branch moves. Possible conflicts.

Use git fetch before reviewing what teammates have done; use git pull when you're ready to integrate.

Read before pull Run git fetch && git log HEAD..origin/main --oneline to see exactly what's coming before you pull. No surprises.
13

Rebasereplay commits onto a new base β€” clean history, sharp edges

Rebasing rewrites history by replaying commits one at a time onto a new base. Used wisely it produces beautiful linear history. Used carelessly it destroys collaborators' work.

The rebase mental model

Where merge joins two histories with a merge commit, rebase takes commits from one branch and re-applies them onto another, creating new commits with new SHAs. The original commits are abandoned (but recoverable via reflog).

The Golden Rule Never rebase commits that exist outside your local repository. Rebasing rewrites history; if anyone has based work on the original commits, you've broken their copy. Public branches (main, shared release branches) are off-limits. Your own un-pushed feature branch is fair game.

Basic rebase

git rebase <base>Replay current branch's commits onto base
git rebase mainMost common: bring feature branch up-to-date with main
git rebase --onto <new-base> <old-base>Surgical: move a range of commits to a new parent
git rebase --continueResume after resolving conflicts
git rebase --abortBail out, restore pre-rebase state
git rebase --skipSkip the conflicting commit entirely
git rebase --autosquashHonor fixup! / squash! commits automatically
git rebase --autostashAuto-stash dirty tree before rebasing
git rebase --rebase-mergesPreserve merge commits during rebase

Standard "update my feature branch" flow

# On your feature branch, get latest main
git fetch origin
git rebase origin/main

# If conflicts:
#   1. Edit conflicted files
#   2. git add <resolved>
#   3. git rebase --continue

# Force-push the rewritten branch (yours, not shared)
git push --force-with-lease

Interactive rebase β€” the history sculptor Pro

# Edit the last 5 commits
git rebase -i HEAD~5

# Edit everything since branching from main
git rebase -i main

An editor opens with one line per commit. Change the leading word to apply an action:

pickUse commit as-is
reword (r)Use commit, but edit the message
edit (e)Stop here so you can amend the commit
squash (s)Meld into previous commit, combine messages
fixup (f)Like squash, but discard this commit's message
drop (d)Delete the commit entirely
exec (x)Run a shell command after the line (e.g. x npm test)
break (b)Stop here, no command β€” manually inspect / change anything
label / reset / mergeReplicate merge structure β€” used with --rebase-merges

The fixup workflow β€” clean history without the hassle

# 1. Discover a small mistake in commit abc1234
#    Stage your fix, then:
git commit --fixup abc1234

# 2. Repeat for any other late fixes

# 3. Auto-squash all fixups into their targets
git rebase -i --autosquash main

With git config --global rebase.autoSquash true, every interactive rebase automatically reorders fixup! commits next to their targets. Combined with commit --fixup, it's the cleanest possible workflow.

Comparing pre/post rebase β€” range-diff

# Before forcing the push, see what really changed
git rebase main
git range-diff main feature@{1} feature

Rebase vs. merge β€” the trade-off

MergeRebase
HistoryBranchy, trueLinear, simplified
CommitsPreserved as-isRecreated with new SHAs
Public-safeAlwaysNever on shared branches
ConflictsOnce, in merge commitPossibly multiple times, per replayed commit
Best forLong-lived branches, integration pointsShort-lived feature branches, cleaning up local WIP
Hybrid strategy Rebase your feature branch onto main while developing (clean local history). Merge with --no-ff when integrating to main (preserves "this came from a feature" marker). GitHub's "Squash and merge" PR option achieves a similar outcome remotely.
14

Tags & releasesmarking important moments in history

Tags are immutable named pointers to a commit β€” perfect for marking releases. Two flavors: lightweight (a ref pointing at a commit, like a stationary branch) and annotated (a full Git object with a message, signature, and metadata).

Tag commands

git tagList all tags
git tag -l "v1.*"List tags matching a pattern
git tag v1.0.0Lightweight tag at HEAD
git tag -a v1.0.0 -m "Release 1.0"Annotated tag β€” preferred for releases
git tag -a v1.0.0 -m "msg" <sha>Tag a past commit
git tag -s v1.0.0 -m "msg"GPG-signed annotated tag
git tag -d v0.9.0Delete a local tag
git push origin v1.0.0Push one tag
git push origin --tagsPush all tags
git push --follow-tagsPush commits + reachable annotated tags
git push origin --delete v0.9.0Delete a remote tag
git show v1.0.0Show tag's commit + metadata
git tag -f v1.0.0 <sha>Force-replace a tag (avoid on shared tags)

Annotated vs. lightweight

LightweightJust a ref to a commit. No metadata. Use for private/temporary markers.
AnnotatedFull object with tagger, date, message, optional signature. Use for releases, ALWAYS.

Semantic Versioning quick reference

MAJOR.MINOR.PATCH

MAJOR  — incompatible API changes
MINOR  — backwards-compatible new features
PATCH  — backwards-compatible bug fixes

# Pre-release / build metadata
v1.2.0-alpha.1
v1.2.0-beta.3
v1.2.0-rc.1
v1.2.0+20251231

See the full Semantic Versioning 2.0.0 spec for canonical rules.

Releases via GitHub CLI

# Create a release linked to a tag
gh release create v1.0.0 \
  --title "v1.0.0 — Initial release" \
  --notes "First public release"

# Auto-generate notes from PRs since last tag
gh release create v1.1.0 --generate-notes

# Mark as pre-release
gh release create v1.2.0-rc.1 --prerelease

# Attach binaries
gh release create v1.0.0 ./dist/app-*.tar.gz

# List, view, edit, delete
gh release list
gh release view v1.0.0
gh release edit v1.0.0 --notes "Updated notes"
gh release delete v1.0.0

Useful describe

# "Most recent tag, plus how far we've come"
git describe
# v1.2.0-15-g8f3c2a1
# —15 commits past v1.2.0, current SHA starts with 8f3c2a1

# Only annotated tags (default)
git describe

# Include lightweight tags too
git describe --tags

# Always print short SHA, even on a tag
git describe --always
Tags β‰  branches Don't push lightweight tags to share releases β€” they have no metadata, no signer, no message. Always use -a (annotated) or -s (signed annotated) for anything that leaves your machine.
15

Reflog & rescueGit's local time machine

The reflog records every move HEAD makes β€” every commit, switch, reset, rebase. It's how you recover from "I think I just deleted everything." Local-only, kept for ~90 days by default.

Reading the reflog

git reflogHEAD's reflog (newest first)
git reflog show <branch>Reflog for a specific branch
git reflog show stashStash reflog
git reflog -n 20Last 20 entries
git reflog --date=isoWith ISO timestamps
git reflog --since="1 hour ago"Time-filtered
# Sample output
8f3c2a1 HEAD@{0}: rebase (finish): returning to refs/heads/feature
8f3c2a1 HEAD@{1}: rebase (pick): apply migration
6e9b1f0 HEAD@{2}: rebase (start): checkout main
ab12cd3 HEAD@{3}: commit: WIP — broken state
4f5d6c7 HEAD@{4}: commit: stable feature work

The rescue patterns

1. "I just did a hard reset and lost commits"

git reflog
# Find the SHA from BEFORE the reset
git reset --hard ab12cd3

2. "I deleted a branch with unmerged work"

# Reflog still has it (if < 30 days for unreachable, ~90 for reachable)
git reflog                   # find the last SHA the branch pointed to
git switch -c recovered <sha>

3. "I rebased and the result is wrong"

git reflog
# Find HEAD@{N} where N is just before "rebase (start)"
git reset --hard HEAD@{4}

4. "I dropped a stash by accident"

# Stash drops aren't in HEAD's reflog — check stash's own reflog
git fsck --no-reflog | grep "dangling commit"
# Or, if you have the SHA:
git stash apply <sha>

Time-relative refs β€” sometimes faster than reflog

git switch main@{yesterday}
git diff main@{1.week.ago}..main
git log feature@{2.hours.ago}..feature

# Index notation (Nth move ago)
git switch main@{3}    # 3 reflog entries back

Garbage collection & reflog expiration

Git eventually prunes unreachable objects to save space. Defaults:

# Expire reflog entries manually
git reflog expire --expire=30.days --all

# Pause expiration on a critical branch
git config branch.<name>.reflogExpire never
git config branch.<name>.reflogExpireUnreachable never
--prune=now git gc --prune=now --aggressive deletes unreachable objects immediately. After running this, the reflog cannot save you. Avoid unless you really need the disk space.

fsck β€” find lost commits beyond reflog

# List dangling objects (commits, blobs, trees no ref points to)
git fsck --lost-found
git fsck --no-reflog

# Inspect a candidate
git show <dangling-sha>

# Recover
git switch -c rescued <dangling-sha>
16

Worktrees & sparse-checkoutparallel checkouts and partial repos

Worktrees β€” multiple checked-out branches at once

One repo, many working directories. Eliminates the "stash, switch, rebuild, switch back" dance for hotfixes. Each worktree has its own working tree and index but shares the object database.

git worktree add ../hotfix hotfix/login-500Add worktree at ../hotfix on branch hotfix/login-500
git worktree add -b new-branch ../path mainCreate new branch in worktree
git worktree add --detach ../inspect <sha>Detached worktree at a commit
git worktree listList all worktrees
git worktree list -vVerbose listing
git worktree list --porcelainMachine-readable
git worktree remove <path>Remove a worktree (requires clean)
git worktree remove --force <path>Force-remove (loses uncommitted)
git worktree pruneClean up administrative state for deleted worktrees
git worktree prune -nDry run
git worktree lock <path>Mark a worktree as locked (prevents accidental prune β€” useful on removable drives)
git worktree unlock <path>Reverse lock
git worktree move <path> <new-path>Relocate a worktree

Hotfix-while-feature pattern

# You're in the middle of feature/auth, suddenly a P0 bug
cd ~/projects/myapp
git worktree add ../myapp-hotfix -b hotfix/critical main

# In a new terminal:
cd ../myapp-hotfix
# —fix, commit, push, open PR—
gh pr create --base main --title "Fix critical login bug"

# Back in original terminal, your feature work is untouched
cd ~/projects/myapp
# —continue feature work—

# When hotfix is merged, clean up
git worktree remove ../myapp-hotfix
git branch -d hotfix/critical

Sparse-checkout β€” partial working tree

Useful in monorepos: only check out the directories you actually work in. Saves disk space and speeds up status / diff.

git sparse-checkout init --coneInitialize in cone mode (recommended; faster)
git sparse-checkout set apps/web libs/uiReplace patterns β€” only these dirs in working tree
git sparse-checkout add libs/apiAdd another path
git sparse-checkout listShow current patterns
git sparse-checkout reapplyRe-apply (after a checkout that changed the patterns)
git sparse-checkout disableRestore full working tree
# Practical example: monorepo with apps/, libs/, infra/
git clone --filter=blob:none --no-checkout git@github.com:org/monorepo.git
cd monorepo
git sparse-checkout init --cone
git sparse-checkout set apps/web libs/shared
git switch main
# Only apps/web and libs/shared materialize on disk

Bundles β€” repos as files

A bundle is a single file containing Git history β€” useful when you can't push/pull (air-gapped systems, sneakernet across networks).

git bundle create <file> --allBundle the entire repo
git bundle create <file> main..featureBundle just a range
git bundle verify <file>Check a bundle's integrity and prerequisites
git bundle list-heads <file>Show what refs are in a bundle
git clone <file> new-repoClone from a bundle
git fetch <file> mainFetch from a bundle into existing repo

Maintenance β€” keep big repos fast Modern

# Register repo for background maintenance (gc, pack, prefetch)
git maintenance start

# Run a maintenance task right now
git maintenance run --task=gc
git maintenance run --task=prefetch

# Stop scheduled tasks
git maintenance stop

git maintenance sets up cron / launchd / systemd timers to keep your repo healthy. Especially useful in monorepos where manual git gc would otherwise take ages.

17

Plumbing & objectsGit as a content-addressable filesystem

Underneath the porcelain (the user-facing commands) sits the plumbing: low-level commands that operate directly on Git's object database. You almost never need them β€” but understanding them is the difference between using Git and knowing Git.

Inspecting the object database

git cat-file -t <sha>Object type: blob, tree, commit, tag
git cat-file -s <sha>Object size in bytes
git cat-file -p <sha>Pretty-print object contents
git cat-file --batch-check --batch-all-objectsStream metadata for every object
git rev-parse HEADResolve a refspec to its full SHA
git rev-parse --short HEADShort SHA
git rev-parse main^{tree}Tree of main's commit
git ls-tree HEADList entries in HEAD's tree
git ls-tree -r HEADRecursive β€” like find
git ls-filesFiles tracked in the index
git ls-files --others --exclude-standardUntracked, non-ignored files
git verify-pack -v <.idx>Inspect a packfile

Creating objects manually β€” what git add + git commit really do

# 1. Hash a file's contents into a blob
echo "hello world" | git hash-object --stdin -w
# → 3b18e512dba79e4c8300dd08aeb37f8e728b8dad

# 2. Read it back
git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad

# 3. Build a tree containing that blob
git update-index --add --cacheinfo 100644 \
  3b18e512dba79e4c8300dd08aeb37f8e728b8dad hello.txt
git write-tree
# → 68aba62e560c0ebc3396e8ae9335232cd93a3f60

# 4. Create a commit pointing to that tree
echo "first commit" | git commit-tree 68aba62e -p HEAD
# → new commit SHA

# 5. Move the branch ref
git update-ref refs/heads/main <new-commit-sha>

That sequence β€” hash-object, update-index, write-tree, commit-tree, update-ref β€” is exactly what git add + git commit automate.

Refs and symbolic refs

git update-ref refs/heads/foo <sha>Move a ref to point at sha
git update-ref -d refs/heads/fooDelete a ref
git symbolic-ref HEADWhat does HEAD point at?
git symbolic-ref HEAD refs/heads/mainPoint HEAD at a branch
git for-each-refIterate every ref
git for-each-ref --format='%(refname:short) %(committerdate:short)' refs/heads/Custom-format branch listing

The hashing function

Object SHAs are deterministic: the same content always produces the same SHA. The hash is computed over "<type> <size>\0<content>" β€” that's why an empty file isn't all zeros.

Modern Git supports SHA-256 in addition to SHA-1. Set on init:

git init --object-format=sha256

Most servers (including GitHub at the time of writing) still use SHA-1; SHA-256 is opt-in until ecosystem support is universal.

Trailers β€” structured commit metadata

# Add a Co-authored-by trailer (GitHub honors these for credit)
git commit -m "feat: pair-programmed feature" \
  --trailer "Co-authored-by: Alex Smith <alex@example.com>"

# Read trailers from existing commits
git log --format='%(trailers)' -n 5

# Interpret programmatically
git interpret-trailers --parse < commit-msg.txt
Why care about plumbing? Plumbing commands shine in scripts, hooks, and CI pipelines: deterministic, machine-readable output and exact behavior. They're also how you debug "Git did something weird" β€” drop one level deeper and you can see exactly what happened.
18

Config & aliasesmake Git fit your fingers

Config scopes β€” first match wins

--system/etc/gitconfig β€” every user on this machine
--global~/.gitconfig β€” current user, all repos
--local (default).git/config β€” this repo only
--worktree.git/config.worktree β€” this worktree only

Config commands

git config --listAll settings (merged across scopes)
git config --list --show-originShow which file each setting comes from
git config <key>Read one setting
git config <key> <value>Set one setting
git config --add <key> <value>Add a value (multi-valued keys)
git config --unset <key>Remove a setting
git config --editOpen the config file in $EDITOR
git config --get-regexp <pattern>Find keys matching a regex

Conditional includes β€” different identity per directory

# ~/.gitconfig
[user]
    name = Dimitris
    email = dimitris@personal.com

[includeIf "gitdir:~/work/"]
    path = ~/.gitconfig-work

# ~/.gitconfig-work
[user]
    email = dimitris@company.com
[commit]
    gpgsign = true

Now any repo under ~/work/ uses your work email automatically. gitdir/i: is the case-insensitive variant; onbranch: matches by branch name.

Aliases β€” the heart of a productive setup

# Set via command line
git config --global alias.s "status -sb"
git config --global alias.co "switch"
git config --global alias.cb "switch -c"
git config --global alias.unstage "restore --staged"

# Pretty graph log — the legend
git config --global alias.lg \
  "log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit"

# Last commit, full diff
git config --global alias.last "log -1 HEAD --stat"

# What did I do today?
git config --global alias.today \
  "log --since='6 am' --author='Dimitris' --pretty=format:'%h %s'"

# Branches sorted by recent activity
git config --global alias.recent \
  "for-each-ref --sort=-committerdate refs/heads/ --format='%(committerdate:short) %(refname:short)'"

# Quick amend with no message edit
git config --global alias.fixlast "commit --amend --no-edit"

# Show who's been touching a file (with renames)
git config --global alias.who "shortlog -sn --no-merges --follow --"

Direct .gitconfig editing β€” easier for many aliases

# ~/.gitconfig
[alias]
    s = status -sb
    co = switch
    cb = switch -c
    unstage = restore --staged
    last = log -1 HEAD --stat
    lg = log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit

    # Shell-out aliases prefix with "!"
    pushf = "!git push --force-with-lease"
    nuke = "!git reset --hard origin/$(git symbolic-ref --short HEAD)"

Recommended global config β€” battle-tested defaults

[user]
    name = Your Name
    email = you@example.com

[init]
    defaultBranch = main

[pull]
    ff = only

[fetch]
    prune = true
    pruneTags = true

[merge]
    conflictStyle = zdiff3

[rebase]
    autoSquash = true
    autoStash = true

[rerere]
    enabled = true

[diff]
    algorithm = histogram
    colorMoved = default
    mnemonicPrefix = true

[push]
    autoSetupRemote = true
    followTags = true

[commit]
    verbose = true

commit.verbose = true shows the diff in your editor when writing a commit message β€” invaluable for catching last-minute mistakes.

19

GitHub via CLIcollaboration without leaving the terminal

The GitHub CLI (gh) is the official terminal interface to GitHub. Pull requests, issues, releases, code review, repo creation β€” all of it without context-switching to a browser.

Auth & account

gh auth loginInteractive sign-in (browser flow)
gh auth login --hostname <ghe.host>Sign in to GitHub Enterprise
gh auth login --with-token < token.txtPipe a PAT for non-interactive auth
gh auth statusVerify which accounts you're signed into
gh auth refresh --scopes write:packages,read:packagesAdd scopes to existing token
gh auth tokenPrint the current token (use carefully)
gh auth switchSwitch between configured accounts
gh auth logoutSign out

Repos β€” create, clone, fork

gh repo createInteractive new-repo wizard
gh repo create my-app --public --source=. --pushCreate from existing local repo and push
gh repo clone owner/repoClone (handles SSH/HTTPS automatically)
gh repo clone owner/repo -- --depth=1Pass flags through to git clone
gh repo fork owner/repoFork to your account
gh repo fork owner/repo --clone --remoteFork + clone + add upstream remote
gh repo viewOpen current repo in browser
gh repo view --webSame β€” explicit
gh repo list ownerList repos under a user/org
gh repo edit --default-branch mainChange default branch
gh repo edit --visibility privateToggle public/private
gh repo delete owner/repo --yesDelete a repo (irreversible)
gh repo archive owner/repoArchive (read-only)

Pull requests β€” the bread and butter

gh pr createInteractive PR creation from current branch
gh pr create --fillAuto-fill title/body from commits
gh pr create --base main --title "..." --body "..."Non-interactive
gh pr create --draftOpen as draft
gh pr create --reviewer user1,user2 --label bugPre-fill reviewers + labels
gh pr listList open PRs
gh pr list --author "@me"Mine
gh pr list --state all --limit 50Including closed/merged
gh pr view 123Show PR details in terminal
gh pr view 123 --webOpen in browser
gh pr checkout 123Check out a PR's branch locally
gh pr checkout https://github.com/.../pull/123By URL
gh pr diff 123Show PR diff inline
gh pr statusStatus of PRs relevant to you
gh pr checks 123CI / status check results
gh pr checks 123 --watchLive-update until checks complete
gh pr review 123 --approveApprove
gh pr review 123 --request-changes --body "fix tests"Request changes
gh pr review 123 --comment --body "looks good"Plain comment review
gh pr ready 123Mark draft as ready for review
gh pr edit 123 --add-label urgent --title "new"Edit metadata
gh pr merge 123 --mergeMerge commit
gh pr merge 123 --squash --delete-branchSquash + cleanup branch
gh pr merge 123 --rebase --autoAuto-merge once checks pass
gh pr close 123Close without merging
gh pr reopen 123Reopen

Typical PR workflow

# 1. Branch and work
git switch -c feature/api-rate-limit
# —edit, commit—
git push -u origin feature/api-rate-limit

# 2. Open PR
gh pr create --base main --fill --reviewer alice,bob

# 3. Watch CI
gh pr checks --watch

# 4. Address review feedback
# —edit, commit, push—
gh pr comment --body "Updated based on feedback, please re-review"

# 5. Auto-merge once approved + CI green
gh pr merge --squash --auto --delete-branch

Issues

gh issue create --title "..." --body "..."Create issue
gh issue create --template bug_report.mdUse a template
gh issue listList open issues
gh issue list --label "bug" --assignee "@me"Filtered
gh issue view 42Show issue
gh issue comment 42 --body "On it"Add comment
gh issue close 42Close
gh issue reopen 42Reopen
gh issue edit 42 --add-label urgentEdit
gh issue develop 42 -cCreate & checkout a branch linked to issue #42

Branch protection β€” set rules from CLI

# Require PR reviews + passing checks via gh api
gh api -X PUT repos/:owner/:repo/branches/main/protection \
  -f required_status_checks='{"strict":true,"contexts":["ci"]}' \
  -f enforce_admins=true \
  -f required_pull_request_reviews='{"required_approving_review_count":1}' \
  -f restrictions=null

Other useful gh commands

gh run listRecent workflow (Actions) runs
gh run view <id> --logLogs for a run
gh run watchLive-watch the most recent run
gh run rerun <id>Rerun a failed workflow
gh workflow listList workflow files
gh workflow run <name>Manually dispatch a workflow
gh release view --webOpen releases page
gh gist create file.md --publicQuick gist
gh codespace listManage Codespaces
gh secret set NAME --body "value"Set repo secret
gh api <endpoint>Direct REST/GraphQL API access
gh extension install <owner/name>Install a gh extension

Markdown & READMEs β€” the project face

Every repo's README is rendered Markdown. It's the project's homepage on GitHub. Conventions worth following:

For collaborative projects, also commit:

20

Workflows that pay rentbattle-tested patterns, not theoretical ones

Starting points, not scripture. Mix and match for your team's risk tolerance, deployment cadence, and code-review culture.

1. The classic feature branch

# Update main
git switch main
git pull

# Branch off
git switch -c feature/oauth-login

# Work in atomic commits
# —edit, git add -p, git commit—

# Push and open PR
git push -u origin feature/oauth-login
gh pr create --fill

# Once approved + CI passes
gh pr merge --squash --delete-branch
git switch main
git pull --prune

2. Fork & pull request (open-source contribution)

# 1. Fork on GitHub, then clone YOUR fork
gh repo fork upstream-owner/project --clone --remote
cd project

# Now: origin = your fork, upstream = original repo
git remote -v

# 2. Always branch from a fresh upstream main
git fetch upstream
git switch -c fix/typo-readme upstream/main

# 3. Work, commit, push to YOUR fork
git push -u origin fix/typo-readme

# 4. Open PR against upstream
gh pr create --repo upstream-owner/project --base main

# 5. Keep your fork in sync
git switch main
git fetch upstream
git merge upstream/main
git push origin main

3. Trunk-based development

Short-lived branches (hours, not days), all merging to main. Feature flags gate incomplete work in production. Best for high-cadence, well-tested teams.

git switch main && git pull
git switch -c task/short-lived
# —commit, push, PR, merge within the day—

# In code, gate the new behavior
if (featureFlags.isEnabled('new-checkout')) { ... }

4. GitFlow β€” long-lived branches

For projects with formal release cycles. Branches:

More overhead than trunk-based. Use only if you have multiple production versions in the wild simultaneously.

5. The atomic-commit + fixup workflow

# Set once
git config --global rebase.autoSquash true
git config --global rebase.autoStash true

# Day's work — clean commits as you go
git add -p && git commit -m "feat(api): add /users endpoint"
git add -p && git commit -m "test(api): cover /users edge cases"

# Notice a typo in the first commit?
git add . && git commit --fixup HEAD~1

# Before pushing, auto-squash all fixups in place
git rebase -i --autosquash main

# Final, clean push
git push -u origin feature/users-api

6. Hotfix while mid-feature

# You're deep in feature/auth, P0 bug reported on main
git stash push -m "wip: auth refactor"
git switch main && git pull
git switch -c hotfix/login-500
# —fix, test, commit, push, PR, merge—
gh pr create --fill --label hotfix
gh pr merge --squash --delete-branch

# Back to feature work
git switch -
git stash pop

Or, with worktrees (no stashing needed):

git worktree add ../app-hotfix -b hotfix/login-500 main
cd ../app-hotfix
# —fix in parallel terminal—
gh pr create --fill --label hotfix
cd - && git worktree remove ../app-hotfix

7. CI automation snippets

# GitHub Actions — auto-merge on label
# .github/workflows/automerge.yml
name: Auto-merge
on:
  pull_request:
    types: [labeled]
jobs:
  merge:
    if: github.event.label.name == 'auto-merge'
    runs-on: ubuntu-latest
    steps:
      - run: gh pr merge --squash --auto --delete-branch ${{ github.event.pull_request.number }}
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

8. Release tagging β€” manual

# From clean main
git switch main && git pull

# Annotated, signed tag
git tag -s v1.4.0 -m "Release 1.4.0 — payments + i18n"

# Push tag (and trigger your release workflow)
git push origin v1.4.0

# Create the GitHub release with auto-generated notes
gh release create v1.4.0 --generate-notes --verify-tag

9. Bisect-driven debugging

# Save a one-line repro test
cat > /tmp/test.sh <<'EOF'
#!/bin/sh
npm install --silent && npm test -- --silent regression/login
EOF
chmod +x /tmp/test.sh

git bisect start
git bisect bad HEAD
git bisect good v1.3.0
git bisect run /tmp/test.sh
git bisect reset

Git auto-finds the breaking commit out of potentially hundreds in 10–20 steps. Worth investing 5 minutes in a clean repro script.

10. Branch cleanup

# See branches already merged into main
git branch --merged main

# Delete all of them in one go (skip main itself)
git branch --merged main | grep -v "^\*\|main\|develop" | xargs -r git branch -d

# Prune stale remote-tracking refs
git fetch --prune --prune-tags

# Find branches with no upstream (fork-style stale)
git for-each-ref --format='%(refname:short) %(upstream:short)' refs/heads/ \
  | awk '$2 == ""'
21

Pro-tips & gotchasthe things nobody tells you until you hit them

Best practices

Common gotchas

Recovery cheat-sheet

SymptomFirst-line response
"I lost my last commit"git reflog, then git reset --hard <sha>
"I deleted a branch"git reflog, then git switch -c <name> <sha>
"I committed to the wrong branch"git reset HEAD~ --soft, switch, recommit
"I committed something I shouldn't have"git revert <sha> if pushed; git reset --soft HEAD~1 if local
"I committed a secret"Rotate the secret first. Then rewrite history with git filter-repo or BFG.
"I want to undo a force-push"Find old SHA in reflog, git push --force-with-lease origin <sha>:<branch>
"I'm stuck mid-merge / mid-rebase"git merge --abort / git rebase --abort
"Working tree is in a weird state"git status, then git restore what's broken
"git is slow"git maintenance start, or one-shot git gc --aggressive

Performance & large repo tips

Security

If you push a secret Rotate the secret immediately. Rewriting history doesn't help if the bad commit was pushed even briefly β€” assume it's been scraped. Then rewrite history with git filter-repo (the modern replacement for git filter-branch) and force-push.