04 Β· Software Engineering

Git & Collaboration

Git questions separate people who memorized commands from people who understand the model. Once you know git is a content-addressed graph with movable pointers, rebase, reflog, and bisect stop being magic. Bonus for you: Sentinel creates PRs programmatically β€” you've automated the collaboration layer most people only click through.

01The mental model: a content-addressed object graph + pointers

Git is, at bottom, a key–value store in .git/objects where the key is the SHA-1/SHA-256 hash of the content. Four object types:

blob

File contents. Just bytes. No filename, no metadata β€” the name lives in the tree that points to it.

tree

A directory listing: names + modes β†’ blob/tree hashes. Trees pointing at trees = your directory hierarchy, one snapshot.

commit

A pointer to one tree (the complete snapshot) + parent commit hash(es) + author/committer + message.

tag

(Annotated) a named, sign-able pointer to a commit β€” how releases get fingerprinted.

commit 7d1c tree 5a90 (parent …) commit 9e3f tree 4b2a parent 7d1c author Β· msg tree 4b2a README.md β†’ e3d9 src/ β†’ tree 8c11 blob e3d9 # readme bytes tree 8c11 app.py β†’ b7f0 blob b7f0 def main(): …
One commit β†’ one root tree β†’ blobs and subtrees. The commit also points at its parent, forming the history DAG. Every arrow is a content hash, so a commit hash fingerprints the entire snapshot and the entire history behind it.

Three consequences that unlock everything

Commits are snapshots, not diffs. Every commit references a full tree. Diffs are computed on demand between two snapshots. Storage stays small because identical content hashes identically β€” unchanged files are shared between commits β€” plus packfile delta compression. This is why checkout is fast, and why "which diff does this commit contain?" depends on which parent you compare against.

Content-addressing = integrity + dedup. Change one byte anywhere and every hash upstream of it changes. A commit hash therefore fingerprints the entire history behind it β€” that's why rewriting history changes hashes, always.

History is a DAG. Commits point to parents. Merge commits have two parents. "Branches" are not containers of commits β€” they're just pointers into this graph.

02Refs, HEAD, and the staging area

Everything is a pointer

  • A branch is a 41-byte file (.git/refs/heads/main) containing a commit hash. Creating a branch is writing one file β€” that's why it's free.
  • HEAD is a pointer to a branch (usually). Commit β†’ new commit object created β†’ the branch moves forward β†’ HEAD follows. Detached HEAD just means HEAD points directly at a commit instead of a branch; commits you make are fine, they're just unreferenced once you leave (recoverable via reflog).
  • Remote-tracking refs (origin/main) are your local, read-only cache of where the remote's branches were at last fetch. git fetch updates them; git pull = fetch + merge (or rebase) into your branch. Understanding that origin/main is local state explains 90% of "git is out of sync" confusion.

The three zones β€” and why staging exists

working directory  --git add-->  index (staging)  --git commit-->  object DB (history)

The index/staging area is a proposed next commit β€” a full snapshot draft you build up. Why it exists (interview-worthy): it decouples editing from committing, letting you commit a subset of your changes. git add -p (stage hunk-by-hunk) is the power move: you fixed a bug and drive-by-cleaned some naming β€” stage them into two separate coherent commits instead of one mudball.

git reset in this model

FlagBranch pointerIndexWorking filesVerdict
--softmoveskeptkeptCommits "un-happen"; all work stays staged
--mixed (default)movesresetkeptWork stays in files, unstaged
--hardmovesresetoverwrittenThe only genuinely destructive one for uncommitted work

03Branching strategies

Trunk-based

Everyone merges to main frequently (≀ a few days per branch); main is always releasable; incomplete features hide behind feature flags; deploys are decoupled from merges. What CI/CD-mature teams (and DORA research) converge on: small integrations = small conflicts = fast feedback.

GitHub Flow

Trunk-based with a PR ritual: branch β†’ PR β†’ review + CI β†’ squash/merge β†’ deploy. The default for most product teams β€” almost certainly what you'll describe.

Git Flow

develop + feature/* + release/* + hotfix/* + main-as-released. Heavyweight; justified only when you ship versioned releases and maintain several in parallel (on-prem software, mobile release trains, libraries with LTS lines). For continuously-deployed services it adds ceremony and long-lived divergence for no benefit β€” saying exactly that is the expected senior answer.

Release branches

Cut release/1.4 to stabilize while main moves on; cherry-pick fixes back. Fine as a targeted tool even in trunk-based shops.

The principle beneath all of them: branch lifetime is the enemy. The longer a branch lives, the bigger the divergence, the worse the merge, the later the feedback. Every strategy is a policy for keeping integration frequent.

04Merge vs rebase β€” the honest tradeoff

Both integrate branch B into branch A; they differ in the shape of history they produce.

Merge creates a merge commit with two parents. History records what actually happened, including the parallelism. Never rewrites anything β†’ safe on shared branches, always.

Rebase replays your commits, one by one, as new commits on top of the new base. Result: linear history, as if you'd started your work from the latest main. The originals aren't modified (impossible β€” content-addressed); new commits are minted and your branch pointer moves. The old ones become unreferenced (reflog-recoverable).

Commit Graph Stepper β€” watch merge and rebase diverge

Build the DAG one command at a time, then take the fork: merge adds a two-parent node; rebase mints new commits and abandons the old ones.

MergeRebase
HistoryTrue but tangledClean/linear but "rewritten"
Shared-branch safeYesNo β€” cardinal rule below
Conflict experienceOnce, all at oncePer-commit (finer-grained; can repeat β€” git rerere caches resolutions)
Bisect/log readabilityHarderExcellent

The cardinal rule: never rebase commits that others may have based work on β€” i.e., anything pushed to a shared branch. Rebasing mints new hashes; teammates' work still points at the old ones; the repo forks into parallel realities requiring force-pushes and pain. Your own unpushed/unshared feature branch: rebase freely.

A sane team policy to state: rebase your feature branch onto main to stay current (instead of merge-from-main noise); then land via squash-merge or rebase-merge so main stays linear, one commit per PR, revertable in one command. git push --force-with-lease (never bare --force) when updating your own PR branch β€” it refuses to clobber work you haven't seen.

05Interactive rebase β€” history as a draft

git rebase -i main opens your commits as an editable script:

pick    a1b2c3  Add webhook signature verification
squash  f4d5e6  fixup typo
reword  9g8h7i  wip                      ← becomes a real message
drop    k1l2m3  debug logging

Verbs: pick (keep), reword (edit message), squash/fixup (fold into previous, keep/discard message), edit (stop here to amend/split), drop β€” and reordering lines reorders commits.

Why it matters: you develop in the order you thought, but you should publish in the order that reads. Interactive rebase converts "wip, wip, fix, actually fix" into 3 coherent commits a reviewer can follow. Power shortcut: git commit --fixup=<sha> then git rebase -i --autosquash β€” mark fixes for auto-folding as you go.

06Debugging with history: bisect

git bisect = binary search over commits to find which commit introduced a bug.

git bisect start
git bisect bad                # current commit is broken
git bisect good v2.1.0        # this release was fine
# git checks out the midpoint; you test; repeat:
git bisect good|bad
# β†’ "abc123 is the first bad commit"
git bisect reset

1,000 commits β†’ ~10 tests. The killer feature:

git bisect run pytest tests/test_thing.py β€” give it any command that exits 0=good / 1=bad and it finds the culprit unattended. Pairs beautifully with a repro test: write the failing test first, let bisect run it across history.

Practical notes that show real use: it only works if commits build (another argument for atomic commits); git bisect skip for unbuildable ones; the found commit tells you where to look, and small commits mean the diff you're staring at is small.

07Reflog β€” the undo journal (nothing is ever lost)

git reflog is a local, per-ref journal of everywhere HEAD (and each branch) has pointed, for ~90 days β€” including states no branch points to anymore.

git reflog
# a9f8e7 HEAD@{0}: reset: moving to HEAD~3        ← the mistake
# 3c2b1a HEAD@{1}: commit: the work you "lost"    ← the treasure
git branch rescue 3c2b1a       # or: git reset --hard HEAD@{1}

The deep point to articulate: git almost never deletes commits β€” it only moves pointers. "Lost" commits are just unreferenced, and reflog is the map back until gc reaps them weeks later. Limits to admit: reflog is local-only (no cloning it), and it can't recover uncommitted work destroyed by reset --hard / checkout -- (partial exception: git fsck --lost-found can surface blobs of staged files).

Rescue recipes β€” cover the scenario before they finish asking

Rescue #1: You ran git reset --hard HEAD~3 and vaporized three committed changes.

git reflog                     # find the pre-reset tip, e.g. 3c2b1a
git reset --hard HEAD@{1}      # or: git branch rescue 3c2b1a

The commits were never deleted β€” only the branch pointer moved. Reflog remembers where it was. (If the work was uncommitted, reflog can't help; git fsck --lost-found may recover staged blobs.)

Rescue #2: A rebase went sideways halfway through and your branch is mangled.

git reset --hard mybranch@{1}  # "where the branch was before the rebase"
# modern git also leaves ORIG_HEAD:
git reset --hard ORIG_HEAD

Rebase mints new commits; the originals are still in the object DB, and the branch's reflog entry from just before the rebase points straight at them.

Rescue #3: You deleted a branch that had unmerged work.

git reflog                     # find the deleted branch's last tip sha
git branch restored <sha>      # re-branch it β€” done

Deleting a branch deletes a 41-byte pointer file, not the commits. Find the tip in reflog and point a new branch at it.

Rescue #4: You committed in detached-HEAD state, then switched away.

git reflog                     # your detached commits are in HEAD's journal
git branch keep-this <sha>

Detached HEAD isn't a problem β€” HEAD just points at a commit instead of a branch (normal during bisect or checking out a tag). The only footgun is leaving commits unreferenced, and reflog is the map back.

Related tools to mention in the same breath

git worktree add

Multiple branches checked out in parallel dirs β€” review a PR without stashing your work.

git stash

Shelve WIP quickly and get a clean working directory.

git cherry-pick

Copy one commit onto another branch β€” hotfix backports to release branches.

git revert

New commit that undoes an old one β€” the safe, shared-branch way to undo, vs reset which rewrites.

08PR hygiene β€” engineering the review, not just the code

A PR is a product whose user is the reviewer. Your Sentinel experience is gold here: you generated 100+ PRs/month for human+AI review, which forced you to learn what makes a PR consumable.

Scope

One logical change per PR. Small PRs get real review; big ones get rubber stamps (review defect-detection falls off a cliff past a few hundred lines). Split by: refactor-then-feature (two PRs β€” the pure-mechanical refactor is fast to review, the feature diff becomes tiny), vertical slices, or stacked PRs for genuinely large work.

Description

What changed, why (the reviewer can see the what in the diff β€” the why is what they can't see), how it was tested, and anything risky ("touches the retry path; verified against staging with forced 429s"). Link the issue. Screenshots/logs for anything visual or behavioral.

Before requesting review

Self-review your own diff top-to-bottom (you'll catch the leftover debug print), make CI green first (reviewer time is the scarce resource; robots go first), annotate non-obvious hunks with PR comments.

Landing

Squash-merge for a clean one-commit-per-PR main; delete the branch; the PR title becomes the commit message β€” write it accordingly.

As a reviewer: respond within a working day (review latency is the #1 team-velocity tax), separate blocking issues from nit:s, approve-with-comments when the fixes are trivial rather than forcing another round-trip.

09Semantic (conventional) commits

type(scope): imperative summary ≀ ~70 chars

Body: the why, the context, the tradeoffs β€” wrapped, optional.

BREAKING CHANGE: description        ← or type! for breaking

Types: feat, fix, refactor, perf, test, docs, build, ci, chore. Examples:

feat(webhooks): verify GitHub HMAC signatures before enqueue
fix(gateway): retry on provider 429 with jittered backoff
refactor(review): extract diff-risk scoring into pure function
feat(api)!: remove deprecated /v1/incidents endpoints

Why teams adopt it (beyond tidiness): the messages are machine-readable, enabling automated changelogs and semantic-version bumps (fix→patch, feat→minor, !→major) via semantic-release tooling; git log --oneline becomes scannable; and the discipline of picking a type forces atomic commits (if you can't pick one type, it's two commits).

Message craft: imperative mood ("add", not "added" β€” reads as "if applied, this commit will add…"); subject = what, body = why. The body is documentation with a guaranteed audience: the future debugger running git blame β€” often you. A commit that explains why a weird workaround exists saves someone re-deleting it and re-breaking prod.

10Rapid-fire interview Q&A

Q: What actually happens on git commit?

Staged content is written as blobs, trees are built for the directory structure, a commit object is created pointing to the root tree + parent + metadata, and the current branch ref advances to the new commit's hash.

Q: Merge vs rebase β€” your policy?

Rebase my own unshared feature branch onto main to stay current; never rebase shared history; land PRs by squash-merge so main is linear and each PR reverts atomically. --force-with-lease only, on my own PR branches.

Q: You force-pushed and lost commits β€” recover them.

git reflog on that branch, find the pre-push tip, git branch rescue <sha> (or reset the branch to it). The commits were unreferenced, not deleted.

Q: How do you find which commit broke prod?

Pin a repro (ideally a failing test), then git bisect run <test-cmd> between the last known-good release tag and HEAD β€” binary search finds the first bad commit in logβ‚‚(n) automated steps.

Q: Why snapshots instead of diffs?

Snapshots make checkout/branch O(changed content), make every commit independently verifiable by hash, and let diffs be computed against any base. Dedup + packfiles keep storage cheap.

Q: Detached HEAD β€” problem?

No β€” HEAD just points at a commit instead of a branch. It's the normal state during bisect/checkout-of-a-tag. Only footgun: committing there and switching away leaves the commits unreferenced β€” recover via reflog, or branch before leaving.

11Self-test β€” close the tab and answer these

Name git's four object types and what a commit object contains.

Blob (file bytes), tree (directory listing mapping names β†’ blob/tree hashes), commit (one root tree + parent hash(es) + author/committer + message), annotated tag (named, sign-able pointer to a commit). Filenames live in trees, not blobs.

Why is creating a branch free, and what is HEAD?

A branch is a 41-byte file containing a commit hash β€” creating one is writing one file. HEAD is a pointer to a branch (usually); on commit, the branch moves and HEAD follows. Detached HEAD = HEAD points directly at a commit.

Contrast git reset --soft / --mixed / --hard.

All move the branch pointer. --soft keeps index and files (work stays staged); --mixed resets the index but keeps files (work unstaged); --hard also overwrites the working files β€” the only one destructive to uncommitted work.

When is Git Flow actually justified, and what should CD services use instead?

Git Flow earns its weight only when you ship versioned releases and maintain several in parallel (on-prem, mobile release trains, LTS libraries). Continuously-deployed services should use trunk-based / GitHub Flow β€” short-lived branches, feature flags, main always releasable.

State the cardinal rule of rebase and explain why it exists mechanically.

Never rebase commits others may have based work on (anything pushed to a shared branch). Rebase mints new commits with new hashes β€” content-addressing makes in-place edits impossible β€” so teammates' work still points at the old hashes and the histories fork.

Write the unattended-bisect session that finds a prod-breaking commit.

git bisect start, git bisect bad, git bisect good v2.1.0, then git bisect run pytest tests/test_repro.py; finish with git bisect reset. ~logβ‚‚(n) automated steps; requires commits that build (git bisect skip for the rest).

What can reflog recover β€” and what can it never recover?

Recovers anything that was ever committed: bad reset --hard, botched rebase (branch@{1} / ORIG_HEAD), deleted branches, detached-HEAD commits. It cannot recover uncommitted work destroyed by reset --hard or checkout -- (except git fsck --lost-found for staged blobs), and it's local-only β€” it doesn't clone.

What do conventional commits buy a team beyond tidy logs?

Machine-readable messages: automated changelogs and semver bumps (fix→patch, feat→minor, !→major) via semantic-release; scannable git log --oneline; and the type discipline forces atomic commits — if one type doesn't fit, it's two commits.