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.
Almost every Git command moves data between these three locations. Understanding which is which removes 90% of beginner confusion.
.git/index. git add writes to it; git commit seals it..git/objects. Immutable. HEAD points at the current branch tip.| blob | File contents. No name, no metadata. Addressed by SHA-1/SHA-256 of contents + header. |
| tree | Directory listing β points to blobs and other trees with names and modes. |
| commit | Snapshot pointer to one tree, plus author, committer, message, and parent commit(s). |
| tag | Annotated 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.
git add.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.
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 β 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
# 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
# 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"
# 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
git config --global --edit to open ~/.gitconfig directly. Easier than typing git config --global x.y z for every setting.
# 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
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.
# 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>
# 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
.git/| HEAD | Pointer to the current branch ref |
| config | Per-repo configuration |
| index | The 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 |
.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.
# 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.
A commit should be one logical change. Stage with surgical care; commit messages outlive the code.
| git add <path> | Stage a specific file or directory |
| git add . | Stage everything in the current directory and below |
| git add -A | Stage all changes in the entire working tree (incl. deletions) |
| git add -u | Stage modifications + deletions of tracked files only |
| git add -p | Patch mode β interactively pick hunks to stage |
| git add -i | Full interactive staging menu |
| git add -N <path> | Track an empty/new file without staging contents (intent-to-add) |
| git commit | Open 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 --amend | Replace last commit (message + new staged changes) |
| git commit --amend --no-edit | Re-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-empty | Empty commit (useful as a CI trigger) |
| git commit -S -m "msg" | GPG/SSH-signed commit |
# 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.
type(scope)!: subject
[optional body]
[optional footer(s)]
| feat | New user-facing feature |
| fix | Bug fix |
| docs | Documentation only |
| style | Formatting, no code change |
| refactor | Code change that neither fixes a bug nor adds a feature |
| perf | Performance improvement |
| test | Adding or fixing tests |
| build | Build system / dependency change |
| ci | CI configuration change |
| chore | Tooling / housekeeping |
| revert | Reverts 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.
Closes #123).git bisect, git revert, and code review dramatically more useful.
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.
# 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
| git show <sha> | Show metadata + full patch for a commit |
| git show <sha> --stat | Just the file-change summary |
| git show <sha>:path/to/file | Show a file's contents at that commit |
| git show HEAD~3 | Three commits before HEAD |
| git show HEAD^^ | Two commits before HEAD (alt syntax) |
| git show <branch>:<path> | File content from another branch β no checkout needed |
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
| HEAD | Currently checked-out commit |
| HEAD~3 | Three commits back along first parent |
| HEAD^ | First parent of HEAD (= HEAD~1) |
| HEAD^2 | Second parent (the merged-in branch tip) |
| main..feature | Commits in feature but not in main |
| main...feature | Symmetric 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) |
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 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.
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.
| Command | Compares |
|---|---|
| git diff | Working tree vs. index (unstaged changes) |
| git diff --staged | Index vs. HEAD (staged changes β a preview of the next commit) |
| git diff HEAD | Working 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.
# 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
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
# 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.
# 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
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.
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).
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 |
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".
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
Three modes β same target, different effect on your working tree and index:
| Mode | HEAD | Index | Working tree |
|---|---|---|---|
| --soft | moves | untouched | untouched |
| --mixed (default) | moves | resets | untouched |
| --hard | moves | resets | resets 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
| I want to⦠| Use |
|---|---|
| Discard one file's unstaged edits | git restore <file> |
| Unstage a file | git restore --staged <file> |
| Undo last commit, keep changes | git reset --soft HEAD~1 |
| Throw away last 3 commits entirely | git reset --hard HEAD~3 (local only) |
| Undo a commit that's been pushed | git revert <sha> |
| Fix the message of the last commit | git commit --amend |
| Add forgotten file to last commit | git add <file> && git commit --amend --no-edit |
| Rewrite older commits in series | git rebase -i <base> |
| Recover a "lost" commit | git reflog + git reset --hard <sha> |
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.
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 |
| git branch | List local branches (current marked with *) |
| git branch -a | List all branches β local + remote-tracking |
| git branch -r | List remote-tracking branches only |
| git branch -v | List with last commit SHA + subject |
| git branch -vv | Same as -v + upstream tracking info |
| git branch --merged | Branches fully merged into current |
| git branch --no-merged | Branches 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/main | Set tracking branch |
| git branch --unset-upstream | Remove tracking |
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
# 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
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.
Merging integrates one branch's history into another. The result depends on the shape of the history and the strategy you pick.
| Strategy | When it happens | History shape |
|---|---|---|
| Fast-forward | Target is a direct ancestor of source | Linear β branch pointer just slides |
| 3-way merge | Both branches have new commits | Creates a merge commit with two parents |
| Squash merge | Explicitly requested with --squash | One new commit, no merge parent β original branch unrelated |
| 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 --abort | Abandon a merge in progress, restore pre-merge state |
| git merge --continue | Resume after resolving conflicts |
# 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
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
| git diff --name-only --diff-filter=U | List 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 mergetool | Launch configured visual merge tool |
| git config merge.conflictStyle zdiff3 | Show base version in conflict markers β much more useful |
| git config rerere.enabled true | Reuse Recorded Resolution β Git remembers conflict resolutions and replays them |
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 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.
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."
| git stash push | Stash tracked, modified files (default) |
| git stash push -m "msg" | Stash with a descriptive label |
| git stash push -u | Include untracked files |
| git stash push -a | Include untracked and ignored files |
| git stash push -p | Patch mode β pick hunks to stash |
| git stash push -k | Stash unstaged changes only, keep index intact |
| git stash push -- <path> | Stash only specific files |
| git stash list | Show all stashes |
| git stash show | Summary of latest stash |
| git stash show -p | Full patch for latest stash |
| git stash show -p stash@{2} | Patch for the 3rd stash |
| git stash pop | Apply latest stash + remove from list |
| git stash apply | Apply 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 clear | Delete all stashes β cannot be undone |
| git stash branch <new-branch> | Create a branch starting from the stash's parent & apply it |
# 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 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.
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.
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).
| git remote | List remote names |
| git remote -v | List with URLs (for fetch + for push) |
| git remote show origin | Detailed 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 origin | Drop refs to deleted remote branches |
# 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
# 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
| git push | Push current branch to its tracked upstream |
| git push origin main | Push main to origin |
| git push -u origin <branch> | First push β creates remote branch + sets up tracking |
| git push --all | Push every local branch |
| git push --tags | Push all tags |
| git push origin <tag> | Push one tag |
| git push --follow-tags | Push commits + annotated tags reachable from them |
| git push origin --delete <branch> | Delete the remote branch |
| git push --force-with-lease | Safer force-push β fails if remote moved unexpectedly |
| git push --force | Plain force-push (dangerous on shared branches) |
| git push --dry-run | Show what would happen |
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 -vv | Show local β upstream tracking config |
| git switch --track origin/feature | Create local feature tracking origin/feature |
| git switch feature | Often 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 main | Set tracking after the fact |
--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.
fetch is the safe inspection step. pull is fetch + integrate in one command. When in doubt, fetch first.
| git fetch | Fetch from default remote (usually origin) |
| git fetch origin | Fetch from a named remote |
| git fetch --all | Fetch from every remote |
| git fetch origin main | Fetch a specific branch |
| git fetch --tags | Also fetch new tags |
| git fetch --prune | Remove tracking refs for branches deleted on remote |
| git fetch --prune-tags | Also prune deleted tags |
| git fetch --depth=N | Limit fetch depth (shallow) |
| git fetch --unshallow | Convert a shallow clone to full |
git pull is shorthand for two commands: git fetch then either git merge or git rebase depending on configuration.
| git pull | Default behavior (depends on pull.rebase / pull.ff) |
| git pull --ff-only | Only allow fast-forward; abort otherwise |
| git pull --no-ff | Always create a merge commit |
| git pull --rebase | Rebase local commits on top of remote, no merge commit |
| git pull --rebase=interactive | Rebase + open editor to reorder/squash |
| git pull --autostash | Auto-stash dirty tree before pulling, pop after |
# 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
# 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
| git fetch | Read-only inspection. Updates remote-tracking refs. Your branches don't move. |
| git pull | Fetch + integrate. Your branch moves. Possible conflicts. |
Use git fetch before reviewing what teammates have done; use git pull when you're ready to integrate.
git fetch && git log HEAD..origin/main --oneline to see exactly what's coming before you pull. No surprises.
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.
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).
main, shared release branches) are off-limits. Your own un-pushed feature branch is fair game.
| git rebase <base> | Replay current branch's commits onto base |
| git rebase main | Most 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 --continue | Resume after resolving conflicts |
| git rebase --abort | Bail out, restore pre-rebase state |
| git rebase --skip | Skip the conflicting commit entirely |
| git rebase --autosquash | Honor fixup! / squash! commits automatically |
| git rebase --autostash | Auto-stash dirty tree before rebasing |
| git rebase --rebase-merges | Preserve merge commits during rebase |
# 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
# 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:
| pick | Use 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 / merge | Replicate merge structure β used with --rebase-merges |
# 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.
# Before forcing the push, see what really changed
git rebase main
git range-diff main feature@{1} feature
| Merge | Rebase | |
|---|---|---|
| History | Branchy, true | Linear, simplified |
| Commits | Preserved as-is | Recreated with new SHAs |
| Public-safe | Always | Never on shared branches |
| Conflicts | Once, in merge commit | Possibly multiple times, per replayed commit |
| Best for | Long-lived branches, integration points | Short-lived feature branches, cleaning up local WIP |
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.
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.
| git reflog | HEAD's reflog (newest first) |
| git reflog show <branch> | Reflog for a specific branch |
| git reflog show stash | Stash reflog |
| git reflog -n 20 | Last 20 entries |
| git reflog --date=iso | With 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
git reflog
# Find the SHA from BEFORE the reset
git reset --hard ab12cd3
# 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>
git reflog
# Find HEAD@{N} where N is just before "rebase (start)"
git reset --hard HEAD@{4}
# 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>
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
Git eventually prunes unreachable objects to save space. Defaults:
gc.reflogExpire)gc.reflogExpireUnreachable)git gc --prune=now immediately deletes anything older than the cutoff# 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
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.
# 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>
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-500 | Add worktree at ../hotfix on branch hotfix/login-500 |
| git worktree add -b new-branch ../path main | Create new branch in worktree |
| git worktree add --detach ../inspect <sha> | Detached worktree at a commit |
| git worktree list | List all worktrees |
| git worktree list -v | Verbose listing |
| git worktree list --porcelain | Machine-readable |
| git worktree remove <path> | Remove a worktree (requires clean) |
| git worktree remove --force <path> | Force-remove (loses uncommitted) |
| git worktree prune | Clean up administrative state for deleted worktrees |
| git worktree prune -n | Dry 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 |
# 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
Useful in monorepos: only check out the directories you actually work in. Saves disk space and speeds up status / diff.
| git sparse-checkout init --cone | Initialize in cone mode (recommended; faster) |
| git sparse-checkout set apps/web libs/ui | Replace patterns β only these dirs in working tree |
| git sparse-checkout add libs/api | Add another path |
| git sparse-checkout list | Show current patterns |
| git sparse-checkout reapply | Re-apply (after a checkout that changed the patterns) |
| git sparse-checkout disable | Restore 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
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> --all | Bundle the entire repo |
| git bundle create <file> main..feature | Bundle 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-repo | Clone from a bundle |
| git fetch <file> main | Fetch from a bundle into existing repo |
# 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.
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.
| 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-objects | Stream metadata for every object |
| git rev-parse HEAD | Resolve a refspec to its full SHA |
| git rev-parse --short HEAD | Short SHA |
| git rev-parse main^{tree} | Tree of main's commit |
| git ls-tree HEAD | List entries in HEAD's tree |
| git ls-tree -r HEAD | Recursive β like find |
| git ls-files | Files tracked in the index |
| git ls-files --others --exclude-standard | Untracked, non-ignored files |
| git verify-pack -v <.idx> | Inspect a packfile |
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.
| git update-ref refs/heads/foo <sha> | Move a ref to point at sha |
| git update-ref -d refs/heads/foo | Delete a ref |
| git symbolic-ref HEAD | What does HEAD point at? |
| git symbolic-ref HEAD refs/heads/main | Point HEAD at a branch |
| git for-each-ref | Iterate every ref |
| git for-each-ref --format='%(refname:short) %(committerdate:short)' refs/heads/ | Custom-format branch listing |
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.
# 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
| --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 |
| git config --list | All settings (merged across scopes) |
| git config --list --show-origin | Show 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 --edit | Open the config file in $EDITOR |
| git config --get-regexp <pattern> | Find keys matching a regex |
# ~/.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.
# 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 --"
# ~/.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)"
[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.
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.
| gh auth login | Interactive sign-in (browser flow) |
| gh auth login --hostname <ghe.host> | Sign in to GitHub Enterprise |
| gh auth login --with-token < token.txt | Pipe a PAT for non-interactive auth |
| gh auth status | Verify which accounts you're signed into |
| gh auth refresh --scopes write:packages,read:packages | Add scopes to existing token |
| gh auth token | Print the current token (use carefully) |
| gh auth switch | Switch between configured accounts |
| gh auth logout | Sign out |
| gh repo create | Interactive new-repo wizard |
| gh repo create my-app --public --source=. --push | Create from existing local repo and push |
| gh repo clone owner/repo | Clone (handles SSH/HTTPS automatically) |
| gh repo clone owner/repo -- --depth=1 | Pass flags through to git clone |
| gh repo fork owner/repo | Fork to your account |
| gh repo fork owner/repo --clone --remote | Fork + clone + add upstream remote |
| gh repo view | Open current repo in browser |
| gh repo view --web | Same β explicit |
| gh repo list owner | List repos under a user/org |
| gh repo edit --default-branch main | Change default branch |
| gh repo edit --visibility private | Toggle public/private |
| gh repo delete owner/repo --yes | Delete a repo (irreversible) |
| gh repo archive owner/repo | Archive (read-only) |
| gh pr create | Interactive PR creation from current branch |
| gh pr create --fill | Auto-fill title/body from commits |
| gh pr create --base main --title "..." --body "..." | Non-interactive |
| gh pr create --draft | Open as draft |
| gh pr create --reviewer user1,user2 --label bug | Pre-fill reviewers + labels |
| gh pr list | List open PRs |
| gh pr list --author "@me" | Mine |
| gh pr list --state all --limit 50 | Including closed/merged |
| gh pr view 123 | Show PR details in terminal |
| gh pr view 123 --web | Open in browser |
| gh pr checkout 123 | Check out a PR's branch locally |
| gh pr checkout https://github.com/.../pull/123 | By URL |
| gh pr diff 123 | Show PR diff inline |
| gh pr status | Status of PRs relevant to you |
| gh pr checks 123 | CI / status check results |
| gh pr checks 123 --watch | Live-update until checks complete |
| gh pr review 123 --approve | Approve |
| 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 123 | Mark draft as ready for review |
| gh pr edit 123 --add-label urgent --title "new" | Edit metadata |
| gh pr merge 123 --merge | Merge commit |
| gh pr merge 123 --squash --delete-branch | Squash + cleanup branch |
| gh pr merge 123 --rebase --auto | Auto-merge once checks pass |
| gh pr close 123 | Close without merging |
| gh pr reopen 123 | Reopen |
# 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
| gh issue create --title "..." --body "..." | Create issue |
| gh issue create --template bug_report.md | Use a template |
| gh issue list | List open issues |
| gh issue list --label "bug" --assignee "@me" | Filtered |
| gh issue view 42 | Show issue |
| gh issue comment 42 --body "On it" | Add comment |
| gh issue close 42 | Close |
| gh issue reopen 42 | Reopen |
| gh issue edit 42 --add-label urgent | Edit |
| gh issue develop 42 -c | Create & checkout a branch linked to issue #42 |
# 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
| gh run list | Recent workflow (Actions) runs |
| gh run view <id> --log | Logs for a run |
| gh run watch | Live-watch the most recent run |
| gh run rerun <id> | Rerun a failed workflow |
| gh workflow list | List workflow files |
| gh workflow run <name> | Manually dispatch a workflow |
| gh release view --web | Open releases page |
| gh gist create file.md --public | Quick gist |
| gh codespace list | Manage 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 |
Every repo's README is rendered Markdown. It's the project's homepage on GitHub. Conventions worth following:
CONTRIBUTING.md)LICENSE fileFor collaborative projects, also commit:
.github/CODEOWNERS β auto-assign reviewers.github/PULL_REQUEST_TEMPLATE.md β PR description scaffold.github/ISSUE_TEMPLATE/*.yml β structured issue formsSECURITY.md β vulnerability reportingCONTRIBUTING.md β how to contributeStarting points, not scripture. Mix and match for your team's risk tolerance, deployment cadence, and code-review culture.
# 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
# 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
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')) { ... }
For projects with formal release cycles. Branches:
main β productiondevelop β integration branch for next releasefeature/* β branch from develop, merge backrelease/* β stabilize a release, merge into both main and develophotfix/* β branch from main, merge into bothMore overhead than trunk-based. Use only if you have multiple production versions in the wild simultaneously.
# 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
# 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
# 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 }}
# 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
# 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.
# 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 == ""'
--ff-only by default. Forces an explicit decision (merge vs. rebase) when histories diverge.--force-with-lease, never plain --force. Protects against silently overwriting a teammate's push.rerere. Once you've resolved a conflict once, Git remembers it. Saves hours during long-lived rebases.zdiff3 conflict style. Shows the original alongside both sides β radically easier to resolve.commit.verbose=true. Shows the diff inline when writing your commit message β catches mistakes before they ship..gitignore per repo, not per directory. Easier to audit and maintain.commit.gpgsign=true) for repos that care about authorship integrity..git-blame-ignore-revs for noisy reformatting commits, so blame stays useful.git checkout is overloaded. Modern Git replaces it with git switch (branches) and git restore (files). Use them β fewer ways to misfire.git switch -c..gitignore after committing does nothing. Use git rm --cached to stop tracking.git pull = fetch + merge. If main has diverged and pull.ff is unset, you'll get a surprise merge commit. Set pull.ff=only.--force-with-lease.-u. By default, git stash ignores untracked files. They stay in your working tree if you switch branches.git stash pop can leave the stash dropped on conflict. Use git stash apply when uncertain.git reset --hard destroys uncommitted changes silently. Reflog won't save them β they were never committed. Stash first.git clone has no reflog history. Don't rely on it after cloning.git push --tags or git push --follow-tags, or set push.followTags=true.git clone --recurse-submodules.git revert on a merge commit needs -m. You must specify which parent's history to keep (usually -m 1).core.autocrlf=input on Mac/Linux, true on Windows. Or commit a .gitattributes with * text=auto eol=lf.FOO.md and foo.md are the same file on case-insensitive filesystems but different in Git's index. Set core.ignoreCase=false with caution.git log. By default, dates show in author's TZ. Use --date=local or --date=iso-strict for consistency.| Symptom | First-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 |
git maintenance start for background gc/prefetch.--filter=blob:none) to defer blob downloads.fetch.writeCommitGraph=true for faster git log on big repos.core.fsmonitor=true to avoid scanning the working tree on every command.git repack -ad --depth=50 --window=250 for major space savings.user.signingkey + commit.gpgsign=true + tag.gpgsign=true. Modern Git supports SSH signing too.git log --show-signature / git verify-tag <tag>.gitleaks, trufflehog).git filter-branch) and force-push.