Git Advanced Features
Most developers use git as a glorified backup tool — add, commit, push,
pull. Git is actually a content-addressable filesystem with a powerful DAG
(Directed Acyclic Graph) at its core, and it exposes dozens of features that
most people never discover. This note collects the ones that change how you
work.
Push Options
How they work — the protocol layer
Push options are a feature of the git wire protocol (available since git 2.10). When you run git push -o some_option, the git client sends the option strings alongside the ref updates in the same network transaction. The server receives them inside its pre-receive or proc-receive hook as environment variables:
# Inside the server-side hook, git sets:
GIT_PUSH_OPTION_COUNT=2
GIT_PUSH_OPTION_0=merge_request.create
GIT_PUSH_OPTION_1=merge_request.remove_source_branchThe hook reads these and decides what to do. The vocabulary of option strings is entirely server-defined — git itself has no opinion about what merge_request.create or ci.skip mean. They are plain strings. Whether they do anything depends entirely on what hooks the server has installed.
This means push options are not portable across hosting platforms.
Platform support
| Platform | Push option support |
|---|---|
| GitLab | Full: merge_request.* and ci.* built-in |
| GitHub.com | No push option support. Use commit message conventions: [skip ci], [ci skip], [skip actions] to skip CI; PRs are created via web UI or gh pr create |
| GitHub Enterprise | Limited; some ci.* options depending on version |
| Gitea / Forgejo | Partial; some merge_request.* equivalent |
| Self-hosted (bare git) | Whatever your own hooks implement |
If you push merge_request.create to GitHub.com, nothing happens — the option is silently ignored because there is no hook to handle it.
ci.skip and ci.variable — how they reach the pipeline
ci.skip and ci.variable are GitLab-specific. They do not become environment variables during the push itself. The flow is:
git push -o ci.variable="FLAG=dark_mode"
→ server hook reads GIT_PUSH_OPTION_0="ci.variable=FLAG=dark_mode"
→ GitLab backend creates a pipeline with FLAG=dark_mode as a pipeline variable
→ CI runner picks up the job and sets FLAG=dark_mode as an env var in the job shell
So ci.variable does end up as an environment variable in the CI job — but indirectly. The push option tells GitLab how to create the pipeline, not what env vars to set during the push. ci.skip similarly tells GitLab not to create a pipeline at all for that push.
Sending options manually
# Open a GitLab MR when pushing a branch
git push -o merge_request.create origin my-branch
# Multiple options in one push
git push \
-o merge_request.create \
-o merge_request.remove_source_branch \
-o merge_request.squash \
-o "merge_request.title=feat: dark mode" \
origin my-branch
# Skip CI for a trivial push
git push -o ci.skip origin my-branch
# Inject a pipeline variable into the triggered pipeline
git push -o ci.variable="FEATURE_FLAG=dark_mode" origin my-branchMaking options fire automatically
remote.<name>.pushOption (singular — not pushOptions) in git config fires
automatically on every push to that remote without any -o flags:
[remote "origin"]
pushOption = merge_request.create
pushOption = merge_request.remove_source_branchApplying config to all GitLab repos regardless of directory
Git 2.36 added hasconfig:remote.*.url to includeIf. It re-reads the
per-repo .git/config to discover the remote URL, then conditionally includes
a global config snippet. This means you can target all repos that point at
gitlab.com — wherever they live on disk — without any per-repo setup:
# ~/.gitconfig
[includeIf "hasconfig:remote.*.url:https://gitlab.com/*/*"]
path = ~/.config/git/gitlab.inc
[includeIf "hasconfig:remote.*.url:git@gitlab.com:*/*"]
path = ~/.config/git/gitlab.inc# ~/.config/git/gitlab.inc
[remote "origin"]
pushOption = merge_request.create
pushOption = merge_request.remove_source_branchFull GitLab push options reference
| Option | Effect |
|---|---|
merge_request.create | Open an MR (no-op if one exists) |
merge_request.target=<branch> | Target branch for the MR |
merge_request.title=<title> | MR title |
merge_request.description=<desc> | MR description |
merge_request.label=<label> | Add label (repeat for multiple) |
merge_request.assign=<user> | Assign to user |
merge_request.reviewer=<user> | Request review |
merge_request.milestone=<id> | Attach to milestone |
merge_request.remove_source_branch | Delete branch after merge |
merge_request.squash | Squash commits on merge |
merge_request.draft | Mark as draft |
merge_request.merge_when_pipeline_succeeds | Auto-merge when CI passes |
ci.skip | Tell GitLab not to create a pipeline for this push |
ci.variable=KEY=VALUE | Create the pipeline with this variable set |
git worktree — Multiple Working Directories
The problem it solves
You’re deep in a feature branch. Someone needs a hotfix reviewed. The normal
flow: stash everything, checkout main, make the fix, checkout back, pop stash.
With worktrees, you have two (or more) working directories checked out
simultaneously from a single .git directory.
How it works — the .git pointer
Git’s .git directory in the main repo stores everything: all objects (commits, trees, blobs), all refs, and the config. A worktree adds a second working directory but shares that same object store — there is no duplication of history.
In the main repo’s .git/worktrees/<name>/ git stores the per-worktree metadata:
HEAD— which commit/branch this worktree is onindex— the staging area for this worktreegitdir— a back-pointer to the worktree directory itselflocked(optional) — preventsgit worktree prunefrom deleting it
Inside the worktree directory itself (e.g. ~/hotfix/), there is no .git directory. Instead there is a .git file — a plain text file containing a single line:
gitdir: /home/user/project/.git/worktrees/hotfix
This is not a symlink. It is a text file that tells git where to find the per-worktree metadata. Git reads this file, resolves the path, finds the index and HEAD for this worktree, and then follows the commondir pointer back to the main .git/ for objects and refs.
~/project/.git/ ← main repo: objects, refs, config (shared)
│ objects/ ← ALL commits, trees, blobs for all worktrees
│ refs/
│ worktrees/
│ hotfix/
│ HEAD ← "ref: refs/heads/main"
│ index ← staging area for the hotfix worktree
│ gitdir ← path back to ~/hotfix/.git
│
~/hotfix/ ← the worktree directory
.git ← TEXT FILE: "gitdir: ~/project/.git/worktrees/hotfix"
(working tree files...)
# Add a worktree for a hotfix at ../hotfix, checked out to main
git worktree add ../hotfix main
# Now you have two working dirs:
# ~/project/ (your feature branch, untouched)
# ~/hotfix/ (checked out to main, ready to fix)
# List active worktrees
git worktree list
# When done, remove it (deletes the directory and the worktrees/hotfix metadata)
git worktree remove ../hotfixYou cannot check out the same branch in two worktrees simultaneously — git enforces this (each branch can only have one index/HEAD) to prevent corruption.
git bisect — Binary Search for Bugs
The problem it solves
“This worked three weeks ago.” You have hundreds of commits and no idea which
one broke something. git bisect performs a binary search through commit
history: at each step you mark the current commit as good or bad, and git
checks out the midpoint of the remaining range. You find the culprit in
O(log n) steps.
Manual bisect
git bisect start
git bisect bad # current HEAD is broken
git bisect good v1.2.0 # this tag was fine
# git checks out the midpoint commit — test it, then:
git bisect good # or: git bisect bad
# repeat until git prints:
# "abc1234 is the first bad commit"
git bisect reset # return to original HEADAutomated bisect with a script
If you can write a script that exits 0 for good and non-zero for bad,
bisect runs fully automatically:
git bisect start HEAD v1.2.0
git bisect run ./test.sh
# git runs ./test.sh at each midpoint and converges without manual stepsgit rerere — Reuse Recorded Resolution
The problem it solves
You resolve the same merge conflict repeatedly — e.g. a long-running branch
that you rebase onto main daily. rerere (Reuse Recorded Resolution) records
how you resolved a conflict the first time and replays that resolution
automatically on future identical conflicts.
How it works
When a conflict occurs, git hashes the conflicted region (the combination of
the two sides). It stores this hash → resolution mapping in .git/rr-cache/.
On the next conflict with the same hash, it applies the stored resolution
automatically.
# Enable globally
git config --global rerere.enabled true
# After enabling, just work normally — rerere is transparent.
# To inspect what it has recorded:
git rerere status
git rerere diff
# To forget a bad recording:
git rerere forget <path>git reflog — The Universal Undo Button
What it is
Every time HEAD moves — checkout, commit, reset, rebase, merge — git appends
an entry to the reflog. It is a local, time-stamped log of where HEAD has been.
It is not pushed to remotes and expires after 90 days by default.
Why it matters
git reset --hard, git rebase, and accidental branch deletions all feel
permanent. They are not, as long as the commits are in the reflog.
# See recent HEAD positions
git reflog
# Output example:
# abc1234 HEAD@{0}: reset: moving to HEAD~3
# def5678 HEAD@{1}: commit: add dark mode
# ...
# Recover the "lost" commit
git checkout def5678 # detached HEAD at the lost commit
git checkout -b recovery # save it as a branchgit log Power Flags
Most people use git log with no flags. The real power:
# Find every commit that added or removed a specific string (pickaxe search)
git log -S "function calculateTax"
# Find commits where a regex matches the diff content
git log -G "calculateTax|computeTax"
# Track a file across renames
git log --follow -- src/utils/tax.ts
# Who changed this specific function? (Git 2.12+)
git log -L :calculateTax:src/utils/tax.ts
# Show the actual patch for each commit
git log -p
# Commits reachable from branch-a but not branch-b
git log branch-b..branch-agit commit --fixup + rebase.autoSquash
The problem it solves
You commit a feature, then notice a bug in it two commits later. You want to amend the original commit — not create a noisy “oops” commit — without interactive rebase gymnastics.
How it works
# Fix a bug introduced in commit abc1234
git add <the fix>
git commit --fixup=abc1234
# Creates a commit titled "fixup! <original commit message>"
# Rebase squashes it automatically into abc1234
git rebase -i --autosquash mainEnable rebase.autoSquash = true globally so you never need --autosquash:
[rebase]
autoSquash = truepush.autoSetupRemote
Every time you push a new branch for the first time, git complains:
fatal: The current branch my-branch has no upstream branch.
One config line silences this forever:
[push]
autoSetupRemote = trueNow git push on a brand-new branch just works.
url.<base>.insteadOf — URL Rewriting
The problem it solves
A repo’s clone URL uses HTTPS, but you want git to always use SSH so your key is used instead of prompting for a password. Or the reverse for CI.
# Always use SSH even when an HTTPS URL is specified
[url "git@github.com:"]
insteadOf = https://github.com/
# Or force HTTPS (useful in CI where SSH keys aren't provisioned)
[url "https://github.com/"]
insteadOf = git@github.com:Git rewrites the URL transparently before any network operation.
git maintenance — Background Repository Housekeeping
What it does
Git repos accumulate loose objects, stale pack files, and an un-optimised
commit-graph over time. git maintenance runs housekeeping tasks on a
scheduler so your repo stays fast without manual git gc.
# Register the current repo and start the background scheduler
git maintenance startDefault schedule:
| Task | Frequency | What it does |
|---|---|---|
prefetch | Hourly | Fetches remote refs in the background |
loose-objects | Daily | Packs loose objects into pack files |
incremental-repack | Daily | Consolidates pack files |
commit-graph | Weekly | Writes a serialised reachability index |
The commit-graph is particularly impactful: git serialises reachability
information so that git log --graph, git merge-base, and similar operations
skip re-traversing the full DAG on every invocation.
git sparse-checkout — Partial Monorepo Checkouts
The problem it solves
A monorepo has 50 GB of history and dozens of subdirectories. You only work
on services/payments. Checking everything out wastes disk and slows index
operations.
How it works
Combine a partial clone (don’t download blobs until needed) with sparse-checkout (only materialise specific paths on disk):
git clone --filter=blob:none --sparse https://github.com/org/monorepo
cd monorepo
git sparse-checkout set services/payments shared/proto--filter=blob:none tells the server to omit file content from the initial
clone. Blobs are fetched lazily on first access. Combined with sparse-checkout,
you get a working tree that contains only the paths you declared.
See also
- Jujutsu and SPR — an alternative VCS built on git’s object model with a cleaner mental model for stacked changes
- GitLab CI vs GitHub Actions — CI/CD platform comparison
References
man git-push,man git-config,man git-worktree,man git-bisect- Git push options — GitLab docs
- Git rerere — Pro Git book
- Partial clone — GitHub blog