# Git Cheatsheet

- URL: https://karandeepsingh.ca/cheatsheets/devops/git/
- Author: Karandeep Singh
- Published: 2025-11-18
- Tags: git, devops
- Summary: The git commands I actually reach for, built around one table of how to undo things, plus reflog recovery, scripted bisect, and the rules for rewriting history that is already shared.

Nearly every git question I get asked is really the same question: *how do I undo this?* So that is what this sheet is built around — one table for undoing things, then the few commands worth knowing properly. The rest of git you can look up; these are the ones where guessing costs you work. Everything shown running below was run.

**tip:**
The rule that prevents most git disasters: **rewrite history only on commits
nobody else has pulled.** Your own unpushed branch, rebase freely. A shared
branch, use `revert`. That single line decides between almost every pair of
commands here.

## The Undo Table

> **Docs:** [Reset, restore and revert](https://git-scm.com/docs/git#_reset_restore_and_revert)

| I want to | Command |
|---|---|
| Unstage a file, keep the edit | `git restore --staged file` |
| Throw away edits to a file | `git restore file` |
| Throw away *all* uncommitted edits | `git restore .` then `git clean -fd` |
| Fix the last commit message | `git commit --amend` |
| Add a forgotten file to the last commit | `git add f && git commit --amend --no-edit` |
| Undo the last commit, keep changes staged | `git reset --soft HEAD~1` |
| Undo the last commit, keep changes unstaged | `git reset HEAD~1` |
| Undo the last commit and the changes | `git reset --hard HEAD~1` |
| Undo a commit that is already pushed | `git revert <sha>` |
| Recover something you just destroyed | `git reflog` then `git reset --hard <sha>` |
| Move the last commit to another branch | `git reset --hard HEAD~1` after cherry-picking it across |
| Abandon a merge in progress | `git merge --abort` |
| Abandon a rebase in progress | `git rebase --abort` |

**danger:**
`--hard` and `git clean` are the two commands on this sheet that destroy work
the reflog cannot return, because uncommitted changes were never in a commit
to begin with. `git stash` first if you are not certain.

## reset: What Each Mode Touches

Three things move, and the mode decides how many: the **branch pointer**, the
**index** (staging area) and the **working tree**.

| Mode | Branch | Index | Working tree |
|---|---|---|---|
| `--soft` | moved | untouched | untouched |
| `--mixed` *(default)* | moved | reset | untouched |
| `--hard` | moved | reset | **overwritten** |

```text
$ git log --oneline | head -3
7b8d969 commit 3
b126a69 commit 2
7c1ee69 commit 1
$ git status --short
 M app.txt
A  new.txt
$ git reset --soft HEAD~1
$ git status --short
MM app.txt
A  new.txt
# commit 3's changes are now staged alongside what was already there,
# and new.txt is still staged. Nothing was lost.
$ git reset --mixed HEAD~1
$ git status --short
 M app.txt
?? new.txt
# the index is cleared: app.txt is modified-but-unstaged and new.txt is
# back to being untracked. Both files still have all their content.
```

## reflog: The Undo Button Behind the Undo Button

> **Docs:** [git reflog](https://git-scm.com/docs/git-reflog)

A commit is never deleted by `reset`, only left unreferenced. The reflog
records every position `HEAD` has held, so it is still reachable:

```text
$ git log --oneline | head -3
ca5e030 commit 5
bc458bb commit 4
7c1ee69 commit 1
$ git reset --hard HEAD~2
$ git log --oneline | head -2
7c1ee69 commit 1
# two commits gone from the branch
$ git reflog
7c1ee69 HEAD@{0}: reset: moving to HEAD~2
ca5e030 HEAD@{1}: commit: commit 5
bc458bb HEAD@{2}: commit: commit 4
7c1ee69 HEAD@{3}: reset: moving to HEAD
$ git reset --hard ca5e030
$ git log --oneline | head -3
ca5e030 commit 5
bc458bb commit 4
7c1ee69 commit 1
```

Entries expire after 90 days (30 for unreachable commits), and the reflog is
**local** — it is not pushed, and a fresh clone has none.

```bash
git reflog                        # HEAD's history
git reflog show main              # one branch's history
git reset --hard HEAD@{2}         # or address it by position
git fsck --lost-found             # when even the reflog has no entry
```

## revert: Undo Without Rewriting

```bash
git revert <sha>                  # a new commit that undoes that one
git revert --no-commit <sha>      # stage the undo, commit it yourself
git revert -m 1 <merge-sha>       # undo a merge, keeping parent 1's line
git revert <old>..<new>           # a range, newest first
```

```diff
# what reverting looks like in the log
@@ history @@
 a1b2c3d  Add rate limiting
 d4e5f6a  Break pagination        <- the bad commit, still there
+9f8e7d6  Revert "Break pagination"
```

Use `revert` on anything already pushed. Use `reset` only on commits that live
nowhere but your machine.

## rebase vs merge

> **Docs:** [git rebase](https://git-scm.com/docs/git-rebase)

```bash
git merge main                    # a merge commit; history shows the branching
git rebase main                   # replays your commits on top; linear history
git rebase --continue             # after fixing a conflict
git rebase --abort                # back to where you started
git pull --rebase                 # the one to put in your config
```

```bash
git config --global pull.rebase true       # no more accidental merge commits
git config --global rebase.autoStash true  # stash and restore around a rebase
```

**warning:**
Rebase gives every replayed commit a **new SHA**. Doing it to a branch someone
else has pulled means their history and yours disagree, and the next `pull`
either conflicts or quietly duplicates the commits. If you must force-push a
shared branch, `--force-with-lease` refuses when the remote moved since you
last fetched; plain `--force` does not check.

### Interactive rebase

```bash
git rebase -i HEAD~5              # edit the last five
git rebase -i --root              # all of them
```

```text
pick   a1b2c3d  Add parser
squash d4e5f6a  Fix typo in parser     # fold into the one above
reword 9f8e7d6  Add tests              # change the message
edit   1a2b3c4  Add validation         # stop here to amend
drop   5e6f7a8  Debug printf           # remove entirely
```

## bisect: Find the Commit That Broke It

> **Docs:** [git bisect](https://git-scm.com/docs/git-bisect)

Binary search over history. With `run` it is fully automatic — your script's
exit status answers the question, zero for good and non-zero for bad:

```text
$ cat test.sh
#!/bin/sh
[ "$(./calc.sh)" = "4" ]
$ git bisect start HEAD <first-commit>
$ git bisect run ./test.sh
running './test.sh'
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[<sha>] change 7
running './test.sh'
Bisecting: 0 revisions left to test after this (roughly 1 step)
[<sha>] refactor arithmetic
running './test.sh'
<sha> is the first bad commit
commit <sha>

    refactor arithmetic

 calc.sh | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
bisect found first bad commit
$ git bisect reset
```

Twelve commits took four tests; a thousand would take about ten. The manual
form is the same search with you answering:

```bash
git bisect start
git bisect bad                    # current commit is broken
git bisect good v1.4.0            # this tag was fine
# git checks out the midpoint; test it, then:
git bisect good      # or: git bisect bad      # or: git bisect skip
git bisect reset                  # always, when you are done
```

## Branches

```bash
git switch -c feature/x           # create and switch (clearer than checkout)
git switch -                      # back to the previous branch
git branch -vv                    # local branches + their upstreams
git branch --merged main          # safe to delete
git branch -d old                 # delete if merged; -D to force
git push origin --delete old      # delete it on the remote too
git fetch --prune                 # drop local refs to branches deleted upstream
```

## Stash

```bash
git stash push -m "wip: parser"   # -m, so it is findable later
git stash push -u                 # include untracked files
git stash push path/to/file       # only part of the tree
git stash list
git stash show -p stash@{1}       # what is actually in it
git stash pop                     # apply and drop
git stash apply stash@{1}         # apply and keep
git stash branch fix stash@{0}    # new branch from where it was stashed
```

**info:**
A stash is a commit, so `git stash drop` by mistake is also recoverable:
`git fsck --unreachable | grep commit` lists the orphans, and
`git stash apply <sha>` takes one back.

## cherry-pick

```bash
git cherry-pick <sha>             # that commit, onto the current branch
git cherry-pick <a>^..<b>         # an inclusive range
git cherry-pick -x <sha>          # record the source sha in the message
git cherry-pick -n <sha>          # stage it without committing
git cherry-pick --abort
```

`-x` is the one to use when backporting to a release branch — six months on,
the message says where the commit came from.

## Inspecting

```bash
git log --oneline --graph --decorate --all     # the shape of everything
git log -p path/to/file                        # every change to one file
git log -S "functionName"                      # commits that added or removed a string
git log --since="2 weeks" --author="Karan"
git show <sha>                                 # one commit in full
git blame -L 40,60 file                        # who last touched these lines
git diff main...feature                        # what the branch adds (merge-base)
git diff --staged                              # what is about to be committed
```

**Aliases worth having in ~/.gitconfig**
```ini
[alias]
    s      = status --short --branch
    lg     = log --oneline --graph --decorate -20
    last   = log -1 --stat
    unstage = restore --staged
    amend  = commit --amend --no-edit
    # branches by how recently you touched them
    recent = branch --sort=-committerdate --format='%(committerdate:relative)%09%(refname:short)'
    # what am I about to push?
    out    = log @{u}.. --oneline

[core]
    excludesfile = ~/.gitignore_global
[push]
    default = current          # push the current branch, no argument needed
    autoSetupRemote = true     # and set upstream on first push
[diff]
    algorithm = histogram      # better diffs on reordered code
[rerere]
    enabled = true             # remember conflict resolutions, replay them
```

`rerere` earns its keep on a long-lived branch: resolve a conflict once and
git replays the same resolution every rebase after.

## Worktrees

One repository, several checked-out branches, no stashing to switch:

```bash
git worktree add ../hotfix hotfix/urgent    # existing branch
git worktree add -b fix/x ../fix main       # new branch from main
git worktree list
git worktree remove ../hotfix
git worktree prune                          # clean up deleted ones
```

Far better than cloning again: the worktrees share one object store, so a
second checkout of a large repo costs almost nothing.

## Fixing a Commit You Already Made

```bash
git commit --amend                # message and/or staged changes
git commit --amend --no-edit      # just fold in what is staged
git commit --fixup <sha>          # mark it as fixing an older commit
git rebase -i --autosquash HEAD~5 # and fold all fixups in automatically
```

**warning:**
`--amend` makes a new commit with a new SHA — it is a rewrite like any other.
Amending something you have pushed needs a force-push, with the same caveat as
rebase.

## Common Pitfalls

- **`git checkout` doing two jobs.** It switches branches *and* discards file
  changes. `git switch` and `git restore` split those apart; use them.
- **`git pull` making merge commits.** Set `pull.rebase true` and the noise
  goes away.
- **Force-pushing with `--force`.** Use `--force-with-lease`, which refuses if
  the remote moved since your last fetch.
- **Committing a secret.** Removing it in a later commit does not remove it
  from history — it must be rewritten (`git filter-repo`) *and* the credential
  rotated, because it was pushed.
- **`git clean -fd` to "tidy up".** It deletes untracked files with no reflog
  and no recovery. `git clean -nd` first, which only lists them.
- **A detached HEAD you commit onto.** The commits are real but no branch
  points at them; `git switch -c keep` before leaving, or find them in the
  reflog afterwards.

**Question:** When did you last check that you can recover a commit you deleted? `git reflog` on your own repo right now — can you find the SHA of something you reset away this month?

## Related Cheatsheets

- [Jenkins Cheatsheet](https://karandeepsingh.ca/cheatsheets/devops/jenkins/) — where these branches get built
- [Bash Basics Cheatsheet](https://karandeepsingh.ca/cheatsheets/bash/bash-basics/) — scripting around git, including for `bisect run`
- [Bash CLI Cheatsheet](https://karandeepsingh.ca/cheatsheets/bash/bash-cli/) — the find, grep and jq that pair with `git log -S`

## Quiz

1. You hard-reset a branch and lost two commits that were never pushed. Are they gone?
   a) Yes, a hard reset is permanent
   b) No — the reflog still points at them for about 90 days, so `git reset --hard <sha>` brings them back
   c) Only if you had stashed first
   d) Only if someone else had pulled them
   Answer: b. Commits are not deleted by a reset, only unreferenced. The reflog records every position HEAD has held, so the old tip is still reachable by SHA until garbage collection expires it — 90 days by default for reachable entries, 30 for unreachable ones.

2. What is the difference between `git reset --soft HEAD~1` and `git reset --mixed HEAD~1`?
   a) soft is for staged files, mixed for unstaged
   b) Both move the branch pointer back one commit; soft leaves the changes staged, mixed unstages them but keeps them in the working tree
   c) mixed also deletes the files
   d) They are the same, mixed is just the default name
   Answer: b. All three modes move the branch. --soft stops there, leaving the index and working tree untouched so the commit content is still staged. --mixed (the default) also resets the index, so the changes show as unstaged. --hard additionally overwrites the working tree, which is the only one that can destroy uncommitted work.

3. A bad commit is already pushed to a branch your team uses. What should you do?
   a) git reset --hard and force-push
   b) git revert <sha>, which makes a new commit undoing it and leaves history intact
   c) Delete the branch and recreate it
   d) git rebase -i and drop the commit
   Answer: b. revert is the only one of these that does not rewrite shared history. reset plus force-push changes commits other people already have, so their next pull conflicts or silently drops work. Rewrite only branches nobody else has pulled.

4. What does `git bisect run ./test.sh` do?
   a) Runs the test on every commit in order
   b) Binary-searches the range, running the script at each step, and names the first commit where it starts failing
   c) Reverts commits until the test passes
   d) Only works with git hooks installed
   Answer: b. Given a known-good and known-bad commit, bisect halves the range each step. With `run` it uses your script exit status instead of asking — zero is good, non-zero is bad — so finding a regression in a thousand commits takes about ten automated builds.


