Git, Testing and Code Review Basics
Open in the interactive app: practice, quiz, mock interview →In one minute
- Git records snapshots of your whole project, each identified by a hash of its content and its parents. Branches are just names pointing at snapshots, which is why branching is cheap and history is hard to change silently.
- Tests are code that checks code. A healthy suite is shaped like a pyramid: many fast unit tests for logic, fewer integration tests for the places your code meets a database, a queue or another service, and a handful of end-to-end tests for the critical user journeys.
- Unit test decisions and calculations; integration test boundaries. If a bug would come from your logic, a unit test catches it. If it would come from SQL, serialisation, configuration or a network call, only an integration test will.
- Code review is a second pair of eyes on correctness, design and risk before code ships. Small pull requests get better reviews than large ones.
- Code written by an AI agent needs the same review as code from a new teammate, with extra attention to tests that were weakened, APIs that do not exist and changes outside the task. The problem shape is trust but verify.
How it works
Git stores snapshots
A commit is a snapshot of every file in the project at one moment, plus a message, an author, a timestamp and a pointer to its parent commit (or two parents, for a merge). Git names each commit by a hash of all of that, such as 9b1f411 (Pro Git, Git Objects). Change one byte anywhere, in a file or in a parent, and the hash changes. History is therefore a chain of commits where each one vouches for everything before it.
You work in three places: the working directory (files you edit), the staging area or index (what the next commit will contain, filled by git add), and the repository (commits, created by git commit). git status shows the difference between them; git diff shows the lines that changed.
Branches, merges and pull requests
A branch is a movable name for a commit. git switch -c feature creates a name pointing at the current commit; each new commit moves it forward. Meanwhile main moves forward with other people's work. To combine them, Git finds the merge base, the most recent commit both branches share, and compares each side's changes against it. Changes to different places combine automatically. Changes to the same lines in different ways are a conflict that a human must resolve. A rebase replays your commits on top of the latest main instead, giving a straight history at the cost of rewriting your commits' hashes, so only rebase branches nobody else has pulled.
Teams rarely merge straight into main. You push a branch and open a pull request (PR), continuous integration (CI) runs the tests, a reviewer reads the change, and only then is it merged. Branch strategy is a team choice too: Software Engineering at Google (Ch 16, Version Control and Branch Management) explains why Google keeps one trunk, and Pro Git's branching chapters show the workflow most other teams use.
What a test is
A test sets up a situation, runs the code, and asserts the result: "given a cart with two items at 10 and 15, the total is 25". A good test fails for one clear reason, runs fast, gives the same answer every time, and reads like a description of behaviour. Tests that depend on the current time, random numbers, test order or a shared database are flaky: they fail sometimes without a code change, and teams soon learn to ignore them. Inject the clock and the random source as parameters, as every practice exercise in this course does.
The test pyramid
The shape comes from Mike Cohn's Succeeding with Agile: lots of small, fast unit tests, some coarser service tests, very few tests through the UI. Fowler adds a working rule: if a higher-level test finds a bug and no lower-level test fails, write the lower-level test too (Fowler). An inverted pyramid, mostly end-to-end tests, is the "ice-cream cone" he warns against. Google's testing blog makes the same argument for its own teams (Google Testing Blog); the Software Engineering at Google book covers sizing tests by resources used, not by name (Ch 11 Testing Overview, Ch 14 Larger Testing).
| Level | What it exercises | Typical speed | Rough share |
|---|---|---|---|
| Unit | One function or class, in memory, no I/O | Microseconds to milliseconds | Most tests |
| Integration | Your code plus one real dependency: database, queue, HTTP client, file system | Tens of ms to seconds | A solid layer |
| End-to-end | The deployed system through its real interface | Seconds to minutes | A handful |
Unit test pure logic and edge cases: pricing rules, validation, parsing, state transitions, retry and backoff calculations, permission checks. These are cheap to write and pin down exactly what the code decides.
Integration test the boundaries: SQL queries and migrations against a real database (often in a container), unique constraints and transactions, JSON serialisation of API responses, HTTP handlers with their routing and middleware, message encoding for a queue, and configuration loading. Mocking a database in a unit test proves only that your code calls the mock the way you expected; the real database may still reject the query.
Keep end-to-end tests for the few journeys whose failure would be an incident: sign up, log in, pay. They are slow and brittle, so each one must earn its place.
Code review
A reviewer checks, roughly in this order: does the change do what the PR says, is it correct on edge cases and failures, are there tests that would catch a regression, is it secure (input validation, authorisation, secrets), will it be operable (logging, metrics, a safe rollout), and is it readable. Style questions that a formatter or linter can settle should be left to the tools. Comments should be specific and explain why: "this retries a non-idempotent POST, so a timeout could charge twice" is useful; "I don't like this" is not. Google's reviewer guide treats about 100 lines as a reasonable change and 1,000 as usually too large, and counts files too: 200 lines in one file may be fine, spread across 50 files probably is not (Google eng-practices). Authors should also expect speed from reviewers: Google's rule is a first response within one business day at most (Google eng-practices), because a slow review pushes authors to batch work into bigger changes.
Reviewing code written by an agent
AI agents now write a large share of code, and their output looks confident whether it is right or not. Review it as you would a capable new teammate's work, and check these specifically:
- Tests that bend to the code. Did the change edit or delete an existing assertion, add a skip, or loosen a check so the suite passes? A changed expected value needs a reason.
- Tests that assert nothing. A test that calls the function and checks only that it did not crash proves little. Look for real assertions on outputs and side effects.
- Invented or outdated APIs. A library function, flag or config key that does not exist, or existed two versions ago. CI catches some of this; read the imports.
- Scope creep. Reformatting, renames or "improvements" outside the task make the real change hard to see. Ask for them in a separate PR.
- Plausible but wrong edge cases. Off-by-one boundaries, time zones, money in floating point, missing error handling on network calls.
Run the code yourself for anything risky. The agent's summary of what it did is a claim, not evidence.
The same shape everywhere
Git hashes, tests, CI and review are all "trust but verify": accept work quickly, but check it with an independent mechanism before it can do damage. The same shape appears in payments, where a daily reconciliation compares your ledger with the bank's, and in operations, where a canary release checks a new version on 1% of traffic before it reaches everyone.
Down the stack
Layer 1The contract
Git promises that a commit hash identifies exactly one snapshot and its full history: if two people have the same commit hash, they have the same files and the same past (barring a hash collision, which is why the SHA-256 transition exists). Branches promise nothing except "this name points at that commit now". A merge promises to apply both sides' changes relative to the merge base, and to stop and ask when they overlap. A test suite's contract is narrower than people assume: a green run means these specific assertions held on this code, in this environment, this time. It says nothing about paths no test exercised.
Layer 2The mechanism
Inside .git/objects, Git stores four kinds of object. A blob is a file's content. A tree is a directory listing that maps names to blob or tree hashes. A commit points to one tree plus parent commits, author and message. An annotated tag points to a commit. Each object's name is the SHA-1 hash of a header such as blob 12 followed by a zero byte and the content, and the object is stored zlib-compressed in a file named by the hash, with the first two hex characters as the directory (Pro Git, Git Objects). Since Git 2.13 that SHA-1 is a hardened implementation that resists the SHAttered collision attack, and a repository can opt in to SHA-256 with extensions.objectFormat; such repositories cannot be read by older Git versions, and the transition plan keeps SHA-1 and SHA-256 names convertible during migration (Git hash-function-transition). A branch is a small file such as .git/refs/heads/main holding a commit hash, and .git/HEAD says which branch you are on. Because identical content gives an identical hash, a file unchanged across 1,000 commits is stored once. git merge walks the commit graph to find the best common ancestor (git merge-base shows it), then does a three-way comparison of base, ours and theirs.
Layer 3The machine
Hashing is fast, hundreds of MB per second per core, but rehashing a large repository on every git status would still be slow. So the index file records each tracked file's stat data (ctime, mtime, device, inode, size and more), and git status calls stat on each file and only rehashes ones whose metadata changed (Git index format). Loose objects are later packed into packfiles that store similar objects as deltas against each other, which is why a repository with years of history is often smaller than you would expect. Test speed follows the latency numbers from the first lesson: a unit test runs in memory in microseconds; an integration test opens sockets, crosses process boundaries and waits for a database to fsync, so it costs milliseconds; an end-to-end test drives a browser over the network and costs seconds.
Why it's built this way
- Many people change one codebase at once, so history must be shared without trusting every copy.
- Content hashes make tampering and corruption detectable and make identical content free to store, so Git names everything by hash.
- Snapshots plus a common ancestor are enough to merge two lines of work automatically most of the time.
- Checking correctness by hand does not scale, so we encode expectations as tests; fast tests run on every change, so most tests must be fast and in memory.
- Tests only check what someone thought of, so a human reviewer adds judgement about intent, risk and design that automation cannot.
Diagram
flowchart LR E["Edit on a branch"] --> C["Commit: snapshot plus parent hash"] C --> P["Push and open pull request"] P --> U["CI: unit tests, seconds"] U --> I["CI: integration tests, minutes"] I --> R["Human review"] R -->|changes requested| E R -->|approved| M["Merge into main"] M --> D["Deploy with canary"]
Worked example
An agent opens a PR adding discount codes to checkout: 420 changed lines across 9 files, with the summary "Added discount codes, all tests pass." CI agrees: 1,180 unit tests in 25 seconds and 140 integration tests against a Postgres container in 3 minutes, all green.
The reviewer starts with the diff of the test files, not the code. Three things stand out. First, an existing test test_total_rounds_half_up now expects 10.00 instead of 10.01; the agent changed the rounding rule and then updated the test to match. Second, the new discount logic has eight unit tests, but none for a code that has expired or one applied twice. Third, there is no integration test for the new discount_redemptions table, which has a unique constraint on (order_id, code) that the code relies on to stop double use.
Then the code. Discounts are computed with floating-point numbers, so 3 items at 19.99 with 15% off gives 50.974499999... and rounds the wrong way about once in a few thousand orders. Two files were reformatted, adding 160 lines of noise.
The review asks for: reverting the rounding change and the reformatting; money in integer cents; unit tests for expiry, a zero total and double application; and one integration test that inserts the same redemption twice and asserts the second fails. The revised PR is 230 lines, CI adds 40 seconds, and the double-use bug, which would have let one code be redeemed by two concurrent checkouts, never reaches production. Total review time: about 40 minutes, against an incident that would have cost a day and real money.
In the wild
- Knight Capital, 2012: a half-finished deploy. New trading code was installed on 7 of 8 servers; on the eighth, a reused flag woke up retired "Power Peg" logic. In about 45 minutes it sent millions of unintended orders and lost roughly $460M. During the response, staff removed the new code from the seven good servers, which made all eight misbehave. The SEC cited missing deployment review and incident procedures (SEC order).
- CrowdStrike, 2024: a content update that passed its validator. On 19 July a new template type defined 21 input fields but the sensor supplied only 20, so the interpreter read past the end of an array. The Content Validator had a logic error, testing covered too narrow a range of matching criteria, and the update was not staged. CrowdStrike's fixes include bounds checks and staged deployment (CrowdStrike root cause analysis).
- xz Utils, 2024: a backdoor hidden in test files. Obfuscated payloads sat in test files and were pulled in by a build line present only in release tarballs, not in the git tree. Andres Freund found it by noticing sshd logins using about 0.5 s of CPU instead of 0.01 s. Reviewing the git history alone would not have shown it (oss-security post).
Trade-offs
| Choice | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Unit tests | Fast, precise, easy to debug | Miss integration bugs, can over-mock | Logic, calculations, edge cases |
| Integration tests | Catch SQL, schema, wire format and config bugs | Slower, need real dependencies | Every boundary your code owns |
| End-to-end tests | Test what users see | Slow, flaky, hard to diagnose | A few critical journeys |
| Merge commits | Preserve true history, no rewriting | Busier graph | Shared branches, long-lived work |
| Rebase and fast-forward | Linear, readable history | Rewrites hashes, risky on shared branches | Your own short-lived branches |
| Small PRs | Faster, deeper reviews, easy rollback | More PRs to coordinate | Almost always |
Pitfalls in production
- Mocking everything, so the suite passes while the real query fails. Fix: test each database query and external call at least once against a real instance.
- Flaky tests that everyone reruns until green. Fix: quarantine and fix them quickly; inject clocks and randomness; isolate test data.
- Committing secrets such as API keys, which stay in history even after deletion. Fix: use secret scanning in CI and pre-commit hooks, and rotate any key that was ever committed.
- Force-pushing over a shared branch and erasing a teammate's commits. Fix: protect
main, usegit push --force-with-lease, and rebase only your own branches. - Rubber-stamp approvals on large or agent-written PRs. Fix: cap PR size, require tests for behaviour changes, and read test diffs first.
- Coverage targets gamed with tests that assert nothing. Fix: treat coverage as a map of untested code, not a score, and review assertions.
Leadership lens
- Set the team's testing strategy explicitly: what must have unit tests, which boundaries need integration tests, and which few journeys get end-to-end tests.
- Keep CI fast, ideally under 10 minutes; when it gets slow, people batch changes into big risky PRs.
- Write down how agent-written code is reviewed: same bar as human code, test diffs read first, and a named human accountable for every merge.
- Protect
mainwith required checks and reviews, and make reverting a bad merge a routine, blameless action.
Check yourself
Why does changing an old commit's message change the hash of every commit after it?
Show answer
Each commit's hash covers its parent's hash, so a new hash for the old commit changes the parent field of its child, which changes the child's hash, and so on to the tip.
A service's repository code builds an SQL query and the unit test mocks the database. What kind of bug can that test never catch?
Show answer
Any bug in how the real database treats the query: a syntax error, a wrong column name, a missing index, a violated constraint or a transaction behaving differently. Only an integration test against a real database catches those.
An agent's PR changes an expected value in an existing test. What should a reviewer do?
Show answer
Treat it as a behaviour change and ask why: either the old expectation was wrong and the PR should say so, or the new code broke something and the test was bent to hide it.
Why is a suite with 10,000 end-to-end tests and no unit tests a poor design?
Show answer
It is slow, so it runs rarely; flaky, so failures get ignored; and hard to diagnose, because a failure could be anywhere in the stack. Most checks belong at the unit level, where they are fast and precise.
Sources and further study
Primary
- Git: hash-function-transition: how Git is moving from SHA-1 to SHA-256 and what interoperates.
- Git: index file format: the per-file stat data behind a fast
git status. - Google eng-practices: small CLs: size guidance and why it helps review.
Systems docs
- Pro Git, Git Objects: blob, tree and commit storage with worked commands.
- Google eng-practices: review speed: the one-business-day response rule.
- Fowler: The Practical Test Pyramid: layers, proportions and the ice-cream cone anti-pattern.
Books
- Software Engineering at Google (Winters, Manshreck, Wright), Ch 9 Code Review, Ch 11 Testing Overview, Ch 14 Larger Testing, Ch 16 Version Control and Branch Management: free online.
- Pro Git (Chacon and Straub): chapters on branching and Git internals, free at git-scm.com.
Other curricula
- roadmap.sh: Git and GitHub: treats Git as a command and workflow skills path.
- roadmap.sh: QA: lists test types and levels as a learning map.
Watch
- Lecture 6: Version Control (git) (Missing Semester): Git's data model of blobs, trees and commits, explained from the ground up.
- Git Internals by John Britton of GitHub (CS50): what happens inside
.gitwhen you add, commit and branch. - TDD, Where Did It All Go Wrong (Ian Cooper): testing behaviour rather than implementation details, and why over-mocking hurts.
Free resources
- Pro Git, Git Internals: Git Objects: the official book's explanation of blobs, trees and commits.
- git merge-base documentation: how Git defines the best common ancestor, including criss-cross merges.
- The Practical Test Pyramid (Martin Fowler's site): a long, concrete guide to unit, integration and end-to-end tests.
- Just Say No to More End-to-End Tests (Google Testing Blog): why a suite built from end-to-end tests fails its team.
- Google Engineering Practices: Code Review: how Google reviewers and authors approach a code review.
- What to look for in a code review (Google): a checklist from design to tests to naming.