Back to the map
ProgrammingFoundation8h

Git & collaboration

Branches, reviews, and not losing work.

Git records the history of a set of files: every change, who made it, and when. It lets several people edit the same project at once without overwriting each other, and lets you return to any earlier state. GitHub is a website that hosts those histories and wraps a review conversation around them.

Without it work gets lost, two people's edits silently clobber each other, and nothing tells you which change broke the thing that is now broken. It is also how teams see you: a pull request is usually the first sample of your work a reviewer ever reads.

It sits under every repository you touch, every take-home and every day of the job. Machine learning strains it in one specific way: git handles code well and handles multi-gigabyte datasets and model weights badly, which is why experiment tracking and MLOps exist as separate topics.

The history is the product, not a side effect; commits are notes to whoever has to work out later why this changed.

In more detailBeyond commit and push: rebasing to keep history readable, bisecting to find where something broke, and writing a pull request someone can actually review. In ML work, git alone is not enough, data and model weights need separate versioning, which is where the discipline usually breaks down.

Section 1 of 2, 5h

Git as you actually use it

By the end: You can work on a team without losing work or producing unreviewable pull requests.

  • The mental model: three places a file can be
  • Branching and merging
  • Rebase, and when not to
  • Undoing things safely
  • Pull requests people can review

Git is the tool you touch on every working day, and most of the trouble people have with it comes from not knowing where a change currently lives. This section gives you that model first, then the handful of commands that follow from it: branching, merging, undoing safely, and finding the commit that broke something. Without it you lose work that was never recoverable, or produce a pull request nobody can review.

A repository is a graph of commits#

Almost every confusing thing git does stops being confusing once you hold one picture: a repository is a graph of commits, each one pointing back at the commit or commits it came from, and a branch is nothing but a name stuck to one node in that graph. Every command in this section either adds a node, moves a name, or both. Build a small repository and look at it rather than taking that on trust.

mkdir demo && cd demo && git init -b main
git config user.name Ada && git config user.email ada@example.com

for n in 1 2 3; do echo "line $n" >> app.py; git add app.py; git commit -m "commit $n"; done
git switch -c feature
echo "feature work" >> feature.py && git add -A && git commit -m "add feature.py"
echo "more feature" >> feature.py && git add -A && git commit -m "extend feature.py"
git switch main
echo "line 4" >> app.py && git add -A && git commit -m "commit 4 on main"

git log --graph --oneline --all
Four commits on main, two on a branch that left after the third. Every hash printed below will be different in your copy; the shape will not.
* 435ccff extend feature.py
* b2245a1 add feature.py
| * fdd7b5c commit 4 on main
|/
* 5c7464c commit 3
* 480943e commit 2
* 10a008f commit 1
The output of that last command. Read it bottom up: history flows upward and the two lines are the two branches.

Now look at what the pieces actually are, because both are smaller than people expect. A branch is a forty-character file. A commit is a snapshot plus a pointer to its parent.

cat .git/refs/heads/main
# fdd7b5c434ebec6f9e0859fabb8b54eb66d18ef7

cat .git/HEAD
# ref: refs/heads/main

git cat-file -p HEAD
# tree f74d34831129b127c5c4d7a4376604a2e499fea2
# parent 5c7464c29e79ff0dc498534a9a52465a45d3d87e
# author Ada <ada@example.com> 1789324096 +0530
# committer Ada <ada@example.com> 1789324096 +0530
#
# commit 4 on main
A branch is a file containing one hash. HEAD is a file saying which branch you are on. Creating a branch writes 41 bytes, which is why it is instant and why deleting one loses nothing.

Three consequences follow directly, and between them they answer most beginner questions. Creating and deleting branches is free and reversible, because the commits are not stored in the branch — the branch only names one of them. A commit is identified by a hash of its content, its parents and its metadata, so any command that changes a commit's contents or its parent produces a different commit with a different hash rather than editing the old one. And nothing in the graph is ever removed by an ordinary command: commits that no branch points at are still on disk, which is what makes the recovery moves in the next section work.

Three places#

Alongside the graph there is the staging area, and most day-to-day confusion comes from not knowing which of three places a change is in. The working directory is your files. The staging area is what you have marked for the next commit. The repository is committed history.

git status                # what is where, read this constantly
git add file.py           # working directory -> staging
git commit -m "message"   # staging -> repository
git push                  # repository -> remote

Branching#

git switch -c feature/retrieval-eval    # create and move to it
git switch main                        # move back
git branch -d feature/retrieval-eval   # delete when merged

Branch names are read by other people. A name describing the change beats a ticket number alone, and a ticket number plus a description beats both.

Merge or rebase#

Merge keeps both histories and adds a merge commit. Rebase replays your commits on top of the target, producing a straight line. Rebase makes history readable, and it rewrites commits, which is why it has a rule attached.

In the graph both are one sentence. Merge adds a node with two parents and moves the branch name onto it. Rebase makes new copies of your commits with a different parent, then moves the branch name onto the last copy. Run each on a copy of the demo repository and compare.

git switch main && git merge feature          git switch feature && git rebase main

*   d983e7b Merge branch 'feature'               * 24e98d7 extend feature.py
|\                                               * e5ebea2 add feature.py
| * 435ccff extend feature.py                    * fdd7b5c commit 4 on main
| * b2245a1 add feature.py                       * 5c7464c commit 3
* | fdd7b5c commit 4 on main                     * 480943e commit 2
|/                                               * 10a008f commit 1
* 5c7464c commit 3
* 480943e commit 2
* 10a008f commit 1
Same two commits of work, two different graphs. On the right, b2245a1 and 435ccff have become e5ebea2 and 24e98d7 - identical changes, different commits, because the parent changed and the parent is part of the hash.

That right-hand column is the whole reason for the rule below. The old commits b2245a1 and 435ccff still exist on your disk, but nothing points at them any more, and anyone else who had fetched them now holds commits that your branch has never heard of. Their git will offer to merge the two versions of the same work back together, which produces every commit twice.

Never rebase anything you have pushed and others have pulled

Rebasing rewrites commits, so their copy and yours diverge. Rebase your own unpushed work freely. Once it is shared, merge instead.

The merge commit on the left is also worth one more look, because it is the only kind of commit with two parents and that shape matters later. It is what lets git work out, for any commit, whether it is an ancestor of main, which is how a pull request knows it has been merged. It is also why reverting a merge needs telling which of the two parents to keep, covered in the recovery section. And if main had not moved since the branch left, git would not have created a node at all: it would simply have slid the name main forward onto the branch tip, which is what the phrase fast-forward means.

What reset actually resets#

Reset is the command people are most afraid of, and the fear is aimed at the wrong part of it. Reset always moves the current branch name to the commit you give it. The flag decides which of the other two places follow it there. Here is the same repository, reset back one commit three times, with the resulting status and the contents of the file.

git reset --soft  HEAD~1   HEAD=5c7464c   status: M_ app.py   app.py has 4 lines
git reset --mixed HEAD~1   HEAD=5c7464c   status: _M app.py   app.py has 4 lines
git reset --hard  HEAD~1   HEAD=5c7464c   status: (clean)     app.py has 3 lines

M_  staged: the change is in the index, ready to commit again
_M  not staged: the change is in your files only
--hard  the fourth line is gone from the file, and was never committed elsewhere
All three move the branch to the same commit. Only the third one touches your files. --mixed is the default, so a bare git reset is the middle row.

Read from that what each is for. Soft reset is how you squash the last few commits into one: move back three, everything is staged, commit once. Mixed reset is how you unstage and rethink what belongs in the commit. Hard reset is how you throw the work away, and it is the only one of the three that can lose anything, because the commits it leaves behind are still recoverable through the reflog while uncommitted file changes are not.

Undoing#

git restore file.py              # discard uncommitted changes to a file
git restore --staged file.py     # unstage, keep the changes
git commit --amend               # fix the last commit (before pushing)
git revert <sha>                 # undo a commit with a new commit, safe
git reset --hard <sha>           # move the branch, DESTROYS uncommitted work

git reflog                       # the safety net: every state you have been in
reflog is the command that gets work back when something has gone badly wrong. Learn it before you need it.

Prefer revert to reset on anything shared. Revert adds a commit that undoes the change, which is honest history. Reset pretends the change never happened, which breaks everyone who already has it.

Finding when something broke#

git bisect start
git bisect bad                   # current state is broken
git bisect good v1.4             # this version was fine
# git checks out midpoints; mark each good or bad
git bisect reset
Binary search through history. Finds the breaking commit in about log2(n) steps.

Pull requests#

  • Keep them small. A 2000-line pull request gets approved without being read, which is worse than not being reviewed.
  • One concern per pull request. Refactoring and behaviour change together makes both unreviewable.
  • Write the description for someone who was not in the conversation: what, why, and what you considered.
  • Review your own diff before requesting review. You will find something, every time.

Data and model weights do not belong in git. Git stores a full copy of every version of a binary file, so a repository with model checkpoints in it becomes unusable quickly, and because history is immutable the size never comes back down once it has gone in — removing the file in a later commit leaves every earlier copy in the history that everyone clones. Use object storage with a versioned path, or a dedicated data versioning tool, and commit the reference.

What to skip#

Git has a very large surface and most of it is never used by working engineers. The commands above, plus the recovery moves in the next section, cover essentially everything that comes up on a normal team. A few things are worth deliberately not learning until something forces you to.

  • Submodules. They solve a real problem badly and every team that adopts them spends time on them forever. If you are given a repository that uses them, learn git submodule update --init --recursive and no more.
  • Branching models with names. Gitflow and the rest are process documents rather than git features, and the branch commands underneath are the ones above. Learn the model your team uses when you join it.
  • Interactive rebase beyond squashing. Reordering, splitting and editing old commits is worth knowing exists and is not worth practising. Squashing before review covers almost every real need.
  • Custom merge drivers, git hooks written by hand, and anything involving git filter-branch. Modern equivalents exist, and the cases that need them are rare enough that you should read the documentation at the time rather than carrying it around.
  • Memorising flags. The four worth having in your fingers are git status, git log --oneline --graph, git diff and git reflog. Everything else can be looked up without the lookup costing you anything.

Where people go wrong

git reset --hard with uncommitted work in the directory. It is unrecoverable through normal means, because the work was never committed and reflog only tracks commits.

Build this: Create a repository, make ten commits with a bug in the middle, then find it with bisect. Then practise recovering a branch you deleted using reflog.

Open this section on its own page
Section 2 of 2, 3h

When something goes wrong in git

By the end: You can recover lost work, undo in the right way for the situation, and handle a committed secret correctly.

  • The reflog remembers everything
  • restore, reset and revert: which one
  • Resolving a merge conflict calmly
  • Stash and cherry-pick
  • A secret was committed

This section is the set of moves for the moment something in git has gone wrong: commits that seem to have vanished after a reset or a rebase, a change you need to undo, a merge conflict, or a secret that is now in the history. The fact that makes most of it recoverable is that git almost never throws anything away, and the reflog lists every position HEAD has held. The exception is the leaked secret, which is why that part starts with revoking the key rather than rewriting history.

You almost never lose a commit#

git reflog                     # every place HEAD has been, with ids
git branch rescue 3f9c2a1      # put a branch on the commit you thought was gone

After a bad reset or rebase, the old commits still exist for weeks. The reflog lists them. Find the one you want and point a branch at it.

Worth doing once for real, in a repository you do not care about, so that the muscle exists before the panic does. Here is a hard reset two commits too far and the recovery, in a scratch repository. Your hashes will differ.

git log --oneline -1
# fdd7b5c commit 4 on main

git reset --hard HEAD~2
git log --oneline -1
# 480943e commit 2                 <- two commits of work apparently gone

git reflog -4
# 480943e HEAD@{0}: reset: moving to HEAD~2
# fdd7b5c HEAD@{1}: commit: commit 4 on main
# 5c7464c HEAD@{2}: checkout: moving from feature to main
# 435ccff HEAD@{3}: commit: extend feature.py

git reset --hard 'HEAD@{1}'      # or the hash; quote it, the braces are shell syntax
git log --oneline -1
# fdd7b5c commit 4 on main
HEAD@{1} means where HEAD was one move ago. The reflog is local and per-repository, so it records your moves and not a colleague's, and entries expire after 90 days by default.

A deleted branch is the same problem with one extra step, because the branch name is gone and so its own reflog went with it. Two things still know where it was. The delete command printed the hash, and the HEAD reflog recorded every commit you made while you were on it.

git branch -D feature
# Deleted branch feature (was 435ccff).        <- keep this line

git reflog | grep feature
# 5c7464c HEAD@{3}: checkout: moving from feature to main
# 435ccff HEAD@{4}: commit: extend feature.py
# b2245a1 HEAD@{5}: commit: add feature.py
# 5c7464c HEAD@{6}: checkout: moving from main to feature

git branch feature 435ccff
git log --oneline -1 feature
# 435ccff extend feature.py
If the scrollback is gone too, git fsck --no-reflogs lists unreachable commits: dangling commit 435ccff786a96bd38d49c6603703194bdb9f2991.

The one thing that is genuinely lost is work that was never committed. A hard reset overwrites your files from the commit, and there is no record of what was in them, so a morning of uncommitted editing is simply gone. There is an exception worth knowing, and it is the strongest argument for staging work you are not ready to commit: git add writes the file into the object database immediately, and objects are not deleted by reset.

echo "half an hour of work" >> app.py
git add app.py                   # this line is the difference
git reset --hard
git status --short               # nothing: the file is back to the committed version

git fsck --lost-found
# dangling blob 8233b38978eca16f6ab63b4fc1475cb5fc945dd2

cat .git/lost-found/other/8233b38978eca16f6ab63b4fc1475cb5fc945dd2
# line 1
# line 2
# line 3
# line 4
# half an hour of work
Run without the git add, git fsck --lost-found prints nothing at all and the work is unrecoverable. That is the entire difference.

git add is the cheapest insurance in the tool

Staging costs nothing, commits nothing and tells nobody, and it makes the current state of a file recoverable from that moment on. Before any command you are unsure about — a reset, a rebase, a checkout of another branch, a merge — stage everything with git add -A. If it goes wrong, the content is in the object database and git fsck --lost-found will find it.

Which undo?#

Discard changes to a file you have not committed   git restore <file>
Unstage a file                                      git restore --staged <file>
Undo local commits not yet pushed                   git reset <commit>   (keeps changes)
Undo a commit that others already have              git revert <commit>  (new commit)

The rule: never rewrite history other people have pulled. Revert adds a new commit that undoes the old one, which is safe on shared branches.

Merge conflicts#

A conflict means both sides changed the same lines. Git marks them in the file. Read both versions, decide what the result should be, which is sometimes a mix, remove the markers, run the tests, then add and continue. If it goes badly, git merge --abort or git rebase --abort returns you to where you started.

The default markers give you less than you need, and it is worth seeing why on a small one. Two branches both edited a two-line config file: main changed the timeout from 30 to 45 and the retries from 3 to 5, the other branch changed only the timeout, to 120.

git merge raise-timeout
# Auto-merging config.py
# CONFLICT (content): Merge conflict in config.py
# Automatic merge failed; fix conflicts and then commit the result.

cat config.py
# <<<<<<< HEAD
# TIMEOUT = 45
# RETRIES = 5
# =======
# TIMEOUT = 120
# RETRIES = 3
# >>>>>>> raise-timeout
Four lines, and nothing here says which side changed what. RETRIES looks like a disagreement between 5 and 3.

Read that as it stands and you have a genuine choice to make about TIMEOUT and an invented one about RETRIES. Only one side touched RETRIES, so 5 is simply the newer value and 3 is what it always was, but the markers do not tell you that and picking a side at random here quietly reverts someone's work. Turning on a conflict style that also shows the common ancestor fixes it permanently.

git config --global merge.conflictStyle zdiff3     # needs git 2.35 or newer

git merge --abort && git merge raise-timeout
cat config.py
# <<<<<<< HEAD
# TIMEOUT = 45
# RETRIES = 5
# ||||||| bcf6972
# TIMEOUT = 30
# RETRIES = 3
# =======
# TIMEOUT = 120
# RETRIES = 3
# >>>>>>> raise-timeout
The middle block is what both sides started from. Now RETRIES is obvious - 3 became 5 on one side and nobody touched it on the other - and only TIMEOUT is a real decision.

With the base visible the resolution is mechanical: for each hunk, work out what each side did to the base, and write the file that does both. Here that is TIMEOUT = 120 and RETRIES = 5, which is a line neither branch contains. That is normal and is the reason the advice is to resolve rather than to choose a side; the tooling offers you --ours and --theirs and they are almost always both wrong.

# edit config.py to the version that is actually correct, markers removed
git add config.py
git status --short
# M  config.py
git commit --no-edit          # keeps the generated merge message

git log --graph --oneline --all
# *   6979262 Merge branch 'raise-timeout'
# |\
# | * e2cea00 raise timeout for slow model calls
# * | 9d9cc89 tune timeout and retries
# |/
# * bcf6972 base config
Run the tests between the edit and the commit. A merge that compiles is not a merge that is correct, and a broken merge commit is awkward to bisect through later.

One consequence of that two-parent shape, which catches people out perhaps once in a career and is unpleasant when it does: reverting a merge needs telling which side to keep. A merge undid nothing on its own, so undoing it is ambiguous, and git refuses to guess.

git revert HEAD
# error: commit 6979262629e9e33b0ccc42687601201053afe878 is a merge but no -m option was given.
# fatal: revert failed

git revert -m 1 HEAD          # -m 1 means "keep the first parent", which is the branch
                              # you merged into, almost always main
cat config.py
# TIMEOUT = 45
# RETRIES = 5                 <- main as it was before the merge
Merging that branch again afterwards prints Already up to date and changes nothing, because its commits are still ancestors of main - only their effect was undone. To bring the work back you revert the revert.

Small tools#

  • git stash puts uncommitted changes aside so you can switch branches; git stash pop brings them back.
  • git cherry-pick <commit> copies one commit onto the current branch, useful for moving a fix to a release branch.

A secret was committed#

  1. 1.Revoke and replace the secret immediately. Assume it is already compromised; deleting it from git does not un-leak it.
  2. 2.Then remove it from the code and history if your team's policy requires, which rewrites history and needs coordination.
  3. 3.Add a secret scanner to pre-commit or CI so it does not happen again.

Where people go wrong

Force-pushing to a shared branch to 'clean up'. Everyone else's history now disagrees with the remote.

Build this: In a scratch repository, make five commits, hard reset back three, then recover them with the reflog. Create and resolve a merge conflict. Revert a middle commit and read the resulting history.

Open this section on its own page

Now actually do it

Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.

1Break a repository and recover it

4h

Create a repo with 15 commits. Then: amend a commit, revert a pushed one, delete a branch and recover it with reflog, and resolve a deliberate merge conflict in two files.

Done when: You recovered the deleted branch without looking up the command, and the history is still readable.

2Find a bug with bisect

2h

Introduce a failing test somewhere in the middle of a 20-commit history, then find the breaking commit with git bisect run and an automated test command.

Done when: Bisect found it automatically, without you marking each step by hand.

3Write a pull request someone can review

2h

Take a change you have made and split it into two pull requests: one refactor, one behaviour change. Write descriptions for a reader who was not in the conversation.

Done when: Someone reviews both without asking you what they are for.

8 hours of practice

Interview questions

8 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.

Git as you actually use it

What is a branch in git?

A file containing one commit hash, plus an entry in HEAD saying which branch you are on. The commits are not stored in the branch, so creating and deleting branches is instant and costs nothing, and deleting one does not delete any commits.

Then they push further

Then why does rebasing change the commit hashes?

A commit's id is a hash of its contents, its metadata and its parent. Rebase gives the commits a different parent, so they hash differently. The originals are still on disk until they are garbage collected, which is why the reflog can get them back.

What gets marked up. Strong answers describe the graph and the pointer. Describing a branch as a copy of the code is the answer that predicts trouble later.

What is the difference between git reset --soft, --mixed and --hard?

All three move the current branch to the commit you name. Soft leaves your files and the staging area alone, so the changes from the undone commits are staged and ready to recommit. Mixed, the default, leaves your files alone but unstages. Hard overwrites your files from the commit, and is the only one of the three that can destroy work that was never committed.

What is the difference between merge and rebase?

Merge combines two histories and adds a merge commit, preserving what actually happened. Rebase replays your commits on top of the target, producing a linear history but rewriting the commits. Never rebase anything already pushed and pulled by others.

How do you undo a commit that is already on the shared branch?

git revert, which adds a new commit undoing the change. Reset rewrites history and breaks everyone who already pulled it.

How would you find which commit introduced a bug?

git bisect. Mark a known good commit and a known bad one, and it binary searches the range, so a thousand commits takes about ten checks.

Why not commit model weights to git?

Git stores a full copy of every version of a binary, so the repository grows enormously and clones become impractical. Store weights in object storage with a versioned path and commit the reference instead.

When something goes wrong in git

You pushed an API key to a public repository. What do you do?

Revoke the key and issue a new one first, because it may already have been copied by automated scanners within minutes. Then remove it from the code and, if needed, from history, and add secret scanning to prevent a repeat. Check the provider's logs for misuse.

Then they push further

Why is deleting the commit not enough?

Anyone who cloned or scanned the repository already has it, forks keep it, and caches may too. Only revoking the key removes the risk.

What gets marked up. Revoking first is the whole answer. Candidates who start with rewriting history are marked down.

What is the difference between reset and revert?

Reset moves the branch pointer back, rewriting history, which is fine for local commits. Revert makes a new commit that undoes an earlier one, keeping history intact, which is what you use on shared branches.

These questions on their own page, one link per question

Resources

Free to watch and read, picked for this topic.

Stop 6 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeTyping & validationChecking that model output is what you claimed.11h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.