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_branch

The 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

PlatformPush option support
GitLabFull: merge_request.* and ci.* built-in
GitHub.comNo 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 EnterpriseLimited; some ci.* options depending on version
Gitea / ForgejoPartial; 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-branch

Making 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_branch

Applying 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_branch

Full GitLab push options reference

OptionEffect
merge_request.createOpen 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_branchDelete branch after merge
merge_request.squashSquash commits on merge
merge_request.draftMark as draft
merge_request.merge_when_pipeline_succeedsAuto-merge when CI passes
ci.skipTell GitLab not to create a pipeline for this push
ci.variable=KEY=VALUECreate 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 on
  • index — the staging area for this worktree
  • gitdir — a back-pointer to the worktree directory itself
  • locked (optional) — prevents git worktree prune from 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 ../hotfix

You 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 HEAD

Automated 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 steps

git 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 branch

git 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-a

git 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 main

Enable rebase.autoSquash = true globally so you never need --autosquash:

[rebase]
    autoSquash = true

push.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 = true

Now 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 start

Default schedule:

TaskFrequencyWhat it does
prefetchHourlyFetches remote refs in the background
loose-objectsDailyPacks loose objects into pack files
incremental-repackDailyConsolidates pack files
commit-graphWeeklyWrites 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

References