Skip to content

Core Workflow

Ninety percent of Git use is one small loop: change files, inspect what changed, stage the parts you want, commit them. This page covers that loop in depth, then the far more interesting question of how to undo each step.

Terminal window
git status # what changed?
git diff # what exactly changed?
git add src/parser.js # stage it
git diff --staged # confirm what's going in
git commit -m "Fix header parsing"
git log --oneline -5 # confirm it landed

git status answers “where am I and what is different?”. Run it constantly; it is free and it tells you which command to use next.

Terminal window
git status
On branch main
Your branch is up to date with 'origin/main'.
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: src/parser.js
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: src/api.js
Untracked files:
(use "git add <file>..." to include in what will be committed)
notes.txt

Three sections map exactly onto the three areas: staged (index vs last commit), unstaged (working directory vs index), and untracked (files Git has never recorded).

The short form is what you will actually use day to day:

Terminal window
git status -sb
## main...origin/main
M src/parser.js
M src/api.js
MM src/config.js
?? notes.txt

Two columns: left is the index, right is the working directory. So M means staged, M means modified but not staged, and MM means staged and modified again since staging. ?? is untracked, A is a newly added file, D is deleted, R is renamed.

git add copies the current contents of a path into the index.

Terminal window
git add file.txt # one file
git add src/ # a directory, recursively
git add . # everything under the current directory
git add -A # everything in the repo, including deletions
git add -u # only files Git already tracks (updates + deletions)

git add . and git add -A behave identically in Git 2.x when run from the repository root. From a subdirectory, . is limited to that subdirectory while -A covers the whole repo.

This is the feature that makes the index worth having. git add -p walks you through each hunk (a contiguous block of changed lines with a few lines of context) and asks whether to stage it.

Terminal window
git add -p src/api.js
@@ -14,7 +14,9 @@ function request(url) {
- return fetch(url);
+ console.log('DEBUG', url);
+ return fetch(url, { headers });
}
Stage this hunk [y,n,q,a,d,s,e,?]?

The prompts you will use:

Key Effect
y Stage this hunk.
n Skip this hunk.
s Split into smaller hunks (only if there is a gap of context lines).
e Edit the hunk manually — stage individual lines.
q Quit; keep what you have staged so far.
a / d Stage / skip this hunk and all remaining hunks in the file.

Use it to split a session of mixed work into clean commits, and to catch debug statements before they ship. git add -p with no path walks every modified tracked file.

A commit takes whatever is in the index and writes it as a new snapshot, with the current commit as its parent, then moves the current branch pointer to it.

Terminal window
git commit -m "Add retry logic to the HTTP client"
git commit # opens your editor for a multi-line message
git commit -a -m "..." # stage all *tracked* modified/deleted files, then commit

-a never adds untracked files. It is a shortcut for git add -u && git commit.

The convention Git itself and virtually all projects follow:

Short subject line, imperative mood, under ~50 chars
Blank line, then a body wrapped at ~72 columns. Explain *why* this
change exists and what problem it solves. The diff already shows
what changed; it cannot show your reasoning, the alternatives you
rejected, or the bug report that prompted it.
Fixes #142

Rules that matter:

  • Imperative mood — “Add retry logic”, not “Added” or “Adds”. It reads as an instruction: apply this commit and it will add retry logic. This matches Git’s own generated messages (“Merge branch…”, “Revert …”).
  • Blank line after the subject. Git treats the first paragraph as the subject; tools like git log --oneline, GitHub, and email formatting rely on that blank line.
  • Explain why, not what. The diff is the what.
  • Do not end the subject with a period; it is a title, not a sentence.

git diff — three questions, three commands

Section titled “git diff — three questions, three commands”

The confusion around diff disappears once you name what it is comparing.

Command Compares
git diff Working directory vs index — “what have I not staged yet?”
git diff --staged Index vs HEAD — “what will this commit contain?”
git diff HEAD Working directory vs HEAD — “everything I’ve changed since the last commit”.
git diff <commit> Working directory vs that commit.
git diff A B Commit A vs commit B.
git diff main..feature Same as git diff main feature.
git diff main...feature feature vs the merge base — “what does this branch add?”

--cached is an exact synonym for --staged.

The three-dot form is the one you want when reviewing a branch: it ignores everything that landed on main after your branch diverged, so you see only your own changes.

Useful modifiers:

Terminal window
git diff --stat # summary: files and +/- counts
git diff --name-only # just the filenames
git diff -w # ignore whitespace changes
git diff --word-diff # highlight changed words, not whole lines
git diff -- src/parser.js # limit to a path
Terminal window
git diff --stat
src/api.js | 12 ++++++++----
src/parser.js | 3 +--
2 files changed, 11 insertions(+), 6 deletions(-)
Terminal window
git log # full messages, newest first
git log --oneline # one line per commit
git log --oneline --graph --decorate # ASCII graph with branch/tag names
git log --oneline --graph --all # ...including all branches

--decorate has been on by default since Git 2.13, but including it is harmless.

Terminal window
git log --oneline --graph --all -8
* 9c1f0a3 (HEAD -> feature) Add pagination to results
* 4e2b7d1 Extract query builder
| * 1a2b3c4 (origin/main, main) Bump dependencies
|/
* 7d8e9f0 Rename config keys
* 3f2a1b0 Initial commit

Filtering, which is where log earns its keep:

Terminal window
git log -5 # last 5 commits
git log --author="Ada" # by author
git log --since="2 weeks ago" # by date
git log --grep="parser" # search commit messages
git log -S"functionName" # commits that add/remove that string ("pickaxe")
git log -p # show the diff of each commit
git log --follow -- src/parser.js # history of one file, across renames
git log main..feature # commits on feature not on main
git log --merges # only merge commits

-S is the underrated one: it finds the commit that introduced or removed a given string anywhere in the codebase, which is usually how you answer “when did this function appear?”.

Inspect a single commit:

Terminal window
git show HEAD
git show 9c1f0a3 --stat
git show HEAD:src/parser.js # the file's contents at that commit

You almost never type a full hash. Git accepts many forms:

Reference Means
HEAD The commit you currently have checked out.
HEAD~1, HEAD~ Its first parent (one commit back).
HEAD~3 Three commits back along first parents.
HEAD^ First parent — same as HEAD~1.
HEAD^2 The second parent — only meaningful on a merge commit.
9c1f0a3 An abbreviated hash; any unambiguous prefix works (usually 7+ chars).
main, v1.2.0 A branch or tag name resolves to the commit it points at.
origin/main The remote-tracking branch.
@{-1} The previously checked-out branch.
HEAD@{2} Where HEAD was two moves ago — see the reflog.

The ~ versus ^ distinction only matters at merges: ~ walks back along first parents, ^ selects which parent.

A ── B ── M M^ = B (first parent)
/ M^2 = D (second parent)
C ── D M~2 = A (back two, via first parents)

A .gitignore file lists patterns for files Git should not track or report as untracked. Commit it — it is part of the project.

.gitignore
# Dependencies
node_modules/
# Build output
dist/
*.tsbuildinfo
# Logs
*.log
npm-debug.log*
# Environment and secrets
.env
.env.*
!.env.example
# OS and editor cruft
.DS_Store
.idea/
*.swp
Pattern Matches
build Any file or directory named build, at any depth.
build/ Only directories named build, at any depth.
/build Only build in the same directory as this .gitignore (anchored).
*.log Any .log file at any depth. * does not cross /.
doc/*.txt .txt files directly in doc/, not in doc/sub/.
doc/**/*.txt .txt files anywhere under doc/. ** crosses directories.
!important.log Negation — re-include a file an earlier pattern excluded.
[0-9]*.tmp Character ranges work, as in shell globbing.
# comment A comment. Escape a literal # as \#.

Two rules people trip over:

  1. The last matching pattern wins. Ordering matters, so put negations after the broad pattern they carve an exception out of.

  2. You cannot re-include a file inside an excluded directory. If logs/ is ignored, Git never descends into it, so !logs/keep.log has no effect. Exclude the contents instead:

    logs/*
    !logs/keep.log

Git consults several ignore sources. Later entries here override earlier ones:

  1. core.excludesFile — your personal global ignores.
  2. .git/info/exclude — repo-local, not committed, not shared.
  3. .gitignore files, from the repository root downwards. A .gitignore in a deeper directory overrides one above it.

Set up a global ignore for editor and OS files so you never put them in a project’s .gitignore:

Terminal window
git config --global core.excludesFile ~/.gitignore_global
printf '.DS_Store\n.idea/\n*.swp\n' >> ~/.gitignore_global

Debug a surprising ignore with:

Terminal window
git check-ignore -v dist/app.js
# => .gitignore:5:dist/ dist/app.js

It prints the file, line number, and pattern responsible.

Terminal window
git rm old-file.txt # delete from disk AND stage the deletion
git rm --cached secrets.json # stop tracking, keep the file on disk
git rm -r old-directory/ # recursive
git rm -f modified.txt # force, when the file has unstaged changes

Deleting with your shell (rm file.txt) works too — Git notices, and git add -A or git add -u stages the deletion. git rm just does both steps at once.

Terminal window
git mv old-name.js new-name.js

git mv is pure convenience; it is exactly equivalent to renaming the file and running git rm old-name.js && git add new-name.js.

Four commands, four different jobs. Picking the right one is entirely about which of the three areas you want to change.

git restore (Git 2.23+) is the modern, unambiguous way to throw away file changes.

Terminal window
# Discard unstaged changes to a file — copy from the index over the working directory
git restore src/api.js
# Unstage a file — copy from HEAD over the index; working directory untouched
git restore --staged src/api.js
# Do both — discard staged and unstaged changes
git restore --staged --worktree src/api.js
# Restore a file as it was in an older commit (into the working directory)
git restore --source=HEAD~3 src/api.js
# Everything
git restore .

Older tutorials use git checkout -- <file> and git reset HEAD <file> for these two jobs. They still work, but checkout is heavily overloaded (it also switches branches), which is exactly why restore and switch were introduced.

git reset <commit> moves the current branch to point at <commit>. The mode flag decides whether the index and working directory follow.

Mode Moves branch Resets index Resets working directory
--soft yes no no
--mixed (default) yes yes no
--hard yes yes yes

Read it as a ladder: each mode does everything the one above it does, plus one more area.

Terminal window
# Undo the last commit but keep everything staged — the classic "redo that commit"
git reset --soft HEAD~1
git commit -m "A better message and the file I forgot"
# Undo the last commit and unstage everything; changes stay in your files
git reset HEAD~1
# Undo the last commit and throw away all its changes
git reset --hard HEAD~1

git reset with a path behaves differently: it never moves HEAD, it only copies from the given commit into the index. git reset HEAD <file> is the old way to unstage.

reset rewrites history: it makes commits unreachable. That is fine locally and dangerous on a branch other people have pulled.

git revert instead creates a new commit whose diff is the inverse of the target commit. History grows rather than changes, so everyone’s clone stays consistent.

Terminal window
git revert 9c1f0a3 # revert one commit; opens an editor for the message
git revert --no-edit HEAD # use the default message
git revert HEAD~3..HEAD # revert a range (newest first)
git revert -n 9c1f0a3 # stage the inverse but don't commit yet

Reverting a merge commit needs -m to say which parent’s line of development to keep — almost always 1, the branch you merged into:

Terminal window
git revert -m 1 <merge-commit>

Rule of thumb: if the commit has been pushed and others may have it, use revert. If it is purely local, reset is cleaner.

git commit –amend — fix the last commit

Section titled “git commit –amend — fix the last commit”

Replaces the most recent commit with a new one built from the current index.

Terminal window
# Fix the message only
git commit --amend -m "Fix header parsing, not headers parsing"
# Add a forgotten file to the previous commit
git add src/forgotten.js
git commit --amend --no-edit
# Change the author
git commit --amend --author="Ada Lovelace <ada@example.com>"

--no-edit keeps the existing message without opening an editor.

Situation Command
Staged a file by mistake git restore --staged <file>
Want to throw away edits to a file git restore <file>
Forgot a file in the last commit git add <file> then git commit --amend --no-edit
Bad message on the last commit git commit --amend
Last 3 commits should be one git reset --soft HEAD~3 then git commit
Want to erase the last local commit entirely git reset --hard HEAD~1
Need to undo a commit already pushed git revert <commit>
Want to see an old version of a file git show <commit>:<path>
  • git status -sb shows index state (left column) and working-directory state (right column).
  • git add -p is the reason the staging area exists; use it to build reviewable commits.
  • diff = working vs index, diff --staged = index vs HEAD, diff HEAD = working vs HEAD, diff main...feature = only what the branch adds.
  • .gitignore affects untracked files only, last matching pattern wins, and you cannot re-include inside an excluded directory.
  • restore touches files, reset moves the branch pointer, revert adds an inverse commit, --amend replaces the last commit.
  • Anything committed is recoverable; anything not committed is not.