Module 4 · Working on real code

4.1 Git: what it actually is, and why understanding it still matters

Lesson 4.1 · 25 min read

Let's start with the honest thing, because it changes how you should read this post.

You will probably never need to type most of these commands. AI can create your branch, stage your changes, write a decent commit message, push it, open the pull request, resolve the merge conflict, and rebase your work onto the latest main branch. It does all of this well, and it does it faster than you would. If your goal is to get a change from your machine into a shared codebase, you can largely ask for that in English and watch it happen.

So why spend a long post on Git?

Because when it goes wrong, and it will, you'll be standing in front of a repository in a state you don't understand, being offered options you can't evaluate, about work you can't afford to lose. And because the difference between someone who directs AI confidently and someone who is at its mercy is not command memorization. It's whether they understand the model underneath, well enough to know what should happen, to recognize when something else did, and to say precisely what they want instead.

That's the deal for this post. We're not memorizing commands. We're building the mental model of what Git actually is, deeply enough that every command and every AI suggestion makes obvious sense. The commands come along for free at the end.


What Git actually is, underneath

Almost everyone learns Git as a set of commands they perform in sequence, and almost everyone remains permanently confused as a result. Learn what it is instead, and the commands stop being magic incantations.

Git is a record of snapshots, linked together in a chain.

That's it. Every time you commit, Git takes a complete snapshot of your entire project as it exists at that moment, and stores it. The snapshot is not a list of changes; it's the whole state of every file. Git is efficient about not duplicating unchanged files, but conceptually, a commit is a photograph of the project, entire.

Each snapshot also records which snapshot came immediately before it. That link is what turns a pile of photographs into a history. Follow the links backward from any commit and you walk the complete story of how the project reached that exact state, one photograph at a time, all the way back to the beginning.

So a repository is a chain of snapshots, each pointing at its parent. Not a folder of files with some version numbers attached. A chain of complete states, ordered by ancestry.

Three things follow from this immediately, and each one dissolves a common confusion:

A commit has an identity, not a number. Each commit gets a long unique identifier, a hash, computed from its contents and its parent. Change anything, even one character in a commit message, and it becomes a different commit, with a different identity. This is why, as you'll see later, commits can't really be "edited" so much as replaced by new commits that look similar. Hold onto that. It explains rebase entirely.

History is a graph, not a line. Because a commit points at its parent, and multiple commits can point at the same parent, history can branch. Two people commit from the same starting point and now there are two chains diverging from one shared ancestor. That's not a special mode Git enters. It's just what happens when two snapshots share a parent.

Nothing is ever really lost, until it's unreachable. Every commit you've ever made exists in the repository, identified by its hash, whether or not anything currently points at it. This is why Git is genuinely hard to destroy work in, and why an experienced person is unbothered by mistakes that panic a beginner. The photographs are still in the drawer, even if the label fell off.

That last promise has one important exception, and we'll be precise about it later, because knowing exactly where the safety net ends is more useful than believing it's everywhere.


The three places your work lives

Before going further, we have to explain something that quietly confuses every beginner and is rarely stated plainly. At any moment, a change of yours exists in one of three places, and Git thinks about them very differently.

Your working directory is the files as they exist right now, on your disk, as you edit them. This is what your code editor shows you.

The staging area is a holding space where you place the specific changes you intend to include in the next snapshot. It is a rough draft of your next commit.

The repository is the permanent chain of snapshots, the history itself.

Work flows in that order. You edit files (working directory), you select some of those edits (staging area), you take the photograph (repository). git add moves things from the first place to the second. git commit turns the second into the third.

The obvious question is why the middle step exists at all, and the answer is genuinely useful. It exists so a commit can be a deliberate, coherent unit rather than a dump of everything you happened to touch. Suppose you spent an afternoon fixing a bug and, along the way, also renamed some variables and adjusted a config file. Those are three unrelated changes sitting together in your working directory. The staging area lets you say: this commit is the bug fix, only. Add just those files, commit, and then handle the rest separately. Your history becomes a series of meaningful, self-contained changes rather than an undifferentiated pile, which matters enormously when someone, possibly you in six months, is trying to understand why something changed.

This also explains git status, which otherwise reads as gibberish. It shows you two distinct lists: changes that are staged and ready for the next commit, and changes in your working directory that are not staged. Two lists, because there are two places. Once you know that, status output becomes obvious rather than intimidating.


Branches are not what you think they are

Here is the single most useful thing in this post, and it's almost never explained properly.

You probably picture a branch as a copy of your project, a parallel folder where you work separately. That's the metaphor the word suggests, and it's wrong, and it's why branches feel heavier and scarier than they are.

A branch is a movable label pointing at one commit. That's the entire implementation. It's a name, and a hash. Nothing more.

When you create a branch, Git writes a tiny file containing the hash of the commit you're currently on. It copies nothing. When you commit while on that branch, Git creates the new snapshot, and then moves the label forward to point at it. The label follows you as you work.

This explains everything that used to be mysterious. Creating a branch is instant, because it's writing a name and a hash. Deleting a branch is harmless to your commits, because you deleted a label, not the photographs. Switching branches is fast, because Git just rearranges your working files to match whatever snapshot the other label points at.

And main is not special. It is a label like any other, pointing at a commit, moving forward as commits land on it. Every convention around protecting main is human agreement, not Git enforcing anything.

There's one more label worth naming: HEAD. HEAD is a pointer to where you currently are, which is normally a branch label. "You are on the feature branch" means HEAD points at the feature branch, which points at a commit. When people say the terrifying phrase "detached HEAD," all it means is that HEAD is pointing directly at a commit rather than at a branch label. You're standing on a photograph with no label attached to your position. Commit there and the new commits have no label following them, so when you walk away, nothing points at them and they become hard to find. Not lost. Unreferenced. The difference is everything, and knowing that difference is the difference between panic and a shrug.

Interactive: git branch model

A hands-on visual for this idea is in the works. The text around it carries everything you need for now.


How branches know about remote branches: tracking

Your repository lives on your machine. There's also a copy somewhere shared, on a hosting service. How do those relate?

The shared copy is called a remote, and it gets a nickname, conventionally origin. A remote is nothing more than a name mapped to a URL, plus some credentials. origin is not a special place; it's a bookmark. You can have several remotes with different names pointing at different copies of the same project, which becomes relevant when you contribute to code you don't own.

Now, the piece that confuses people constantly. Your machine holds three distinct things that are easy to conflate:

Your local branch, main, a label in your repository pointing at a commit.

The remote branch, the real main on the server, which you cannot see directly and which changes when other people push to it.

And a third thing: your local remote-tracking branch, written origin/main. This is your repository's memory of where the server's main was, the last time you checked. It is a label on your machine, not on the server. It updates only when you fetch or pull. If a colleague pushed ten minutes ago and you haven't fetched, your origin/main is stale, and it will confidently show you old information.

That third one explains an enormous amount of Git confusion. When Git says "your branch is 2 commits ahead of origin/main," it is comparing your local label against your local memory of the server, which may be out of date. It has not asked the server anything. Understanding this is why git fetch exists: it's the operation that goes to the server and updates your remote-tracking labels, without touching your work at all. It's how you refresh your memory of the world.

When a local branch is set to correspond to a particular remote branch, we say it tracks it. That's what lets you type git push with no arguments and have Git know where to send it. Tracking is a small configuration saying "this local label corresponds to that remote label." When you create a branch locally and push it for the first time, you set up that correspondence, which is what the -u flag in git push -u origin my-branch does. After that, plain git push knows the destination.

And git pull? It's two operations, and knowing that removes its mystery. pull is fetch (go get the server's latest, update my remote-tracking labels) followed immediately by an integration of that work into your current branch, either a merge or a rebase depending on configuration. When a pull does something surprising, it's almost always the second half surprising you, not the first.


Merge and rebase: two ways to combine work

You branched off, you did some work, and meanwhile main moved forward. Now you want your work and main's work together. There are two fundamentally different ways to accomplish this, and understanding the difference is genuinely important, because AI will offer you both and the choice has consequences.

Merging creates a new commit that has two parents: the tip of your branch and the tip of main. It joins the two chains at a point, and everything that happened stays exactly as it happened. Your commits keep their original identities. The history now contains a visible fork and a visible join, a permanent record that these two lines of development existed separately and came together.

Merging is honest. It records what actually occurred. Its cost is that a project with many merges develops a history that looks like a subway map, with lines splitting and rejoining constantly, which can make reading the project's story genuinely difficult.

Rebasing does something quite different, and the name is unhelpfully abstract, so let's make it concrete. To rebase your branch onto main means: take each of my commits, one at a time, and re-apply them on top of the current tip of main, as if I had started my work from there in the first place.

Now recall the earlier point about commit identity. A commit's hash is computed from its contents and its parent. If you re-apply a commit on top of a different parent, it is a different commit. It has a new hash. The old one still exists, unreferenced, but the commit now in your branch is a new object that merely looks like the old one.

This is the entire nature of rebase, and everything true and dangerous about it flows from this one fact. Rebase does not move commits. It replaces them with new commits that have the same changes and a different ancestry.

The result is beautiful: a clean, straight line of history where your work appears to have been built on the latest main all along. No fork, no join, no subway map. Many teams prefer this strongly, because reading the project's history becomes reading a story rather than untangling a diagram.

The danger is precise, and once you understand the mechanism, the rule that follows is obvious rather than arbitrary. If you rebase commits that you have already pushed and that other people have based work on, you have replaced commits they possess with different commits. Their repository still points at the originals. Yours points at replacements. The histories now disagree about reality, and reconciling that is genuinely miserable.

Hence the rule, which you now understand from first principles rather than as folklore: rebase freely on work that is yours alone and hasn't been shared. Do not rebase commits that others have pulled. The common, safe, everyday case is rebasing your own unmerged feature branch onto an updated main before opening a pull request, which is exactly why AI suggests it so often. That's private work, being cleaned up before it's shared. Perfectly safe.

There's a relative worth knowing by name because you'll see it: interactive rebase, which lets you replay your own commits while editing them, squashing several messy commits into one clean one, rewording messages, reordering, dropping. It's the tool people use to tidy a branch before review. Same mechanism, same rule: it rewrites, so it's for work you haven't shared.

A close cousin, one sentence, now that commits are objects you can replay: cherry-pick takes one specific commit from anywhere and applies it onto your current branch as a new commit. Useful when you want exactly one change from another branch and not the rest.

And when either merging or rebasing finds that both sides changed the same lines, you get a conflict. Git stops and asks you to decide, because it genuinely cannot know whose change is correct. A conflict is not an error. It's Git refusing to guess about your intent. AI is quite good at resolving these now, but the resolution is a judgment about which code should exist, and that judgment is yours to verify.


Rewriting history, and force pushing

Now we close a loop the rebase section opened, because it has a direct and consequential result that catches people.

Suppose you pushed your feature branch, then rebased it to bring it up to date with main. Your local branch now contains new commits with new hashes. The remote branch still holds the original commits. The two histories genuinely disagree about what happened, and neither is a simple extension of the other.

So a normal git push is rejected. Git refuses, correctly, because from its perspective you are asking to discard commits that exist on the server. To proceed you must explicitly say you know what you're doing, which is a force push.

There are two forms, and the difference matters more than almost any other flag in Git. A plain git push --force says: make the remote look exactly like my branch, whatever is there. If a colleague pushed to that branch in the last ten minutes, their work is now gone, replaced by yours, silently.

git push --force-with-lease says something smarter: overwrite the remote only if it is still where I last saw it. If someone has pushed since your last fetch, the operation refuses, and you go find out what happened first. It's the same action, guarded by a check against the thing you can't otherwise see. There is very little reason to ever use plain force when force-with-lease exists.

This is one of the places where "AI does it for me" needs a moment of your attention. Force pushing is a routine and necessary part of the rebase-before-review workflow, and AI will do it without ceremony. On your own unshared feature branch, that's entirely fine and you should not think twice about it. On a branch anyone else has touched, it can erase someone's work permanently. The judgment about which situation you're in is yours, and it takes one second: has anyone else pushed to this branch?

Two related things worth knowing, because platforms present them and they are rewrites in disguise.

When you merge a pull request, most hosting services offer three strategies. A merge commit joins the histories as described above and keeps every one of your commits. A squash merge collapses all the commits on your branch into a single new commit on main; your individual commits never land, and the branch's history effectively disappears. A rebase merge replays your commits onto main individually, without a merge commit. Squash is extremely common, and it's why you sometimes see a beautifully clean main branch with one commit per feature and no trace of the twelve messy commits that produced it. Nothing is wrong; the commits were replaced by one that contains their combined changes.


Undoing things, and the one place the safety net ends

Git offers several ways to undo, and they operate at different depths. Confusing them is how people lose work. Understanding them takes two minutes.

git restore throws away changes in your working directory, returning a file to its last committed state. Simple and local. Note carefully: the changes it discards were never committed, so they were never snapshotted, and they are simply gone.

git revert creates a new commit that undoes the changes of an earlier commit. It adds to history rather than rewriting it. Nothing is replaced, nothing is removed, the original commit stays right where it was, and a new commit sits on top reversing it. Because it rewrites nothing, revert is always safe on shared branches, and it is the correct way to undo something already pushed and pulled by others.

git reset moves your current branch label to point at a different commit. Remember, a branch is a movable label, so reset is exactly what it sounds like: pick up the label and put it somewhere else. What happens to your files depends on the flag, and this is the part to know:

--soft moves the label and leaves everything else alone. Your changes remain staged, ready to be recommitted differently. This is how you redo the last commit with a better message or different contents.

--mixed, the default, moves the label and unstages your changes, leaving them in your working directory. Your work is still there, just no longer staged or committed.

--hard moves the label and makes your working directory match, discarding everything that isn't committed.

That last one is where the safety net genuinely ends, and this is the exception promised at the top of this post. Commits are recoverable, always, because they exist as objects in the repository even when nothing points at them. But uncommitted changes were never snapshotted at all, so reset --hard doesn't unreference them, it deletes them from existence. No reflog will bring them back. There is nothing to bring back.

Hence the single most useful safety habit in Git, and it costs nothing: commit before you do anything you don't fully understand. A commit is cheap, private until pushed, and rewritable afterward. Once your work is committed, it is nearly impossible to lose it, and any operation an AI suggests becomes reversible. Uncommitted work is the only genuinely fragile thing in the entire system.

Which brings us to the command nobody teaches and everybody eventually needs. git reflog is a log of every position HEAD has occupied, including ones no branch points at anymore. Botched rebase, deleted branch, reset to the wrong commit: the old hashes are all sitting in the reflog. Find the hash of where you were, and git switch -c recovered <hash> puts a fresh label on it. The photographs were in the drawer the whole time; reflog is the index.


Referring to other repositories: remotes, forks, and submodules

Three related ideas about how one repository relates to another, all frequently muddled.

Multiple remotes. Since a remote is just a name pointing at a URL, you can have several. This is the standard arrangement when contributing to a project you don't own: you make your own copy of it on the hosting service, called a fork, which is simply a full copy under your account that you can push to. Your local repository then has two remotes: origin pointing at your fork, which you can write to, and conventionally upstream pointing at the original project, which you can only read from. You fetch from upstream to stay current with the real project, and you push to origin, your fork, and then propose your changes back to the original with a pull request. Understanding that these are simply two named URLs makes the whole open-source contribution model stop feeling like a special ritual.

Submodules. Sometimes a project needs to contain another repository inside it, a shared component library, an internal package, some vendored dependency. A submodule is how Git expresses that: your repository records a reference to another repository, at one specific commit.

That last part matters and is the source of every submodule confusion. Your repo does not contain the submodule's files in its own history. It contains a pointer: "this folder should hold repository X, at exactly commit abc123." Pinned to a precise snapshot, deliberately, so your project's behavior doesn't change because someone else pushed to that other repository.

The consequences are all predictable from that one fact. When you clone a project with submodules, you get the pointers but empty folders, until you explicitly ask Git to go fetch each submodule's contents (git submodule update --init --recursive, one of the few commands genuinely worth recognizing, because a project that inexplicably won't build often just needs it). When you want to move a submodule to a newer version of that other repository, you go into it, check out the newer commit, come back out, and commit the pointer change in the parent repository. You are committing a change to which snapshot you're pinned. And a repository's history now depends on two histories, which is why submodules are powerful and why many teams avoid them for anything they can solve with a package manager instead.

The mental model, cleanly: a submodule is a foreign key. Your repository stores a reference to a specific row in another repository's history. You've seen this exact idea before.


Authentication, and the files that must never be committed

You cannot push to a remote without proving you're allowed to, and the mechanisms here are the ones you learned in the authentication post, wearing work clothes.

SSH keys. You generate a pair of cryptographic keys on your machine: a private key that never leaves it, and a public key that you upload to the hosting service. When you connect, the server issues a challenge that can only be answered by someone holding the private key. You answer it, silently, and you're in. Nothing secret is transmitted, ever. This is why SSH is preferred by people who push often: it's secure and requires no typing.

Token-based access over HTTPS. You generate a personal access token, a long secret string, and your machine sends it with each request. This is precisely the API key pattern from the API module: a long-lived secret that identifies an account, granting whatever permissions it was created with. Modern services let you scope these narrowly and expire them, which you should do, because they behave exactly like the keys we discussed, whoever holds it, has it.

Two things worth internalizing. Your credentials authenticate you to the hosting service, controlling what you're permitted to push. They are separate from the identity recorded in your commits, which is just a name and email you configured locally and which Git does not verify. That's why a commit can claim to be from anyone. If you want commits cryptographically proven to be yours, you sign them, which is a separate mechanism, and it's why you sometimes see "verified" badges on commits.

Now the part that connects everything and deserves to be stated as a rule. Secrets do not belong in a repository. An API key committed to a repository is compromised the moment it's pushed, and deleting it in a later commit does not help, because the old commit is still there in the history, permanently, exactly as this post described at the top. Every snapshot is kept. Git's greatest strength, that it never forgets, is precisely what makes a leaked secret unrecoverable. You must rotate the key, not delete the line.

The mechanism that prevents this is a file named .gitignore, which lists patterns of files Git should never track. Untracked files are invisible to git add, never staged, never committed, never pushed. Every real project has one, and the single most important entry in most of them is .env, the file where secrets and configuration live. Your .env stays on your machine and on your server; it never enters the repository. The repository contains a .env.example instead, listing the names of the variables with no values, so anyone cloning knows what they need to supply. Build artifacts, dependency folders, and editor files belong in .gitignore too, but those are housekeeping. The secrets line is safety.

Check this early, on anything you build. If a .env file appears in git status as a change waiting to be committed, .gitignore is missing or wrong, and you are one careless git add . away from publishing your keys.


The commands, now that they mean something

Here they are, and notice that after everything above, each one is now merely the name of a thing you already understand.

Seeing where you are. git status shows your two lists, staged and unstaged. git log --oneline --graph draws the actual chain of commits, and running it once, seeing your history as a real graph, is worth more than any explanation. git diff shows unstaged changes; git diff --staged shows what's about to be committed.

Making a snapshot. git add moves changes into the staging area. git commit -m "message" turns the staging area into a permanent snapshot.

Moving between labels. git switch branch-name moves HEAD to another branch. git switch -c new-branch creates the label and moves to it. (Older material uses git checkout for both; it does more things and is correspondingly easier to misuse.)

Talking to the server. git fetch updates your memory of the remote and touches nothing else, and it is the safest command in Git. git pull is fetch plus integrate. git push sends your commits up, git push -u origin branch-name establishes tracking the first time, and git push --force-with-lease is what you use after rewriting your own branch.

Combining work. git merge branch-name joins histories with a merge commit. git rebase main replays your commits on top of main. git rebase -i replays them and lets you edit as you go. git cherry-pick <hash> applies one commit from elsewhere.

Undoing, at increasing depth. git restore <file> discards uncommitted changes to a file. git revert <hash> adds a new commit that reverses an old one, safe on shared branches. git reset --soft, --mixed, and --hard move your branch label, keeping, unstaging, or destroying your uncommitted work respectively.

The one that saves you. git reflog shows everywhere HEAD has been, including states no branch reaches. It is how you recover from a bad rebase, a deleted branch, or a mistaken reset. It cannot recover work that was never committed.

Useful to recognize. git stash shelves uncommitted changes so you can switch away and return. git submodule update --init --recursive fetches submodule contents.

You do not need to memorize this list. You need to recognize these words when an AI proposes one, and know what it's about to do to your repository.


What this actually buys you

Return to where we started. AI will run these commands. It will branch, commit, rebase, force push, and open your pull requests. That's a genuine gift and you should use it without guilt.

But consider what you can now do that you could not before. When AI suggests rebasing, you know whether your branch has been shared, and therefore whether that's safe or a trap. When it force pushes, you know the difference between the form that checks first and the form that doesn't, and you know to ask whether anyone else has touched this branch. When a merge conflict appears, you understand that Git is not broken, it is refusing to guess, and the decision is yours. When something says you're four commits behind origin/main, you know that's a claim about your local memory of the server, and that fetching will tell you the truth. When you find yourself in detached HEAD, you don't panic; you know exactly what it means and that reflog will retrieve anything you make there. When AI proposes reset --hard, you know that's the one operation that can genuinely destroy uncommitted work, and you commit first. When a project won't build and it has submodules, you know to check whether they were ever initialized. When someone says never rebase a shared branch, you don't take it on authority; you know precisely why, because you know a rebase replaces commits rather than moving them.

That is the whole difference. Not typing speed. The ability to know what state your work is in, to evaluate a suggestion before accepting it, and to recover when something goes wrong, calmly, because you understand what the tool is actually doing to your history.

Six sentences hold all of it. Every version of your work is a snapshot in a chain. Branches are labels that move. Your work passes through three places, and only the third is permanent. Rebase replaces rather than relocates. Remotes are named URLs, and origin/main is only a memory. Nothing committed is ever lost, only unreferenced, and nothing uncommitted is ever safe.


Do this before the next post

Three exercises. The first takes thirty seconds and changes how you see the tool permanently.

In any repository you have, run git log --oneline --graph --all. Look at what appears. That is not a list of your commits; it's the actual graph, with the branch labels shown as pointers into it. Find a branch name in the output and notice it is sitting on a commit, exactly as this post described. If you have merges, you'll see the fork and the join drawn in front of you. You are looking directly at the model.

Second, run git status and read it as two lists rather than one blob. Change a file without staging it. Stage a different file. Run status again and watch the same information sort itself into the two categories you now know are real places.

Third, deliberately create the situation people fear, in a scratch repository where nothing matters. Make a couple of commits on a branch. Note the hash of the last one from the log. Now delete the branch entirely, and confirm it's gone. Then run git reflog, find that hash sitting there in the history of where HEAD has been, and bring it back with git switch -c recovered <hash>.

Do that once, with your own hands, and something permanent happens to your relationship with this tool. You will have destroyed work and retrieved it, and you'll understand in your body, not just your notes, that the photographs are still in the drawer. Then, while you're there, make an uncommitted change and run git reset --hard, and watch it be genuinely, unrecoverably gone. Feel the difference between those two experiences. That contrast, more than any rule you could memorize, is what will make you commit before you experiment, and it's exactly the confidence this post exists to give you.