Skip to content
NexiferLabs
All services
Solutions overview
Browse the library
About Nexifer
gitversion-controldevopstoolingworkflow

Git, properly: the object model, the daily workflow, and how to undo anything

A detailed guide to Git - what it actually stores, the everyday commands, rebasing, undoing mistakes, bisect, large repos, and what Git 3.0 changes.

T

team

14 min read
A stylised illustration of a Git commit graph, with branches and merges. The commits are represented as circles, and the branches are represented as lines connecting the circles. The graph is surrounded by a network of gears and pipes, representing the infrastructure that supports it.

Most developers use maybe eight Git commands and treat the rest as a source of anxiety. That is not a knowledge gap so much as a teaching failure: Git is almost always taught as a list of commands, when the thing that makes it click is the data model underneath. Learn what Git actually stores, and the commands stop being spells you memorised and start being obvious.

This is a long, practical guide built around that idea. The object model first, then the everyday workflow, then the parts people avoid - rebasing, undoing things, finding the commit that broke production - with the commands you would actually type.

What Git actually stores

Git is a content-addressable filesystem with a version control interface bolted on top. That sounds like a joke about its usability, but it is literally the architecture, and it is why everything works the way it does.

There are exactly four object types. Every one is stored under the hash of its own contents, which is what makes history tamper-evident and deduplication automatic.

ObjectContainsAnalogy
BlobFile contents. No name, no path, no permissionsThe bytes of a file
TreeA list of names pointing at blobs and other treesA directory listing
CommitOne tree, zero or more parents, author, committer, messageA snapshot plus its provenance
TagA pointer to an object, with a message and optional signatureAn annotated bookmark
A branch is not on this list, because a branch is not an object. It is a 41-byte file containing a commit hash.

Commits are snapshots, not diffs

This is the single most important correction to make. Git does not store changes. Every commit points at a complete tree representing the whole project at that moment. The diffs you see are computed on demand by comparing two trees.

It sounds wasteful and is not. Unchanged files point at the same blob, so a commit touching one file in a 50,000-file repository adds one blob, a handful of trees and one commit object. Everything else is reused by hash.

bash
# Look at the machinery directly
git cat-file -t HEAD                 # commit
git cat-file -p HEAD                 # the raw commit object

# tree 9f2b...
# parent 4a1c...
# author Naiem <naiem@example.com> 1755100000 +0600
# committer Naiem <naiem@example.com> 1755100000 +0600
#
#     Add order validation

git cat-file -p HEAD^{tree}          # the directory listing it points at
git cat-file -p HEAD:src/app.ts      # the blob contents of one file
Twenty minutes with cat-file will teach you more about Git than any tutorial. Every object is inspectable and there is nothing hidden.

Refs are just files

bash
cat .git/HEAD                        # ref: refs/heads/main
cat .git/refs/heads/main             # 4a1c9f2b3d...

git rev-parse HEAD                   # resolve any ref to a hash
git rev-parse --abbrev-ref HEAD      # the current branch name
Creating a branch writes one small file. That is why branching in Git is instant, and why it was slow in the systems Git replaced.

So the whole model is: commits form a directed acyclic graph through their parent pointers, branches are movable labels on commits, and HEAD is a pointer to the branch you are currently on. Nearly every Git command is moving one of those pointers or building new objects.

The three areas

text
Working tree          Index (staging area)          HEAD / repository
─────────────         ────────────────────          ─────────────────
your actual files  →  what the next commit      →   committed history
                      will contain

     git add ─────────────►
                              git commit ─────────────►
     ◄───────────── git restore
     ◄──────────────────────────────── git restore --source=HEAD
The index is the part that confuses newcomers and the part that makes Git powerful. It lets you commit a subset of your changes deliberately.

Set-up worth doing once

bash
git config --global user.name  "Naiem Inahid"
git config --global user.email "naiem@example.com"

# Default branch for new repositories
git config --global init.defaultBranch main

# Only fast-forward or explicit merges on pull - never a surprise merge commit
git config --global pull.ff only

# Push the current branch to its same-named upstream
git config --global push.default simple
git config --global push.autoSetupRemote true

# Remember how you resolved a conflict, and reapply it automatically next time
git config --global rerere.enabled true

# Better diffs
git config --global diff.algorithm histogram
git config --global diff.colorMoved zebra

# Sort branch listings by most recent activity
git config --global branch.sort -committerdate

# Keep the repository fast in the background
git maintenance start
rerere - reuse recorded resolution - is the most underused setting here. On a long-lived branch you rebase repeatedly, it means resolving each conflict once instead of every time.

Sign your commits

bash
# SSH signing - simpler than GPG and uses a key you already have
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true

git log --show-signature -1
Author fields are free text - anyone can commit as anyone. Signing is what makes authorship verifiable, and forges display it.

Aliases that earn their keep

~/.gitconfig
[alias]
    s  = status -sb
    lg = log --graph --oneline --decorate --all
    last = log -1 HEAD --stat
    unstage = restore --staged
    amend = commit --amend --no-edit
    # branches by recency, with their upstream and last commit
    br = for-each-ref --sort=-committerdate refs/heads/ \
         --format='%(color:yellow)%(refname:short)%(color:reset) %(committerdate:relative) %(contents:subject)'
    # what have I not pushed?
    unpushed = log --oneline @{u}..HEAD

The everyday loop

bash
git status -sb                  # short, with branch and tracking info
git diff                        # working tree vs index - what is NOT staged
git diff --staged               # index vs HEAD - what WILL be committed

git add src/orders.ts           # stage a file
git add -p                      # stage selected hunks, interactively
git add -u                      # stage modifications and deletions, not new files

git commit -m "Reject orders with a zero total"
git commit --amend --no-edit    # fold staged changes into the last commit

git log --oneline -10
git log --graph --oneline --decorate --all

git add -p is the habit worth building

Interactive staging lets you split what you did into what you meant to do. You fixed a bug and also renamed a variable and also removed a stale comment - git add -p turns that into three reviewable commits instead of one unreviewable one.

bash
git add -p

# y - stage this hunk        n - skip it
# s - split into smaller hunks
# e - edit the hunk by hand
# q - quit                   ? - help
s to split and e to edit are the two that make it genuinely powerful. You can stage half a line if you need to.

Commit messages that survive contact with the future

text
Add rate limiting to the login endpoint

Brute-force attempts against /auth/login were not throttled, so an
attacker could try passwords at the speed of the network.

Limits to five attempts per minute per IP, returning 429 with a
Retry-After header. The limit is deliberately per-IP rather than
per-account so that an attacker cannot lock out a real user.

Refs: SEC-214
Subject in the imperative under 50 characters, blank line, then the body explaining why. The diff already shows what changed; it cannot show what you were thinking.

Branching, merging and rebasing

bash
git switch -c feature/order-limits      # create and switch
git switch main                         # switch
git switch -                            # back to the previous branch

git branch -d feature/order-limits      # delete if merged
git branch -D feature/order-limits      # delete regardless
git branch --merged main                # what is safe to delete
git switch and git restore split the jobs that git checkout used to do. checkout still works; the newer commands are less ambiguous and harder to misuse.

Merge or rebase

Both integrate one branch into another. They differ in what history you end up with, and the argument about which is correct has consumed more engineering hours than it deserves.

MergeRebase
What it doesCreates a commit with two parentsReplays your commits onto a new base
History shapePreserves the actual branchingLinear, as if you had worked in sequence
Commit hashesUnchangedRewritten - every commit is a new object
ConflictsResolved oncePotentially once per replayed commit
Safe on shared branchesYesNo
Best forIntegrating a finished branchTidying your own work before review
bash
# Rebase your feature branch onto the latest main before opening a PR
git switch feature/order-limits
git fetch origin
git rebase origin/main

# If a conflict stops you
#   fix the files, then:
git add <resolved-files>
git rebase --continue
#   or, to give up entirely and return to where you started:
git rebase --abort

# Merge a finished branch into main with an explicit merge commit
git switch main
git merge --no-ff feature/order-limits

Interactive rebase

bash
git rebase -i HEAD~5

# pick   4a1c9f2  Add order validation
# reword 8b3d1e4  fix typo            ← change the message
# squash 2c9f8a1  more validation     ← fold into the previous commit
# fixup  7e1b4c3  oops                ← fold in, discard the message
# drop   9d2a6f8  debug logging       ← delete the commit entirely
# edit   1f8c3b2  Refactor pricing    ← stop here so you can amend it
Reordering the lines reorders the commits. This is how a messy fifteen-commit branch becomes four commits a reviewer can actually follow.
bash
# The classic pattern for fixing an earlier commit
git add -p                              # stage just the fix
git commit --fixup=4a1c9f2
git rebase -i --autosquash 4a1c9f2~1    # positions it automatically

# Git 2.55 collapses that into one command (still experimental)
git add -p
git history fixup 4a1c9f2
git history fixup applies the staged change to the named commit and replays everything after it, keeping the original message. It aborts cleanly on conflict rather than stranding you mid-rebase.

Undoing things

This is the section people actually need and the one where the command names are least helpful. The question to ask first is always: which of the three areas am I trying to change?

SituationCommand
Unstage a file, keep the editsgit restore --staged file.ts
Discard uncommitted edits to a filegit restore file.ts
Discard everything uncommittedgit restore . then git clean -fd
Fix the last commit's messagegit commit --amend
Add a forgotten file to the last commitgit add file.ts && git commit --amend --no-edit
Undo the last commit, keep changes stagedgit reset --soft HEAD~1
Undo the last commit, keep changes unstagedgit reset HEAD~1
Undo the last commit and destroy the changesgit reset --hard HEAD~1
Undo a commit that is already pushedgit revert <sha>
Recover something you thought you destroyedgit reflog

reset versus revert

reset moves the branch pointer backwards, so the commits vanish from that branch's history. revert creates a new commit that undoes an old one, leaving history intact. On anything you have pushed, revert is the correct tool - reset plus a force push rewrites history other people already have.

bash
git revert 4a1c9f2                # undo one commit
git revert 4a1c9f2..8b3d1e4       # undo a range
git revert -m 1 <merge-sha>       # undo a merge, keeping the first parent

The reflog, and why you have not lost your work

Git records every movement of HEAD in a local log. Bad rebase, hard reset, deleted branch - as long as it was committed at some point in the last ninety days, it is still there and reachable.

bash
git reflog

# 8b3d1e4 HEAD@{0}: reset: moving to HEAD~3
# 2c9f8a1 HEAD@{1}: commit: Add order limits
# 7e1b4c3 HEAD@{2}: rebase (finish): returning to refs/heads/feature

# Go back to where you were
git reset --hard HEAD@{1}

# Or recover the state onto a fresh branch, which is safer
git switch -c rescue 2c9f8a1

# Deleted a branch by mistake?
git reflog | grep "checkout: moving from feature/order-limits"
Tell every new developer about the reflog in their first week. It converts Git from something frightening into something recoverable.

Finding things in history

Git's investigative commands are the least-known and most valuable part of the tool. This is what you reach for when something broke and nobody knows when.

Searching

bash
# Commits whose message matches
git log --grep="rate limit" --oneline

# Commits that ADDED or REMOVED a string - the pickaxe
git log -S "calculateVat" --oneline

# Commits whose diff matches a regex
git log -G "fetch\(.*retry" --oneline

# History of one file, following it through renames
git log --follow -p src/pricing.ts

# What changed on specific lines of a file
git log -L 40,80:src/pricing.ts

# Everything one author did last month
git log --author="Naiem" --since="1 month ago" --stat
-S is the one to remember. "When did this function disappear?" is a question it answers in seconds and grep cannot answer at all.

Blame, used properly

bash
git blame src/pricing.ts

# Ignore pure formatting commits so blame shows real authorship
git blame --ignore-revs-file .git-blame-ignore-revs src/pricing.ts

# Detect code moved from another file, not just edited in place
git blame -C -C src/pricing.ts

# Blame a specific line range
git blame -L 40,60 src/pricing.ts
.git-blame-ignore-revs
# The commit that reformatted the entire codebase with Prettier
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
# The commit that converted tabs to spaces
b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1
Set git config blame.ignoreRevsFile .git-blame-ignore-revs and commit the file. GitHub honours it too, so blame stops pointing at whoever ran the formatter.

Bisect: finding the commit that broke it

You know it worked in the release two weeks ago and it is broken now, and there are four hundred commits between. Bisect finds the culprit in about nine steps by binary search.

bash
git bisect start
git bisect bad                    # current commit is broken
git bisect good v1.4.0            # this tag was fine

# Git checks out a commit halfway between. Test it, then:
git bisect good     # or: git bisect bad
# …repeat until Git names the first bad commit

git bisect reset                  # return to where you started
bash
# Fully automatic: give it a script that exits 0 for good, non-zero for bad
git bisect start HEAD v1.4.0
git bisect run npm test -- --testPathPattern=checkout

# Or a custom script
git bisect run ./scripts/reproduce-bug.sh
git bisect run is one of the highest-leverage commands in the tool. Write a script that reproduces the bug, go and get a coffee, come back to the exact commit.

Working with other people

bash
git remote -v
git remote add upstream https://github.com/org/repo.git

git fetch origin                  # download, change nothing locally
git fetch --prune                 # also delete refs for branches gone from the remote

git pull                          # fetch + merge (or rebase, depending on config)
git pull --rebase                 # fetch + rebase your local commits on top

git push -u origin feature/x      # push and set upstream tracking
git push --force-with-lease       # safe force: refuses if the remote moved

Prefer fetch then look, over pull. pull is two operations pretending to be one, and the second one - merge or rebase - is the one that can surprise you. Fetch, inspect with git log HEAD..origin/main, then integrate deliberately.

Resolving conflicts

bash
git status                        # lists the conflicted files

# In the file:
# <<<<<<< HEAD
#   your version
# =======
#   their version
# >>>>>>> feature/order-limits

# Better: show the common ancestor too, so you can see what each side changed
git config --global merge.conflictStyle zdiff3

# Take one side wholesale
git checkout --ours  config/prod.json
git checkout --theirs src/pricing.ts

# Or open a three-way merge tool
git mergetool

git add <resolved>
git commit                        # or: git rebase --continue
zdiff3 is a meaningful upgrade over the default conflict style. Seeing the original alongside both changes usually makes the correct resolution obvious.

A branch workflow that works for most teams

  • main is always deployable and protected. Nobody pushes to it directly.
  • Short-lived branches off main, one per unit of work, named for the work rather than the person.
  • Rebase onto main before opening the pull request so the review is against current code.
  • Squash or merge on the forge, matching whichever history shape your team agreed on. Pick one and stop discussing it.
  • Delete the branch after merging. git fetch --prune cleans up your local copies.
  • Tag releases with annotated, signed tags: git tag -s v1.4.0 -m "Release 1.4.0".

The tools people do not know about

Stash

bash
git stash push -m "half-done pricing refactor"
git stash push -p                 # stash selected hunks only
git stash push -u                 # include untracked files

git stash list
git stash show -p stash@{0}       # see what is in it
git stash apply stash@{0}         # apply, keep it in the list
git stash pop                     # apply and remove
git stash drop stash@{0}
Stashes are easy to accumulate and forget. If work will live longer than an hour, a throwaway branch is a better container than a stash.

Worktrees

A worktree gives you a second working directory for the same repository, on a different branch, without cloning again. When someone needs an urgent review while you are mid-refactor, this beats stashing.

bash
git worktree add ../repo-review origin/feature/their-branch
cd ../repo-review                 # a full checkout, same .git objects

git worktree list
git worktree remove ../repo-review
Worktrees share the object database, so a second worktree costs a working copy of the files and nothing else. Useful for long builds too - run one branch's tests while editing another.

Hooks

bash
#!/bin/sh
# .git/hooks/pre-commit - must be executable

if git diff --cached --name-only | grep -qE '\.(ts|tsx)$'; then
    npx tsc --noEmit || {
        echo "Type check failed. Commit aborted."
        exit 1
    }
fi
Hooks in .git/hooks are local and not committed. For shared hooks, point core.hooksPath at a tracked directory, or use a manager like Husky or Lefthook.

Since Git 2.54 hooks can also be defined in configuration rather than as files, and 2.55 added the ability to run config-based hooks in parallel. Keep hooks fast - a pre-commit hook that takes twenty seconds is a hook people will start bypassing with --no-verify.

Submodules, and the honest advice

bash
git submodule add https://github.com/org/lib.git vendor/lib
git clone --recurse-submodules <url>
git submodule update --init --recursive
git submodule update --remote          # move to the tracked branch's latest
Submodules pin an exact commit of another repository. They work, and they generate a steady stream of confusion - detached HEADs, forgotten init steps, commits that reference submodule states nobody pushed.

Before adopting submodules, check whether your package manager can express the dependency instead. A private npm, PyPI or Cargo package is almost always the less painful answer. Submodules earn their place when you genuinely need the source, not the artifact - vendored forks, shared configuration, coupled repositories you build together.

Large repositories

bash
# Shallow clone - history truncated, much faster
git clone --depth 1 <url>
git fetch --unshallow                  # get the rest later if you need it

# Partial clone - full history, blobs fetched on demand
git clone --filter=blob:none <url>

# Sparse checkout - only the directories you work in
git sparse-checkout init --cone
git sparse-checkout set apps/web packages/ui

# Speed up status on a huge working tree
git config core.fsmonitor true
git config core.untrackedCache true

# Background maintenance: prefetch, repack, commit-graph
git maintenance start

# What is taking up all the space?
git count-objects -vH
Partial clone plus sparse checkout is the standard monorepo combination. Git 2.55 added built-in fsmonitor support on Linux, which was previously macOS and Windows only.

Git in 2026, and the road to 3.0

Git 2.55 shipped on 29 June 2026. Most of it is invisible - faster packing, less memory in the diff engine - but a few things are worth knowing about.

  • git history fixup folds staged changes into an earlier commit and replays the rest, replacing the --fixup plus autosquash dance. Still marked experimental.
  • fsmonitor on Linux, so git status in a large working tree can rely on observed filesystem events rather than a full scan.
  • Config-based hooks can run in parallel, building on the hook configuration added in 2.54.
  • Push remote groups - define a named group in config and push to several remotes at once. Note there is no --atomic across a group.
  • Rust is now enabled by default at build time, still optional via NO_RUST. It is expected to become mandatory at or after 3.0.

What Git 3.0 changes

ChangeWhat it means
SHA-256 by defaultReplaces SHA-1, which has had a demonstrated practical collision. The blocker is forge support - GitHub does not yet host SHA-256 repositories
main as the default branch2.52 added hints preparing users for the switch, including how to keep master if you prefer
Reftable storageA new reference backend replacing loose and packed refs, much faster in repositories with very many branches and tags
Rust in the buildCurrently optional and on by default; expected to become required
Targeted for late 2026, with no firm date. The transition depends more on hosting platforms than on Git itself.

None of this changes the commands you type. SHA-256 will mean longer hashes and a migration period where SHA-1 and SHA-256 repositories need to interoperate - work that has been landing incrementally since 2.45.

Mistakes that keep recurring

MistakeConsequence and fix
Committing secretsThe key is in history forever. Rotate it immediately - removing the commit does not un-leak it
git push --force on a shared branchErases other people's commits. Use --force-with-lease
Huge commits touching thirty filesUnreviewable, and impossible to revert cleanly. Use git add -p
Commit messages like "fix" or "wip"Useless in six months, and useless in git log --grep
Merging main into a feature branch repeatedlyA tangled graph. Rebase instead, or merge once at the end
Committing generated files or node_modulesBloated clones and constant conflicts. That is what .gitignore is for
Long-lived branchesMerge pain grows superlinearly with branch age. Merge weekly at worst
Using git pull without knowing your configYou get a merge or a rebase depending on settings you have never read
Fear of the reflogWork discarded that was fully recoverable
Committing large binariesPermanent repository bloat. Use LFS, or do not commit them

If you committed a secret

bash
# 1. Rotate the credential. Right now. Before anything else.
#    Assume it is compromised - it was pushed, mirrored, and possibly indexed.

# 2. Then remove it from history
uvx git-filter-repo --path config/secrets.json --invert-paths

# 3. Force push, and tell everyone to reclone
git push --force --all

# Prevent it happening again
uvx detect-secrets scan --baseline .secrets.baseline
git-filter-repo replaced the old filter-branch, which was slow and full of sharp edges. Step one is the one that matters - rewriting history does not recall a leaked key.

A learning path

  1. Week one. init, add, commit, status, log, diff. Do it in a throwaway repository and inspect .git/ as you go. Look at HEAD and refs/heads/ as plain files.
  2. Week two. Branching, merging, remotes. Deliberately create a conflict and resolve it. Turn on merge.conflictStyle = zdiff3 and see the difference.
  3. Week three. The undo commands, and the reflog. Break things on purpose in a scratch repository and recover them. This is the week Git stops being scary.
  4. Week four. git add -p until it is automatic, then interactive rebase to clean up a branch before review.
  5. Month two. Archaeology: log -S, blame -C, and a real git bisect run. Then worktrees and hooks.
  6. Ongoing. Read Pro Git - it is free, official, and chapter 10 on internals is the one that makes everything else make sense. Play Learn Git Branching for the graph intuition.

Verdict

Git's interface is genuinely inconsistent - the same word means different things in different commands, and checkout did four unrelated jobs for a decade. That is a real criticism and it is why so many people learn a fixed set of commands and stop.

Underneath it is a small, elegant model that has not needed to change in twenty years. Four object types, content-addressed, with branches as movable labels. Once that model is in your head, the commands become predictable, the error messages become readable, and the recovery paths become obvious - because almost nothing in Git is actually destructive.

Spend an afternoon with git cat-file and git reflog. One teaches you what Git stores, the other teaches you that you cannot easily lose anything. Between them they remove most of the fear.

Our advice to anyone who finds Git stressful

If you take three things from this: use git add -p so your commits say one thing each, use --force-with-lease instead of --force, and remember that the reflog has your back. The rest is detail you can look up - and now you know what to look up.

Sources

Back to Blog
Share:

Related Posts