# Git

> Inspect history, undo changes safely, rebase and resolve conflicts, recover lost work, and understand the object model behind each Git command.

Canonical: https://www.wiki.jodisand.me/git/
Reviewed: 2026-09-24
Related: [SSH](https://www.wiki.jodisand.me/ssh/index.md), [Bash](https://www.wiki.jodisand.me/bash/index.md), [Testing](https://www.wiki.jodisand.me/testing/index.md), [System design](https://www.wiki.jodisand.me/design/index.md)


## Cheatsheet

| Task | Command |
| --- | --- |
| What is going on | `git status -sb` |
| Unstaged / staged changes | `git diff` / `git diff --staged` |
| Compact history of all branches | `git log --oneline --graph --decorate --all` |
| Who changed these lines | `git blame -L 10,20 -- file` |
| Unstage a file, keep the edit | `git restore --staged file` |
| Discard local edits to a file | `git restore file` (destructive) |
| Undo last commit, keep changes staged | `git reset --soft HEAD~1` |
| Undo a pushed commit | `git revert <sha>` |
| Amend the last commit | `git commit --amend --no-edit` |
| Park work | `git stash push -u -m wip` / `git stash pop` |
| Find a lost commit | `git reflog` |
| File from another branch | `git restore --source main -- path` |
| Commits that added or removed a string | `git log -S 'needle' --oneline` |
| Rebase onto latest main | `git fetch && git rebase origin/main` |
| Force-push safely | `git push --force-with-lease --force-if-includes` |
| Branches already merged | `git branch --merged main` |
| Delete a remote branch | `git push origin --delete feature-x` |
| Second working tree | `git worktree add ../hotfix hotfix` |
| Find the commit that broke something | `git bisect start BAD GOOD` then `git bisect run ./test.sh` |

## How Git stores history

Git stores snapshots as immutable, content-addressed objects: blobs (file content), trees (directories), commits (a tree, parent commits, author, committer, message) and annotated tags. A branch is a file holding one commit ID. `HEAD` names the current branch, or a commit directly when detached. The index (staging area) is the tree that the next commit will record.

Commands that "rewrite history" (amend, rebase, reset) write new objects and move a branch pointer. The old commits stay in the object store and in the reflog until they expire and `git gc` prunes them, which is why most mistakes are recoverable.

```sh
git cat-file -p HEAD          # the commit object: tree, parent, author, message
git cat-file -p HEAD^{tree}   # the root directory listing
git rev-parse HEAD            # resolve a ref to its full object ID
git rev-parse --abbrev-ref HEAD   # current branch name
```

## Inspecting

```sh
git status -sb                                        # short status plus ahead/behind counts
git log --oneline --graph --decorate --all            # branch topology at a glance
git log --since='2 weeks' --author=alice --stat       # recent commits by one author, files touched
git log -p -- path/to/file                            # history of one file, with diffs
git log --follow -- path/to/file                      # same, following renames
git log -S 'API_URL' --oneline                        # commits that change the count of a string
git log -G 'timeout\s*=' --oneline                    # commits whose diff lines match a regex
git log --grep 'PROJ-123' --oneline                   # commits whose message matches
git log main..feature-x --oneline                     # in feature-x, not in main
git log --left-right --oneline main...feature-x       # commits unique to each side, marked < and >
git show <sha> --stat                                 # one commit, files changed
git show main:path/to/file                            # a file as it is on another branch
git blame -L 20,40 -- file                            # last change per line
git blame -w -C -- file                               # ignore whitespace, detect moved lines
git diff main...feature-x                             # changes on feature-x since it forked from main
```

`A..B` means commits reachable from B but not from A. In `git log`, `A...B` is the symmetric difference (commits on either side but not both). In `git diff`, `A...B` means the diff from the merge base of A and B to B, which is what a pull request shows. `-S` finds commits that change how many times a string occurs; `-G` finds commits with any added or removed line matching a regex, including edits that keep the count the same.

To skip formatting-only commits in `blame`, list them in `.git-blame-ignore-revs` and run `git config blame.ignoreRevsFile .git-blame-ignore-revs`.

## Undoing

Pick the command by what you need to keep.

| Goal | Command | Safe on a pushed branch |
| --- | --- | --- |
| Unstage, keep the edit | `git restore --staged file` | Yes |
| Throw away local edits | `git restore file` | Yes, but the edit is gone for good |
| Undo commit, keep changes staged | `git reset --soft HEAD~1` | No, rewrites history |
| Undo commit, keep changes unstaged | `git reset HEAD~1` | No |
| Undo commit and discard its changes | `git reset --hard HEAD~1` | No, and uncommitted work is lost |
| Reverse a commit with a new commit | `git revert <sha>` | Yes. Use this on shared branches |
| Fix the last commit | `git commit --amend` | No |
| Restore a deleted file | `git restore --source HEAD~1 -- path` | Yes |
| Split staged changes out of a stash | `git stash push --staged` | Yes |

```sh
git restore --source main -- config/app.yaml    # one file from another branch
git revert -m 1 <merge-sha>                     # revert a merge relative to parent 1 (the mainline)
git reset --hard origin/main                    # make the branch match the remote exactly (destructive)
git clean -nd                                   # preview which untracked files would be removed
git clean -fd                                   # remove untracked files and directories (destructive)
```

> [!WARNING] `reset --hard`, `restore` and `clean -fd` delete work that was never committed
> The reflog recovers commits, not uncommitted edits or untracked files. Run `git stash push -u` first if in doubt, and always run `git clean -n` before `-f`.

Reverting a merge has a trap: the merged commits stay in history, so merging the same branch again later brings in nothing. Revert the revert first, then merge.

## Reflog: recovering lost commits

Git records every move of `HEAD` and of each branch in the local reflog, including resets, rebases, amends and checkouts. Entries still reachable from a branch expire after 90 days (`gc.reflogExpire`); entries for commits no longer reachable expire after 30 days (`gc.reflogExpireUnreachable`) ([git-reflog](https://git-scm.com/docs/git-reflog)). The reflog is local and is not pushed or cloned.

```sh
git reflog                                # HEAD movements with the command that caused each
git reflog show feature-x                 # one branch's movements
git reset --hard HEAD@{2}                 # back to where HEAD was two moves ago (destructive to current state)
git branch rescue <sha-from-reflog>       # recover a deleted branch without moving anything
git reset --hard ORIG_HEAD                # undo the last rebase, merge or reset
git fsck --lost-found                     # list dangling commits when the reflog has no entry
```

```text
a1b2c3d HEAD@{0}: rebase (finish): returning to refs/heads/feature-x
e4f5a6b HEAD@{1}: rebase (start): checkout origin/main
9c8d7e6 HEAD@{2}: commit: add retry to client
```

Creating a branch at the old commit is safer than `reset --hard`: it recovers the work without discarding anything.

## Branches

```sh
git switch -c feature-x                   # create and switch
git switch -                              # previous branch
git switch --detach <sha>                 # inspect a commit without moving a branch
git branch -vv                            # local branches, upstreams, ahead/behind
git branch --merged main                  # fully merged into main: safe to delete
git branch --no-merged main               # still carrying unique commits
git push -u origin feature-x              # push and set upstream
git push origin --delete feature-x        # delete the remote branch
git fetch --prune                         # drop remote-tracking refs deleted on the remote
```

```sh
# Delete every local branch already merged into main (skips current and worktree branches)
git branch --merged main | grep -vE '^[*+]|^\s*(main|master)$' | xargs -r git branch -d
```

`git branch -d` refuses to delete an unmerged branch; `-D` forces it. Branches merged by squash or rebase on the server do not show as merged, because their commits differ from the ones in `main`.

## Rebase and merge

Merge records what actually happened, with a merge commit joining two lines of history. Rebase replays your commits on a new base, producing new commit IDs and a linear history. Rebase your own unpublished branch; merge shared branches.

```sh
git fetch origin
git rebase origin/main                    # replay my commits on top of main
git rebase -i origin/main                 # squash, reword, drop, reorder
git rebase --continue                     # after resolving a conflict
git rebase --abort                        # return to the state before the rebase
git rebase --update-refs origin/main      # also move stacked branches that point into the rebased range
git merge --no-ff feature-x               # always create a merge commit
git merge --squash feature-x              # stage all changes as one commit, no merge record
git pull --rebase                         # rebase local commits instead of merging on pull
```

```sh
git config --global pull.rebase true           # make pull --rebase the default
git config --global rebase.autoStash true      # stash and restore dirty changes around a rebase
git config --global rebase.autosquash true     # apply fixup! commits during rebase -i
git commit --fixup <sha>                       # mark a commit as a fix for an earlier one
```

Force-pushing a rebased branch replaces the remote history. Use `git push --force-with-lease --force-if-includes`: `--force-with-lease` refuses if the remote branch moved since you last fetched, and `--force-if-includes` (Git 2.30+) also refuses when a background fetch updated the remote-tracking ref without you integrating it. Set `push.useForceIfIncludes true` to make the second one the default.

## Conflicts

A conflict happens when both sides changed the same region of a file, or one side changed a file the other deleted. Git stages what it resolved and leaves conflict markers in the rest.

```sh
git status                                # "both modified" lists the conflicts
git diff --name-only --diff-filter=U      # only unresolved files
git restore --ours -- file                # take the version from the "ours" side
git restore --theirs -- file              # take the version from the "theirs" side
git add file && git rebase --continue     # mark resolved and carry on (or git merge --continue)
git merge --abort                         # back to before the merge
git config --global merge.conflictStyle zdiff3   # show the common ancestor in conflict markers
git config --global rerere.enabled true   # record resolutions and reapply them automatically
```

During a merge, "ours" is the branch you are on and "theirs" is the branch being merged. During a rebase they swap: "ours" is the branch being rebased onto (upstream) and "theirs" is your commit being replayed. Check `git status` rather than trusting the words.

With `rerere.enabled`, Git records how you resolved each conflict and applies the same resolution when the identical conflict appears again, for example on a repeated rebase. It leaves the file unstaged for review unless `rerere.autoUpdate` is set. `git rerere status` lists files with a recorded preimage.

## Stash and worktrees

```sh
git stash push -u -m 'wip: auth refactor'  # -u includes untracked files
git stash list
git stash show -p stash@{0}                # the stash as a diff
git stash pop                              # apply and drop (keeps the stash if it conflicts)
git stash apply stash@{1}                  # apply and keep
git stash branch fix-auth stash@{0}        # new branch from the stash's base, then apply it

git worktree add ../hotfix hotfix          # second checkout of the same repository
git worktree list
git worktree remove ../hotfix
git worktree prune                         # forget worktrees whose directory was deleted
```

A worktree is a separate directory with its own `HEAD` and index, sharing the object store. Use one to look at another branch without stashing. A branch can be checked out in only one worktree at a time.

## Remotes and authentication

```sh
git remote -v
git remote set-url origin git@github.com:example/repo.git
git config --global credential.helper 'cache --timeout=3600'     # in-memory for 1 hour
git config --global url."git@github.com:".insteadOf "https://github.com/"
git clone --filter=blob:none https://github.com/example/repo.git   # partial clone: fetch file content on demand
git clone --depth 1 https://github.com/example/repo.git            # shallow clone, for CI
git fetch --unshallow                     # convert a shallow clone to a full one
```

Store credentials in a helper, never in the remote URL. A token in `origin` leaks through `git remote -v`, CI logs and shell history. See [SSH](https://www.wiki.jodisand.me/ssh/) for key-based access.

A partial clone (`--filter=blob:none`) keeps full history and is safe for daily work, fetching blobs when needed. A shallow clone (`--depth`) truncates history, so `blame`, `log` and `merge-base` give incomplete answers.

## Tags

```sh
git tag -a v1.4.2 -m 'release 1.4.2'      # annotated: tagger, date and message
git tag -l 'v1.*' --sort=-v:refname       # list, newest version first
git push origin v1.4.2                    # push one tag
git push origin --tags                    # push all local tags
git tag -d v1.4.2 && git push origin :refs/tags/v1.4.2   # delete locally and on the remote
git describe --tags --always              # version string from the nearest tag, e.g. v1.4.2-3-gabc1234
```

Lightweight tags (`git tag v1`) are a bare ref with no metadata. Use annotated or signed (`-s`) tags for releases. `git describe` ignores lightweight tags unless given `--tags`.

## Bisect

```sh
git bisect start HEAD v1.4.0               # bad commit, then known good commit
git bisect run ./test.sh                   # exit 0 = good, 1-127 except 125 = bad, 125 = skip
git bisect reset                           # return to the original branch
```

```sh
# Manual marking when the check cannot be scripted
git bisect good                            # or: git bisect bad, git bisect skip
git bisect log                             # record of decisions, replayable with git bisect replay
```

Bisect halves the range each step, so 300 commits take about nine tests. The script must build and test in one go and must not depend on files that change between commits.

## Hooks and configuration

```sh
git config --global user.name 'Alice Example'
git config --global user.email 'alice@example.com'
git config --global init.defaultBranch main
git config --global core.excludesFile ~/.gitignore
git config --global commit.gpgSign true
git config --global gpg.format ssh                              # sign with an SSH key
git config --global user.signingKey ~/.ssh/id_ed25519.pub
git config --list --show-origin           # every setting and the file that set it
git config --show-origin --get pull.rebase   # where one setting comes from
```

```sh
#!/usr/bin/env bash
# .git/hooks/pre-commit: reject obvious credentials in staged changes
if git diff --cached -U0 | grep -nE 'AKIA[0-9A-Z]{16}|BEGIN (RSA|OPENSSH|EC) PRIVATE KEY'; then
  echo 'possible credential in staged changes' >&2
  exit 1
fi
```

Hooks live in `.git/hooks`, must be executable, and are not cloned. Point `core.hooksPath` at a tracked directory to share them, or use the pre-commit framework. Any hook can be skipped with `--no-verify`, so enforce policy in CI as well.

## Log formatting

`--format` (or `--pretty=format:`) builds one line per commit from placeholders; `%C(...)` colours, `%<(N)` pads and `%(trailers)` pulls structured trailers out of the message. Save a format you use often as an alias so the long string lives in one place.

```sh
git log --format='%h %ad %an %s' --date=short                          # hash, date, author, subject
git log --format='%C(yellow)%h%C(reset) %C(green)%ar%C(reset) %<(16,trunc)%an %s%C(auto)%d'   # padded author, decorations
git log --format='%H%x09%at%x09%ae' --since='90 days'                 # tab-separated for awk or a spreadsheet
git log --format='%s%n%(trailers:key=Reviewed-by,valueonly)' -5       # one trailer's values
git log --date=iso-strict --format='%cd %s' -3                         # committer date in ISO 8601 with offset
git log --format='%h %s' --no-merges main..HEAD                        # the branch's own commits, for a PR description
git log --format='%h %p' -5                                            # each commit and its parent hashes
git log --pretty=reference -3                                          # "abc1234 (subject, 2026-01-15)": the form used to cite commits
git log --stat=80 --format='%h %s'                                     # limit the stat width
git log --graph --format='%h %d %s' --simplify-by-decoration           # only commits with a branch or tag
git shortlog -sne --since='1 year' --group=trailer:co-authored-by      # count co-authors from trailers (2.29+)
git config --global alias.lg "log --graph --format='%C(auto)%h%d %s %C(dim)%an %ar'"
```

| Placeholder | Meaning |
| --- | --- |
| `%H` / `%h` | Full / abbreviated commit hash |
| `%an` `%ae` `%ad` `%ar` | Author name, email, date, relative date |
| `%cn` `%ce` `%cd` | Committer name, email, date (changes on rebase and amend) |
| `%s` / `%b` / `%B` | Subject / body / raw message |
| `%d` / `%D` | Ref names with / without the surrounding parentheses |
| `%p` / `%P` | Abbreviated / full parent hashes |
| `%G?` `%GS` | Signature status (`G` good, `B` bad, `N` none) and signer |
| `%(trailers:key=X)` | Trailers from the message, optionally one key |
| `%x09` / `%n` | Literal tab / newline |

Author is who wrote the change; committer is who last rewrote it. After a rebase the two differ, and `--author` filters on the first while `--since` uses the committer date unless you pass `--author-date-order`.

## Sparse checkout

Sparse checkout restricts the working tree to chosen directories while the repository still holds every object. Combined with a partial clone it makes a monorepo workable on a laptop: history is complete, but only the directories you name are on disk and only their blobs are downloaded. Cone mode (the default since 2.37) matches whole directories and is fast; non-cone mode takes arbitrary `.gitignore`-style patterns and is slow on large trees.

```sh
git clone --filter=blob:none --sparse https://github.com/example/monorepo.git   # --sparse: start with only top-level files
cd monorepo
git sparse-checkout set services/api libs/common          # populate these directories (cone mode)
git sparse-checkout add tools/ci                          # widen the checkout
git sparse-checkout list                                  # current patterns
git sparse-checkout disable                               # back to a full working tree (downloads every blob)
git ls-files -t | grep '^S' | head                        # files marked skip-worktree, not on disk
```

Commands that walk the tree (`git grep`, `git log -- path`) still see the whole repository unless you pass `--sparse` or scope the path. `git status` may be slow on the first run while the sparse index is built; set `git config index.sparse true` (2.32+) so the index itself stays small. A switch to a branch that renames or removes a sparse directory can leave files behind; `git sparse-checkout reapply` fixes the working tree.

## Submodules

A submodule pins another repository at an exact commit inside a directory of yours. The parent records the commit ID in its tree (a "gitlink") and the URL in `.gitmodules`; the submodule's own `.git` lives under the parent's `.git/modules/`. Updating the dependency means committing a new gitlink in the parent, so the two histories move independently and a checkout of an old parent commit restores the matching submodule commit.

```sh
git submodule add https://github.com/example/lib.git vendor/lib   # add, and stage .gitmodules and the gitlink
git clone --recurse-submodules https://github.com/example/app.git  # clone with submodules populated
git submodule update --init --recursive                            # populate after a plain clone
git submodule update --remote --merge vendor/lib                   # move the submodule to its branch tip
git submodule status                                               # commit per submodule; "+" means checked out differs from the recorded one
git submodule foreach 'git fetch --prune'                          # run a command in each
git config --global submodule.recurse true                         # pull, switch and checkout recurse automatically
git push --recurse-submodules=on-demand                            # push submodule commits the parent depends on first
git diff --submodule=log                                           # show which submodule commits changed, not just hashes
git submodule deinit -f vendor/lib && git rm vendor/lib && rm -rf .git/modules/vendor/lib   # remove entirely (destructive)
```

The recurring failure is pushing a parent commit that references a submodule commit nobody else can fetch; `push.recurseSubmodules check` (the default is off) refuses that push. Submodules stay detached at the recorded commit, so a change made inside one must be committed and pushed there before the parent's gitlink is updated. Where you only need vendored files and not a linked history, `git subtree` or a package manager is simpler to operate.

## Signing

Signed commits and tags prove who created them, independent of the account that pushed. Git supports OpenPGP, X.509 (`gpgsm`) and SSH signatures (2.34+); SSH is the easiest because the key already exists and GitHub, GitLab and Gitea verify it. Signing is per repository or global, and the verifier needs an allowed-signers file to say which key belongs to which identity.

```sh
git config --global gpg.format ssh
git config --global user.signingKey ~/.ssh/id_ed25519.pub          # or 'key::ssh-ed25519 AAAA...' for an agent-only key
git config --global commit.gpgSign true
git config --global tag.gpgSign true
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
printf '%s namespaces="git" %s\n' alice@example.com "$(cut -d' ' -f1,2 ~/.ssh/id_ed25519.pub)" >> ~/.ssh/allowed_signers

git commit -S -m 'signed'                                           # sign one commit explicitly
git tag -s v1.5.0 -m 'release 1.5.0'
git log --show-signature -1                                         # verify inline
git log --format='%h %G? %GS %s' -10                                # G good, B bad, N none, U untrusted key
git verify-commit HEAD && git verify-tag v1.5.0
git merge --verify-signatures feature-x                             # refuse to merge unsigned or badly signed commits
```

Signatures are over the commit object, so any rewrite (rebase, amend, squash) drops them and the new commits are signed by whoever rewrote them, or not at all. Servers that "sign" merges and squashes use their own key, which is why a merge commit shows GitHub as the signer. Signing with a hardware-backed key (`sk-ssh-ed25519` on a FIDO token) needs a touch per commit; an agent with `ssh-add -t` caches a software key for a session instead. For releases, verify the tag, not the commit under it: the tag is what the pipeline checked out.

## Large repositories

Repositories with hundreds of thousands of files or many gigabytes of history slow down at three points: enumerating the working tree for `status`, walking commit history for `log` and `merge-base`, and transferring objects on clone and fetch. Each has a specific fix, and `git maintenance` schedules the background ones.

```sh
git config core.fsmonitor true                       # built-in filesystem watcher (2.37+): status stops scanning every file
git config core.untrackedCache true                  # cache untracked-file scan results between runs
git config feature.manyFiles true                    # turns on index v4, untracked cache and skip-hash for big trees
git config fetch.writeCommitGraph true               # keep the commit-graph current after every fetch
git commit-graph write --reachable --changed-paths   # speeds up log --, blame and merge-base with Bloom filters
git maintenance start                                # hourly prefetch, daily gc-like tasks via systemd or launchd timers
git repack -adf --write-bitmap-index                 # server side: one pack with reachability bitmaps for fast clones
git count-objects -vH                                # loose objects and pack size; many loose objects means gc is overdue
git gc --prune=now                                   # collect now (drops unreachable objects immediately; reflog protection is gone)
git rev-list --count --all                           # how many commits; --disk-usage --objects gives total bytes (2.31+)
```

For history that has already accumulated large binaries, `git filter-repo --strip-blobs-bigger-than 10M` rewrites every commit; every clone must be re-cloned afterwards and any signature is lost. Going forward, Git LFS stores large files outside the repository and leaves pointers in it: `git lfs install`, `git lfs track '*.psd'`, commit `.gitattributes`. A partial clone with `--filter=blob:none` is often the better answer for CI and new contributors, because it needs no rewrite. Scalar (`scalar clone`, bundled since 2.38) applies the settings above and registers maintenance in one step for a monorepo.

## gh CLI

`gh` is GitHub's official CLI; it authenticates once, then wraps pull requests, issues, Actions runs, releases and the REST and GraphQL APIs. It reads the current repository from the `origin` remote, so most commands need no repository argument. Its output is scriptable through `--json` and `--jq` (`jq` is built in) or `--template`.

```sh
gh auth login                                            # browser or token flow; gh auth status to check
gh auth setup-git                                        # use gh as the git credential helper for HTTPS remotes
gh repo clone example/app                                # clone; also sets up an upstream remote for forks
gh pr create --fill --base main                          # title and body from the commits
gh pr create --draft --title 'Add retry' --body-file pr.md --reviewer alice --label backend
gh pr list --author @me --state open                     # my open PRs
gh pr view 123 --web                                     # open in the browser
gh pr checkout 123                                       # fetch and switch to the PR's branch
gh pr diff 123                                           # the PR diff in the terminal
gh pr checks 123 --watch                                 # block until CI finishes, exit non-zero on failure
gh pr review 123 --approve --body 'LGTM'
gh pr merge 123 --squash --delete-branch                 # respects branch protection; --auto merges when checks pass
gh pr status                                             # PRs relevant to me: created, reviewing, on this branch
gh run list --workflow ci.yml --limit 10
gh run watch                                             # follow the run for the current branch
gh run rerun --failed                                    # rerun only failed jobs of the latest run
gh run download <run-id> -n coverage                     # fetch an artefact
gh workflow run deploy.yml -f environment=staging        # dispatch a workflow with inputs
gh release create v1.5.0 --generate-notes dist/*         # tag, release notes from PRs, upload assets
gh issue create --title 'Flaky test' --body 'see run 42' --label bug
gh api repos/{owner}/{repo}/branches/main/protection     # any REST endpoint; {owner}/{repo} expand
gh api graphql -f query='{ viewer { login } }'
gh pr list --json number,title,headRefName --jq '.[] | "\(.number)\t\(.title)"'
gh alias set prs 'pr list --author @me'                  # save a command you type daily
```

`gh` stores its token in the system keyring or `~/.config/gh/hosts.yml`; `GH_TOKEN` overrides it in CI, where the Actions-provided `GITHUB_TOKEN` is enough for most repository operations but cannot trigger other workflows or touch other repositories. `gh pr merge --auto` queues the merge for when required checks pass, which is the safe way to leave a PR overnight. For GitLab the equivalent is `glab`, with a near-identical command shape. See [GitHub Actions](https://www.wiki.jodisand.me/github-actions/) for the workflows these commands drive.

## Useful one-liners

```sh
# Branches by last commit date, newest first
git for-each-ref --sort=-committerdate refs/heads --format='%(committerdate:short) %(refname:short) %(authorname)'

# Commits per author, excluding merges
git shortlog -sn --no-merges

# Files changed most often
git log --format=format: --name-only | sort | uniq -c | sort -rn | head

# Largest blobs in the repository
git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | awk '$1=="blob"' | sort -k3 -nr | head

# What landed between two tags
git log --oneline --no-merges v1.4.0..v1.5.0

# Everything that touched a directory last month
git log --since='1 month' --oneline -- infra/

# Which branches contain a commit
git branch -a --contains <sha>

# The first commit that introduced a string
git log -S 'deprecatedFlag' --oneline --reverse | head -1

# Diff ignoring whitespace changes
git diff -w --ignore-blank-lines

# A file as it was on a date (uses the local reflog, so only recent dates work)
git show 'HEAD@{2026-01-15}:path/to/file'

# A file as it was at the last commit before a date (works on any clone)
git show "$(git rev-list -1 --before=2026-01-15 HEAD):path/to/file"

# Apply one commit from another branch, noting its origin in the message
git cherry-pick -x <sha>

# Stage part of a file interactively
git add -p file

# Reset author on the last commit after fixing user.name/user.email
git commit --amend --reset-author --no-edit

# Repository size and object count
git count-objects -vH

# Run routine maintenance (gc, commit-graph, repack) now
git maintenance run

# Commits on this branch not yet in main, oldest first, for a changelog
git log --reverse --format='- %s (%h)' origin/main..HEAD

# The merge base of the current branch and main
git merge-base HEAD origin/main

# Did this commit reach main yet
git merge-base --is-ancestor <sha> origin/main && echo yes

# Files changed in the last commit, names only
git diff-tree --no-commit-id --name-only -r HEAD

# Files changed between two refs, with status letters (A added, M modified, D deleted, R renamed)
git diff --name-status origin/main...HEAD

# Only files that were deleted, ever, matching a pattern
git log --diff-filter=D --name-only --format= -- '*.tf' | sort -u

# Who last touched each file in a directory
git ls-files infra/ | while read -r f; do printf '%s\t%s\n' "$(git log -1 --format='%ad %an' --date=short -- "$f")" "$f"; done

# Local branches whose upstream is gone (deleted on the remote after merge)
git fetch --prune && git branch -vv | awk '/: gone]/ {print $1}'

# Delete those branches (destructive; check the list first)
git branch -vv | awk '/: gone]/ {print $1}' | xargs -r git branch -D

# Rebase only the last 3 commits interactively
git rebase -i HEAD~3

# Squash the branch into one commit without an editor
git reset --soft "$(git merge-base HEAD origin/main)" && git commit -m 'feature: one commit'

# Copy a commit's changes to the working tree without committing
git cherry-pick -n <sha>

# Compare a file between two branches
git diff main feature-x -- path/to/file

# Search every branch for a string in tracked files
git grep -n 'TODO(alice)' $(git for-each-ref --format='%(refname:short)' refs/heads)

# Show untracked and ignored files separately
git status --ignored --short | grep '^!!'

# Verify the object store is intact
git fsck --full --strict

# Dry-run a push to see what would be sent
git push --dry-run origin HEAD

# Export the tree at a tag as a tarball, without .git
git archive --format=tar.gz --prefix=app-1.5.0/ -o app-1.5.0.tar.gz v1.5.0

# Make a bundle: a single file that clones or fetches like a remote (for air-gapped transfer)
git bundle create repo.bundle --all && git clone repo.bundle repo-copy

# Count lines of code by author in the current tree
git ls-files | xargs -n1 git blame --line-porcelain 2>/dev/null | grep '^author ' | sort | uniq -c | sort -rn | head

# Empty commit to trigger CI
git commit --allow-empty -m 'ci: rerun'

# Edit the message of an older commit (rewrites history)
git rebase -i <sha>^   # mark the commit 'reword'
```

## Troubleshooting

| Symptom | Likely cause | Check or fix |
| --- | --- | --- |
| `! [rejected] ... (non-fast-forward)` on push | Remote has commits you do not have | `git pull --rebase`, resolve, push again. Do not force-push a shared branch |
| `stale info` rejection with `--force-with-lease` | Someone pushed since your last fetch | `git fetch`, inspect `git log HEAD..origin/branch`, integrate, then push |
| `fatal: refusing to merge unrelated histories` | Two repositories with no common commit | Confirm it is intended, then `git merge --allow-unrelated-histories` |
| `You are in 'detached HEAD' state` | Checked out a commit or tag, not a branch | `git switch -c new-branch` to keep work, or `git switch main` |
| Commits vanished after reset or rebase | Branch pointer moved | `git reflog`, then `git branch rescue <sha>` |
| `error: Your local changes would be overwritten` | Uncommitted edits conflict with the switch or pull | `git stash push -u`, retry, `git stash pop` |
| `fatal: '...' is already used by worktree` | Branch checked out in another worktree | `git worktree list`; switch that worktree to another branch |
| `Permission denied (publickey)` | SSH key not loaded or not registered | `ssh -T git@github.com`; see [SSH](https://www.wiki.jodisand.me/ssh/) |
| Push prompts for a password over HTTPS | No credential helper, or token expired | Configure `credential.helper`, or switch the remote to SSH |
| `blame` or `log` shows history stopping early | Shallow clone | `git rev-parse --is-shallow-repository`; `git fetch --unshallow` |
| Line endings change on every file | `core.autocrlf` differs between machines | Set `* text=auto` in `.gitattributes` and `git add --renormalize .` |
| File still tracked after adding it to `.gitignore` | `.gitignore` only affects untracked files | `git rm --cached file` and commit |
| A secret was committed | It is in history on every clone | Revoke the secret first. Rewriting history (`git filter-repo`) does not remove existing copies |
| `git status` takes seconds on a large tree | Every file is stat'ed on each run | `core.fsmonitor true`, `core.untrackedCache true`; `git maintenance start` |
| Submodule directory is empty after clone | Submodules are not fetched by default | `git submodule update --init --recursive`, or clone with `--recurse-submodules` |
| `fatal: remote error: upload-pack: not our ref` on submodule update | Parent references a submodule commit that was never pushed | Push inside the submodule first; set `push.recurseSubmodules check` |
| Commit shows `N` (no signature) although signing is on | `commit.gpgSign` set in a different scope, or the commit was rebased | `git config --show-origin --get commit.gpgSign`; re-sign with `git rebase --exec 'git commit --amend --no-edit -S'` |
| `error: gpg failed to sign the data` | Agent not running, key locked, or `gpg.format` mismatch | `ssh-add -l` / `gpg --list-secret-keys`; test with `echo test \| ssh-keygen -Y sign -n git -f ~/.ssh/id_ed25519` |
| Signature shows as `U` (untrusted) on verify | Key not in `allowed_signers` | Add the key with the committer's email to `gpg.ssh.allowedSignersFile` |
| Files missing after switching branches in a sparse checkout | New directory not in the sparse patterns | `git sparse-checkout add dir`, or `git sparse-checkout reapply` |
| `gh: HTTP 403` or `Resource not accessible by integration` in Actions | `GITHUB_TOKEN` lacks the permission | Add `permissions:` to the job, or use a fine-grained PAT in `GH_TOKEN` |
| `gh pr create` opens the wrong repository | `origin` points at a fork | `gh repo set-default`, or pass `--repo owner/name` |
| Clone is slow and huge | Full history with large binaries | `git clone --filter=blob:none`, or `--depth 1` for CI; move binaries to LFS |


