Offered by Dead Brands
Git is a content-addressed snapshot store with a graph on top — almost every "lost work" panic dissolves once you hold that model.
Use it over MCP: connect to https://mcp.deadbrands.co/hub/mcp and call skills_get with slug git-and-version-control-craft.
# Git and Version Control Craft
Credit: SteTy — published free in the Skills Hub
Git is a content-addressed snapshot store with a graph on top — almost every "lost work" panic dissolves once you hold that model. This skill covers the daily craft (commits, branches, merges) and the recovery toolkit (rebase discipline, bisect, blame, reflog, cherry-pick, conflict resolution, .gitignore hygiene) as documented in the official git docs and Pro Git.
## Overview
Every commit is a full snapshot of the project plus metadata (author, message, parent pointers), identified by a hash of its contents. A **branch is just a movable pointer to one commit**; `HEAD` points at the current branch. Three areas matter: the **working tree** (files on disk), the **index/staging area** (what the next commit will contain), and the **repository** (committed history). `git status` and `git diff` (`--staged` for index-vs-HEAD) tell you exactly which area differs from which — run them before and after every operation and Git stops being mysterious.
Because commits are immutable and referenced by hash, rewriting operations (amend, rebase) don't edit commits — they create *new* commits with new hashes and move the pointer. The originals linger, findable via the reflog. That single fact is the basis of all Git recovery.
## Core Principles
1. **Commit early, commit atomically.** One commit = one logical change that leaves the tree working. Small commits make review, bisect, revert, and cherry-pick all trivial.
2. **The golden rule of rebase: never rebase commits that exist outside your local repo** (already pushed and shared). Rewriting public history forces everyone else into recovery work. Rebase your own unpushed work freely.
3. **Prefer the safest undo that fits.** On shared history, `revert` (a new commit that inverts a change) beats `reset` (pointer surgery). `--force-with-lease` beats `--force`.
4. **Nothing committed is ever really lost** until garbage collection — reflog entries default to 90 days (30 for unreachable commits). Panic is optional; look in the reflog first.
5. **The index is a feature.** Stage deliberately (`git add -p` to stage hunks) so each commit contains exactly its change — not whatever happened to be in the directory.
## Techniques
**Commits and messages:** subject line ≤ ~50 characters, capitalized, imperative mood ("Fix retry loop", not "Fixed"/"Fixes"), no trailing period; body wrapped at ~72, explaining *why* (the diff already shows *what*). `git commit --amend` rewrites the tip commit (message or forgotten file) — only before pushing. `git commit -v` shows the diff while you write the message.
**Branches and merging:** `git switch -c feature` creates and enters a branch. `git merge feature` integrates: if your branch hasn't moved, it's a **fast-forward** (pointer slides, no new commit); otherwise Git finds the best common ancestor, three-way merges, and writes a **merge commit** with two parents. Merge preserves the true shape of history — who branched, when it joined — at the cost of a non-linear graph. Use `--no-ff` to force a merge commit that documents the feature's existence.
**Rebase:** `git rebase main` replays your branch's commits one by one onto the tip of `main`, producing a straight-line history that reads as if you started from today's main. The replayed commits are new objects with new hashes. Interactive rebase (`git rebase -i HEAD~5`) reorders, squashes, rewords, edits, or drops commits — the standard pre-push cleanup for your own branch. After rebasing a branch you had already pushed (only your own, only if you must), update the remote with `git push --force-with-lease`, which refuses to overwrite if someone else pushed in the meantime. **Merge vs rebase:** merge = preserve history, safe everywhere; rebase = linear history, easier `git log`/bisect reading, safe only for unshared commits. Note the classic gotcha: during a rebase, `--ours` and `--theirs` are *swapped* relative to merge, because the target branch becomes the base you're replaying onto.
**Bisect — binary search for the breaking commit:** tell Git a known-bad and a known-good commit and it checks out the midpoint, halving the range each step — ~10 tests finds the culprit among 1,000 commits.
```
git bisect start
git bisect bad # current commit is broken
git bisect good v2.1.0 # this one worked
# test, then: git bisect good / git bisect bad … until it names the commit
git bisect reset
```
Automate with `git bisect run ./test.sh` — Git drives, using the script's exit code (0 good, 1 bad, 125 skip). `git bisect skip` steps around untestable commits (build failures, flaky tests).
**Blame — who and why for each line:** `git blame file` annotates each line with the last commit, author, and date. `git blame -L 40,60 file` restricts the range; `-w` ignores whitespace-only changes; `--ignore-rev <hash>` skips a mass-reformat commit so blame points at the last *meaningful* edit. Blame's real use isn't finger-pointing — it's finding the commit whose message and diff explain *why* the line exists. `git log -S 'text'` (pickaxe) finds commits that added/removed a string; `git log -G 'regex'` matches on diff content.
**Reflog — the recovery net:** `git reflog` shows everywhere HEAD has pointed (commits, checkouts, resets, rebases), newest first, with `HEAD@{n}` names. Recovery pattern:
```
git reflog # find the hash from before the accident
git reset --hard <hash> # move the branch back to it — or —
git branch rescue <hash> # pin it on a branch first, inspect, then decide
```
Lost a commit to `reset --hard`, a bad rebase, or a deleted branch (`git branch -D`)? The commit object still exists; the reflog (or `git fsck --lost-found` for dangling objects) finds it. `git stash` entries also live in a reflog.
**Cherry-pick — copy one commit:** `git cherry-pick <hash>` applies that commit's diff onto your current branch as a new commit — the standard way to backport a fix to a release branch. `-x` appends "(cherry picked from commit …)" for traceability. Ranges work: `git cherry-pick A..B`. For a merge commit you must name the mainline parent: `git cherry-pick -m 1 <merge>`.
**Conflict resolution:** conflicts happen when both sides changed the same lines (or one edited, one deleted). The file contains markers:
```
<<<<<<< HEAD
your side's version
=======
incoming version
>>>>>>> feature
```
Workflow: `git status` lists conflicted paths → edit each file to the correct combined content, deleting all markers → `git add` the resolved files → `git merge --continue` (or `rebase --continue`). Escape hatches: `git merge --abort` / `git rebase --abort` return to the pre-operation state; `git checkout --ours <file>` / `--theirs <file>` takes one side wholesale (remember the rebase swap). Prevent repeat pain on recurring conflicts with `git config rerere.enabled true` (reuse recorded resolutions).
**.gitignore discipline:** patterns are per-line globs matched relative to the .gitignore's location: `*.log`, `build/` (directory), `/root.txt` (anchored to that level), `**/temp`, `!important.log` (negation re-includes). Ignoring only affects *untracked* files — an already-committed file keeps being tracked until `git rm --cached <file>` untracks it (the file stays on disk). Keep a global excludes file (`core.excludesFile`) for editor/OS junk (`.DS_Store`, `.idea/`) and project .gitignore for project artifacts (dependencies, build output, `.env`, local config). Rule of thumb: ignore anything generated or secret; commit anything needed to build from a clean clone — including the lockfile.
**Undoing almost anything:**
| Situation | Fix |
|---|---|
| Fix last commit (unpushed) | `git commit --amend` |
| Unstage a file | `git restore --staged <file>` |
| Discard working-tree edits | `git restore <file>` (unrecoverable — the edits were never committed) |
| Undo last commit, keep changes staged | `git reset --soft HEAD~1` |
| Undo last commit, keep changes unstaged | `git reset --mixed HEAD~1` (default) |
| Undo last commit, destroy changes | `git reset --hard HEAD~1` (recoverable via reflog) |
| Undo a *pushed* commit | `git revert <hash>` — new inverse commit, history stays honest |
| Park unfinished work | `git stash push -m "wip"` → `git stash pop` |
## Worked Examples
Wrong-branch commit:
```
git switch correct-branch && git cherry-pick <hash>
git switch wrong-branch && git reset --hard HEAD~1
```
A rebase went sideways mid-way: `git rebase --abort` returns instantly to the pre-rebase state. Rebase *finished* badly yesterday: `git reflog` → find the entry just before `rebase (start)` → `git reset --hard HEAD@{n}` restores the branch exactly.
Regression hunt: `git bisect start HEAD v1.4.0` then `git bisect run make test` — coffee while Git binary-searches 800 commits in 10 automated steps and names the first bad commit.
## Common Pitfalls
- **Rebasing a shared branch** — new hashes for the same changes; teammates' clones diverge and every merge doubles. Golden rule, no exceptions.
- **`git push --force`** instead of `--force-with-lease` — silently overwrites a teammate's new commits on the remote.
- **Committing secrets or huge binaries** — they live in history forever; deleting the file in a later commit does not remove them. Rotate the credential immediately, then scrub with `git filter-repo` (and accept the full-history rewrite).
- **`git add .` without looking** — sweeps in `.env`, temp files, and build junk. Check `git status` / `git diff --staged` first.
- **Adding a file to .gitignore and wondering why it's still tracked** — ignore rules don't untrack; `git rm --cached` does.
- **Long-lived branches** — weeks of divergence guarantee conflict archaeology; integrate early and often.
- **Detached HEAD work left unbranched** — commits made while HEAD points at a raw hash are easy to strand; `git branch save-me` before switching away (the reflog is the backup plan).
## Practice Drills
1. In a scratch repo, make 8 commits, delete the branch with `-D`, then recover the tip commit using only `git reflog`.
2. Create a deliberate merge conflict, resolve it by hand, abort a second attempt with `--abort`, and take one side wholesale with `--ours` on a third.
3. Seed a scratch repo with a bug introduced at a known commit among 30; find it with `git bisect run` and a one-line test script.
4. Rebase a 4-commit feature branch onto an advanced main, resolve the conflict, and compare `git log --graph` before/after.
5. Track a file, then untrack it with `git rm --cached` + .gitignore and verify a clean `git status` while the file remains on disk.
## When to Use and Limits
- **Use it when:** a task calls for git and version control craft, as laid out in the Techniques and Worked Examples above.
- **Watch first for:** Rebasing a shared branch; `git push --force`; Committing secrets or huge binaries (see Common Pitfalls).
- **Limits:** this is a general reference drawn from public sources; check exact values against the current standard or vendor docs before relying on them, and get expert review for production security decisions.
## Quick Reference
- Areas: working tree → (`git add`) → index → (`git commit`) → history. `git status` is home base.
- Merge preserves history (merge commit, two parents); rebase rewrites it (new hashes, linear) — never on shared commits.
- `reflog` = where HEAD has been = recovery. Defaults: 90 days / 30 days unreachable.
- `bisect` = log₂ binary search; `run` automates it (exit 125 = skip).
- `blame -L a,b -w` per line; `-S` pickaxe finds where a string came/went.
- `cherry-pick -x <hash>` copies a commit; `-m 1` for merge commits.
- Pushed mistake → `revert`. Unpushed mistake → `amend`/`reset`. Lost commit → `reflog` + `reset --hard <hash>`.
- .gitignore doesn't untrack: `git rm --cached`. Force-push only with `--force-with-lease`.