Everyday Git: status, diff, log and good commits
Most of a developer's Git day is four questions: what did I change, what am I about to commit, what happened before, and who changed this and why. This lesson gives you the commands that answer each one, and the habits that make the answers useful months later.
By the end you can
- Read a diff, and know exactly what
git diffandgit diff --stagedeach compare - Stage part of a file with
git add -p, so one commit holds one change - Search history with
git logoptions,git showand the pickaxe (-S) - Write a
.gitignorethat keeps secrets, dependencies and build output out of the repo - Write Conventional Commit messages, and check them automatically with a small linter
The everyday picture
Many stores keep a shop diary at the manager's desk. Each entry is short: "14 Sep, 10:12, moved rice to aisle 2 because the delivery door blocks aisle 6. Signed, shift manager." Months later, when head office asks why rice is in aisle 2, the answer is on one line, with a date and a name.
A bad diary says "changes made" every day. It is still a diary, but it answers nothing. Git history is the team's shop diary. Each commit is an entry, and the commit message is the line someone will read in six months while trying to understand a strange discount. The commands in this lesson read the diary; the habits make it worth reading.
Reading a diff
A diff is how Git shows a change. Git computes it on demand by comparing two snapshots (Snapshots and the three areas). Here is one real diff from this lesson's project, the kind you will read in every pull request:
diff --git a/src/discount.js b/src/discount.js
index 8e64b13..8bfea70 100644
--- a/src/discount.js
+++ b/src/discount.js
@@ -3,7 +3,7 @@ const MEMBER_RATE = 0.07; // 7% off for loyalty members (festival offer)
const MAX_DISCOUNT_CENTS = 2500; // never more than $25 per order
export function discountCents(totalCents, isMember) {
- if (!isMember) return 0;
+ if (!isMember || !isEligible(totalCents)) return 0;
return Math.min(Math.round(totalCents * MEMBER_RATE), MAX_DISCOUNT_CENTS);
}
The first four lines name the file: a/ is the old version, b/ the new one, and the index line gives the short IDs of the two stored file contents. The @@ -3,7 +3,7 @@ line starts a hunk: in the old file, 7 lines starting at line 3; in the new file, also 7 lines starting at line 3. After it Git repeats the nearest line above that looks like a function or a declaration, to help you see where you are. Lines starting with - were removed, + were added, and lines starting with a space are unchanged context, three above and below by default. A changed line shows as one removal plus one addition. VS Code shows the same thing side by side in its diff editor, with red and green.
Quick check
A hunk header reads @@ -10,6 +10,8 @@. What happened in that hunk?
The numbers are "start line, line count" for the old file (-) and the new file (+). Both regions include unchanged context lines, so the hunk does not say 6 lines were removed; it says the region grew from 6 lines to 8. The - and + lines inside the hunk show exactly which ones changed.
git diff and git diff --staged compare different things
This is the single most useful fact about diffs, and the one most people get wrong. Remember the three areas from Snapshots and the three areas:
git diffcompares the working folder with the staging area. It shows what you changed but have not staged. Aftergit add, those lines disappear from it.git diff --staged(same as--cached) compares the staging area with the last commit. It shows exactly whatgit commitwould save now.git diff HEADcompares the working folder with the last commit: everything, staged or not.
So the habit before every commit is git diff --staged. It is the only command that shows exactly what goes into the commit. Add --stat to any of them for a one-line-per-file summary instead of the full lines.
Quick check
You staged a fix in src/cart.js, then edited the same file again to try an idea. Which command shows only the new, unstaged idea?
git diff compares your working file with the staged version, so the staged fix cancels out and only the newer edit remains. --staged would show only the fix, and HEAD would show the fix and the idea mixed together.
git diff shows edits you have not staged; git diff --staged shows exactly what the next commit will save; git diff HEAD shows both together. After git add, a change moves from the first to the second.Staging part of a file with git add -p
Real work rarely fits one commit. You fix a bug and, in the same file, add a debug console.log. git add -p (patch mode) walks through each hunk and asks Stage this hunk [y,n,q,a,d,...]?. The keys you need: y stage this hunk, n skip it, s split it into smaller hunks when two changes are close together, q quit, ? help. The result is a file that is partly staged, shown as MM in git status -s, and a commit that contains only the fix.
VS Code does the same with the mouse. In the Source Control view, click a changed file to open the diff editor, select the lines, and use Git: Stage Selected Ranges from the Command Palette or the stage button in the diff editor's gutter. Many developers learn add -p anyway, because it makes you read every hunk before it goes in, and that is where stray debug lines and passwords get caught.
Reading history: log, show and the pickaxe
git log lists commits newest first. Its options turn it into a search tool:
--oneline: one line per commit, short ID and subject. Add--graph --allto draw branches as lines, which matters from Branches and merging on.-p: show the diff of each commit.--statshows only the file list.--author="Dev Two",--since="2026-09-20",--until=...: filter by who and when.-- src/discount.js: only commits that touched that file. The--separates paths from options.--format='%h %an %ad %s' --date=short: your own columns: short ID, author, date, subject.
git show <commit> prints one commit with its message and diff. git show <commit>:path prints a file as it was then.
The most powerful search is the pickaxe: git log -S "0.07" lists only the commits where the number of times 0.07 appears in a file changed. That is, the commits that added or removed it. Add -p to see the change itself. -G "MEMBER_RATE" is the regular-expression cousin: commits whose diff has a changed line matching the pattern, even if the count stayed the same. And git blame -L 2,2 file shows who last changed line 2, in which commit and when. For a product manager, this is how "when did the member discount become 7%, and why?" gets answered in one minute instead of a meeting. Reading code like a detective takes this much further.
What never goes in: .gitignore
Some files must never be committed. A .gitignore file lists patterns, and Git stops showing matching files as untracked, so git add . skips them.
.envand other secret files. Anything committed is copied to every clone, and to GitHub when you push. Deleting it in a later commit does not remove it from history; the old commit still holds it. A password that was ever pushed must be treated as leaked and changed. Commit a.env.examplewith the variable names and fake values instead.node_modules/. Often hundreds of megabytes, different per operating system, and fully recreated bynpm installfrompackage.jsonand the lockfile (Node.js and npm). Commit the lockfile, never the folder.- Build output and logs (
dist/,coverage/,*.log): generated from the source, so they cause noisy diffs and merge conflicts. - OS and editor files (
.DS_Store,Thumbs.db, personal.vscodefiles, as in VS Code for teams).
Patterns: dist/ matches a folder anywhere, *.log any file ending in .log, a leading ! makes an exception (!.env.example). Later lines win, so an exception must come after the rule it overrides. One trap: .gitignore only affects untracked files. If .env was already committed, adding it to .gitignore changes nothing; you must also run git rm --cached .env, which stops tracking it but keeps your local copy. git check-ignore -v file tells you which rule ignores a file. GitHub keeps good starting templates at github.com/github/gitignore.
Quick check
Your .gitignore has the line .env.*, which also hides .env.example. How do you keep .env.example in the repo?
A leading ! re-includes a path, and the last matching line wins, so the exception goes after the rule. Listing the plain name above the rule would only ignore it again, and exceptions are a normal part of .gitignore.
Good commits: small, focused, well described
A good commit does one thing, completely. "Fix the eligibility check" is one commit; "fix eligibility, rename variables, update the README, bump a library" is four. Small commits are easier to review, safer to revert (Undo anything) and much easier to search. A useful test: if the subject line needs the word "and", it is probably two commits.
Many teams write messages in the Conventional Commits format: type(scope): description. The specification itself defines feat (a new feature) and fix (a bug fix). The widely used Angular-based convention adds docs, style, refactor, perf, test, build, ci and chore. A ! after the type, or a BREAKING CHANGE: footer, marks a breaking change. Because the format is machine-readable, tools can generate changelogs and version numbers from it: fix means a patch release, feat a minor one, a breaking change a major one (Branching strategies, releases and versions).
Write the subject in the imperative, as if completing "this commit will...": fix: no member discount on orders below $30. Keep it to about 50 characters; VS Code's commit box warns past that if you turn on the git.inputValidation setting. If the why is not obvious, add a blank line and a body: "Finance asked for this on 5 Oct because small baskets were losing money." Code shows what changed; only the message can say why.
Quick check
Which subject line follows Conventional Commits and the imperative style?
It has a known type, an optional scope in brackets, a colon and a short imperative description. The first is past tense with no type, the third uses the scope as if it were the type and says nothing, and the fourth invents its own format, which tools cannot read.
git log --oneline answers the question, and each commit can be reverted alone.Build it
A realistic session on a small discounts repo for the Partner app: change two files, stage one hunk, commit with a Conventional Commit, write a .gitignore, then search history. All output is from Git 2.55. The Practice section afterwards has a commit-message linter to write and test in your browser.
-
Start from a repo with some history
Three earlier commits by three people already exist. If you are following along, make a similar repo with your own commits, or clone any small project. Money is in integer cents throughout.
Terminalcd ~/work/discounts git log --onelineOutpute4a33d3 (HEAD -> main) feat: raise member discount to 7% for the festival 84aca99 feat: add dollar formatter for receipts 170f204 feat: add member discount with $25 cap -
Make two changes in one file and one in another
In
src/discount.js, apply the eligibility check (the real fix) and add a debug line you do not want to commit. Insrc/receipt.js, add a new function.JavaScript// src/discount.js (the two edits) if (!isMember || !isEligible(totalCents)) return 0; // the fix ... export function isEligible(totalCents) { console.log('isEligible', totalCents); // TODO remove // debug only return totalCents >= 3000; } // src/receipt.js (added at the end) export function discountLine(cents) { return `Member discount: -${dollars(cents)}`; }Terminalgit status -s git diff --statOutputM src/discount.js M src/receipt.js src/discount.js | 3 ++- src/receipt.js | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) -
Stage only the fix with git add -p
Git shows each hunk of
discount.jsand waits for a key. Answer y for the first hunk (the fix) and n for the second (the debug line).Terminalgit add -p src/discount.jsOutput@@ -3,7 +3,7 @@ const MEMBER_RATE = 0.07; // 7% off for loyalty members (festival offer) const MAX_DISCOUNT_CENTS = 2500; // never more than $25 per order export function discountCents(totalCents, isMember) { - if (!isMember) return 0; + if (!isMember || !isEligible(totalCents)) return 0; return Math.min(Math.round(totalCents * MEMBER_RATE), MAX_DISCOUNT_CENTS); } (1/2) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]? y @@ -12,5 +12,6 @@ export function discountCents(totalCents, isMember) { * This protects the margin on small baskets. */ export function isEligible(totalCents) { + console.log('isEligible', totalCents); // TODO remove return totalCents >= 3000; } (2/2) Stage this hunk [y,n,q,a,d,K,J,g,/,e,p,P,?]? nThe first lines of the output, the
diff --githeader, are left out here. If two changes sit in one hunk, press s to split it. -
Check what you are about to commit
Compare the two diffs. The staged one has only the fix; the unstaged one has the debug line and the receipt change.
Terminalgit status -s git diff --staged --stat git diff --statOutputMM src/discount.js M src/receipt.js src/discount.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) src/discount.js | 1 + src/receipt.js | 4 ++++ 2 files changed, 5 insertions(+) -
Commit the fix, drop the debug line, commit the receipt
Commit the fix with a Conventional Commit subject. The single quotes matter: inside double quotes the shell would treat
$30as a variable and drop it. Then throw away the debug line withgit restore(it is unstaged, so this is safe for the committed fix) and commit the receipt change separately. Two changes, two commits.Terminalgit commit -m 'fix: no member discount on orders below $30' git restore src/discount.js git add src/receipt.js git commit -m "feat: show member discount line on receipt" git status -s git log --onelineOutput[main dbf1e0d] fix: no member discount on orders below $30 1 file changed, 1 insertion(+), 1 deletion(-) [main 0b081c7] feat: show member discount line on receipt 1 file changed, 4 insertions(+) 0b081c7 (HEAD -> main) feat: show member discount line on receipt dbf1e0d fix: no member discount on orders below $30 e4a33d3 feat: raise member discount to 7% for the festival 84aca99 feat: add dollar formatter for receipts 170f204 feat: add member discount with $25 capgit status -sprinted nothing: the working folder is clean. For a message with a body, rungit commitwithout-m; VS Code opens (thanks tocore.editorfrom Snapshots and the three areas), you write the subject, a blank line and the body, then save and close the tab. -
Write a .gitignore for a Node project
Create the files a real project accumulates, and look at what Git would offer to commit.
Terminalgit status -sOutput?? .env ?? dist/ ?? logs/ ?? node_modules/Every one of these must stay out. Create
.gitignoreand a safe.env.example:Output# dependencies: npm install recreates them node_modules/ # build output and test coverage dist/ coverage/ # logs *.log logs/ # secrets: never commit them; commit .env.example instead .env .env.* !.env.example # editor and OS files .vscode/* !.vscode/settings.json !.vscode/extensions.json .DS_Store Thumbs.dbINIDATABASE_URL=postgres://user:password@localhost:5432/partner_shop SMS_API_KEY=Terminalgit status -s git check-ignore -v .env dist/main.js node_modules/left-pad/index.js git add .gitignore .env.example git commit -m "chore: ignore secrets, dependencies and build output"Output?? .env.example ?? .gitignore .gitignore:13:.env .env .gitignore:5:dist/ dist/main.js .gitignore:2:node_modules/ node_modules/left-pad/index.js [main 7866e0a] chore: ignore secrets, dependencies and build output 2 files changed, 24 insertions(+) create mode 100644 .env.example create mode 100644 .gitignorecheck-ignore -vprints the file, line and pattern that ignored each path. If a file you expected to be ignored is missing from its output, it is either tracked already (see the warning above) or not matched. -
Search history with log options
Terminalgit log --oneline --author="Dev Two" git log --oneline --since="2026-09-20" --until="2026-10-03" git log --format='%h %an %ad %s' --date=shortOutpute4a33d3 feat: raise member discount to 7% for the festival e4a33d3 feat: raise member discount to 7% for the festival 84aca99 feat: add dollar formatter for receipts 7866e0a Your Name 2026-10-07 chore: ignore secrets, dependencies and build output 0b081c7 Your Name 2026-10-07 feat: show member discount line on receipt dbf1e0d Your Name 2026-10-07 fix: no member discount on orders below $30 e4a33d3 Dev Two 2026-10-02 feat: raise member discount to 7% for the festival 84aca99 Your Name 2026-09-28 feat: add dollar formatter for receipts 170f204 Dev One 2026-09-14 feat: add member discount with $25 capgit log --oneline --graph --allshows the same list with a line of*marks on the left; with one branch it is a straight line, and from Branches and merging it shows branches splitting and joining. -
Find who changed a value, and when
The question: when did the member rate become 7%? The pickaxe finds the commit that added the text
0.07;-pshows its diff, andgit blameconfirms who last touched line 2.Terminalgit log -p -S "0.07" -- src/discount.js git blame -L 2,2 src/discount.jsOutputcommit e4a33d393f009a99e1628c5ff161d8c648bb9ed6 Author: Dev Two <dev2@example.com> Date: Fri Oct 2 18:20:00 2026 +0600 feat: raise member discount to 7% for the festival diff --git a/src/discount.js b/src/discount.js index 7fb4550..8e64b13 100644 --- a/src/discount.js +++ b/src/discount.js @@ -1,5 +1,5 @@ // discount.js: discount rules for Partner app orders -const MEMBER_RATE = 0.05; // 5% off for loyalty members +const MEMBER_RATE = 0.07; // 7% off for loyalty members (festival offer) const MAX_DISCOUNT_CENTS = 2500; // never more than $25 per order export function discountCents(totalCents, isMember) { e4a33d39 (Dev Two 2026-10-02 18:20:00 +0600 2) const MEMBER_RATE = 0.07; // 7% off for loyalty members (festival offer)Blame only shows the last change to a line, so a later reformat would hide the real author; the pickaxe looks through all of history. In VS Code, the Status Bar shows blame for the current line, and the Timeline view in the Explorer lists every commit that touched the open file. The free GitLens extension (
eamodio.gitlens) adds richer blame and history views; it is popular, and it is still an extension, so apply the checks from VS Code essentials.
| Question | Command | In VS Code |
|---|---|---|
| What did I change but not stage? | git diff | Source Control view, Changes, click a file |
| What will this commit contain? | git diff --staged | Source Control view, Staged Changes |
| Stage part of a file | git add -p | Git: Stage Selected Ranges |
| What happened recently? | git log --oneline --graph --all | Source Control Graph |
| What did one commit change? | git show <id> | Click the commit in the graph |
| Who last changed this line? | git blame -L 2,2 file | Status Bar blame |
| When did this text appear? | git log -S "text" -p | (no built-in view) |
| Why is this file ignored? | git check-ignore -v file | Greyed out in the Explorer |
Bring it to your team
You can check most of these by reading a PR's commit list, without reading the code.
- "Can each commit in this PR be described in one line without the word 'and'?"
- "Do our commit messages explain why, at least in the body, for anything a future reader could not guess?"
- "Is
.envin.gitignorein every repo, and do we have a.env.examplethat lists every variable?" - "If I ask when this business rule changed, can someone answer from
git login a minute?" - "Do we use Conventional Commits, and does a check enforce it, so the changelog and version number can be generated?"
Red flags
- Subjects like "update", "fix", "changes" or "wip" in the main branch history.
node_modules,distor a.envfile appearing in a diff.- Leftover
console.loglines and commented-out code in commits. - One commit per day containing everything that changed that day.
Practice
Write a commit-message linter
Teams often run a small check that rejects messages that do not follow the agreed format. Write lintCommit(message). It returns an array of problem codes, in this order, or an empty array when the message is fine:
'type': the first line does not start with a known type (feat,fix,docs,style,refactor,perf,test,build,ci,chore), an optional scope in brackets, an optional!, then a colon, a space and a description.'too-long': the first line is longer than 72 characters.'period': the first line ends with a full stop.'blank-line': there is a second line and it is not empty (the body must be separated by a blank line).
const TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore'];
function lintCommit(message) {
const errors = [];
const lines = message.split('\n');
const subject = lines[0];
// check the subject, then the line after it
return errors;
}
test('good messages pass, with or without scope, ! and body', () => {
expect(lintCommit('fix: no member discount on orders below $30')).toEqual([]);
expect(lintCommit('feat(receipt)!: show discount line')).toEqual([]);
expect(lintCommit('fix: round to cents\n\nFinance asked for this on 5 Oct.')).toEqual([]);
});
test('a missing or unknown type is reported as type', () => {
expect(lintCommit('Fixed the receipt bug')).toEqual(['type']);
expect(lintCommit('update: stuff')).toEqual(['type']);
expect(lintCommit('fix:no space after the colon')).toEqual(['type']);
});
test('a subject over 72 characters is too-long', () => {
const long = 'feat: ' + 'x'.repeat(67);
expect(long.length).toBe(73);
expect(lintCommit(long)).toEqual(['too-long']);
});
test('a full stop at the end is reported as period', () => {
expect(lintCommit('docs: explain the discount rules.')).toEqual(['period']);
});
test('a body right under the subject is blank-line, and problems keep their order', () => {
expect(lintCommit('fix: round to cents\nbecause finance asked')).toEqual(['blank-line']);
expect(lintCommit('changed stuff.\nmore')).toEqual(['type', 'period', 'blank-line']);
});
Show a solution
const TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore'];
function lintCommit(message) {
const errors = [];
const lines = message.split('\n');
const subject = lines[0];
// type, optional (scope), optional !, then ": " and a description
const m = subject.match(/^([a-z]+)(\([a-z0-9-]+\))?!?: \S/);
if (!m || !TYPES.includes(m[1])) errors.push('type');
if (subject.length > 72) errors.push('too-long');
if (subject.endsWith('.')) errors.push('period');
if (lines.length > 1 && lines[1] !== '') errors.push('blank-line');
return errors;
}Rewrite a bad history
Here are four real-looking commits from the left side of the second diagram, with what each one actually did. Write a Conventional Commit subject for each, and say which one should have been split into two commits.
update changed the receipt footer text to "Thank you"
fixes and changes fixed VAT rounding on receipts AND renamed calcTotal to cartTotal
wip added a test for the 7% member rate
final changes 2 removed node_modules from the repo and added it to .gitignore- Every subject has a type from the list, a colon and an imperative description
- The second commit is split into a
fixand arefactor - The test commit uses
test, and the node_modules commit useschore(orbuild) - No subject contains "and", ends with a full stop, or is longer than about 50 characters
Show a model answer
feat(receipt): thank the customer in the footer
fix(receipt): round VAT to whole cents
refactor: rename calcTotal to cartTotal
test: cover the 7% member rate
chore: stop tracking node_modules"fixes and changes" did two unrelated things, so it becomes two commits: the fix can now be reverted or cherry-picked without the rename. The footer change could also be style or chore if your team does not treat copy changes as features; what matters is that the team agrees.
Check yourself
You ran git add src/discount.js. Now git diff prints nothing. Does that mean you have no changes?
After git add, the working file and the staged file are the same, so git diff has nothing to show. The change now sits between the staging area and the last commit, which is exactly what git diff --staged compares. git add never commits.
A developer committed .env with a real API key and pushed it. The next day he adds .env to .gitignore. What is true now?
.gitignore only affects untracked files, and never history. The pushed commit still contains the key in every clone. Change the key first, then stop tracking the file with git rm --cached. GitHub shows dotfiles like any other file.
Which command answers "which commit first introduced the value 0.07 in discount.js?" most directly?
The pickaxe lists commits that added or removed that exact text, across all history. Blame shows only the last commit to touch each line, which may be a later reformat. A diff against an arbitrary older commit shows the total change, not which commit made it.
Flashcards
Key takeaways
git diffshows unstaged edits;git diff --stagedshows exactly what the next commit will save. Read the second one before every commit.git add -p(or Stage Selected Ranges) puts only the hunks you choose into a commit, and makes you read each one.git logwith--oneline,--author,--since,-pand-Sturns history into a searchable record;git blameshows the last change per line..gitignorekeeps.env,node_modulesand build output out, but only for untracked files. A pushed secret is leaked.- One change per commit, with a Conventional Commit subject in the imperative and a body that says why.