Git 2.56 for Daily Work
Git 2.56 for Daily Work: add --resolved, branch --delete-merged, and Friends
Thesis
Git 2.56.0 (tagged 28 September 2026) adds two commands to the end of the merge cycle that most people have been scripting by hand:
git add --resolvedstages exactly the conflicted paths you have finished, and nothing else. It refuses the whole batch if any of them still contain conflict markers.git branch --delete-merged <upstream>deletes local branches whose work has already landed on the upstream they track. Use--dry-runto preview it.
There are also three smaller tools: git bisect run --reset-when-found cleans up after itself, git history drop removes one commit from a branch (and the branches stacked on it) without an interactive rebase, and git branch --forked lists branches by what they track. On top of that, git merge-base and git branch --contains got faster without any change on your side.
Each new flag is a sharp, narrow tool. --resolved only knows what a conflict marker is, so it does not know whether you resolved a conflict well. --delete-merged only removes branches that track something other than their own push destination, and most people’s branches don’t. This page shows both behaviours on real repositories, so you know which of your branches and conflicts the new flags will and won’t handle.
Everything below was run with git version 2.56.0. Earlier versions reject these options as unknown.
Mental model
end of a merge end of a topic's life
git status --short local upstream
M README.md (yours, unrelated) fix/typo ──► origin/main tip reachable? ─┐
UU app.py (resolved) feat/wip ──► origin/main no │
UU config.toml (resolved) feat/x ──► origin/feat/x (push target) │
▼
git add -u stages all three git branch --delete-merged origin
git add --resolved stages only the UU paths deletes fix/typo only
and refuses if any still
has <<<<<<< / ======= / >>>>>>>
| Command | What it does | What it does not do |
|---|---|---|
git add --resolved [<pathspec>] |
Stages unmerged paths that have no conflict markers left. One leftover marker anywhere and nothing is staged | It does not look at binary or modify/delete conflicts. Those have no markers, so they are staged as whatever is in the working tree |
git branch --delete-merged <pattern> [<branch-glob>…] |
Deletes local branches whose upstream matches <pattern> (a ref, a remote name meaning its HEAD, or a glob) and whose tip is reachable from that upstream |
It skips branches that track their own push destination, branches whose upstream is gone, and squash-merged branches, all without a word |
--dry-run |
With --delete-merged, prints Would delete branch … and touches nothing |
|
branch.<name>.deleteMerged=false |
Exempts one branch, and says so (Skipping '…') |
Does not affect git branch -d |
git branch --forked <pattern> |
Lists branches by upstream. It’s the same match --delete-merged uses, without the reachability check |
|
git bisect … --reset-when-found[=original\|found] |
Ends the bisection by itself once the first bad commit is found | Can’t be combined with --no-checkout |
git history drop <commit> |
Removes one commit and replays its descendants, including other branches stacked on top | It won’t drop root or merge commits, and it won’t stop for conflicts. It aborts instead |
Worked examples (Cases)
Every case uses throwaway repositories under /tmp/g256. Source this file first so your hashes match the transcripts. It also shuts out your global config, so your aliases and push.default don’t change the results:
# env.sh — source this so your hashes match the transcripts (needs git 2.56+)
export GIT_AUTHOR_NAME="Dev" GIT_AUTHOR_EMAIL="dev@example.com"
export GIT_COMMITTER_NAME="Dev" GIT_COMMITTER_EMAIL="dev@example.com"
export GIT_AUTHOR_DATE="2026-10-01T09:00:00Z" GIT_COMMITTER_DATE="2026-10-01T09:00:00Z"
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
export GIT_CONFIG_COUNT=2
export GIT_CONFIG_KEY_0=init.defaultBranch GIT_CONFIG_VALUE_0=main
export GIT_CONFIG_KEY_1=advice.detachedHead GIT_CONFIG_VALUE_1=falseCase 1: Stage what you resolved, not what you happened to touch
git add -u after a conflict is a habit, and it’s how unrelated edits end up inside merge commits. Set up a merge with two conflicts while an uncommitted note sits in README.md:
#!/usr/bin/env bash
# setup-conflict.sh — a merge with two conflicted files and one unrelated local edit
set -euo pipefail
rm -rf /tmp/g256/conflict && mkdir -p /tmp/g256/conflict && cd /tmp/g256/conflict
git init -q
printf 'port = 8080\nlog = "info"\n' > config.toml
printf 'def greet():\n return "hello"\n' > app.py
printf '# demo\n' > README.md
git add . && git commit -qm "base"
git switch -qc feature
printf 'port = 9090\nlog = "info"\n' > config.toml
printf 'def greet():\n return "hello, world"\n' > app.py
git commit -qam "feature: new port and greeting"
git switch -q main
printf 'port = 8081\nlog = "info"\n' > config.toml
printf 'def greet():\n return "hi"\n' > app.py
git commit -qam "main: different port and greeting"
echo "local note, not part of the merge" >> README.md # unrelated, uncommitted
git merge feature || true$ source env.sh && ./setup-conflict.sh
Auto-merging app.py
CONFLICT (content): Merge conflict in app.py
Auto-merging config.toml
CONFLICT (content): Merge conflict in config.toml
Automatic merge failed; fix conflicts and then commit the result.
$ cd /tmp/g256/conflict
$ git status --short
M README.md
UU app.py
UU config.toml
$ printf 'def greet():\n return "hello, world"\n' > app.py
$ git add --resolved; echo "exit=$?"
fatal: the following paths still have conflict markers:
config.toml
exit=128
$ git status --short
M README.md
UU app.py
UU config.toml
$ printf 'port = 9090\nlog = "info"\n' > config.toml
$ git add --resolved
$ git status --short
M README.md
M app.py
M config.toml
$ git add --resolved -u
fatal: options '-u/--update' and '--resolved' cannot be used together
$ git commit -q --no-edit
$ git status --short
M README.md
Three things to read off this:
- With
app.pyfixed andconfig.tomlstill marked,--resolvedstaged nothing, not evenapp.py. It is all or nothing on purpose, so the index never shows a half-finished merge as resolved. - Once both were clean it staged both, and
README.mdstayedM. The merge commit holds the merge and nothing else, and your note is still waiting in the working tree. --resolvedcan’t be combined with-uor-A, because those mean “stage everything tracked”. You can narrow it with a pathspec:git add --resolved src/stages only the resolved paths undersrc/.
Case 2: Set topics up the way --delete-merged expects
--delete-merged matches on a branch’s upstream, so it works best when each topic tracks the branch it will land on (origin/main) and is pushed to a branch of its own name. Git already does the first half for you. A branch started from a remote-tracking branch gets that branch as its upstream (branch.autoSetupMerge=true is the default). The push needs one setting:
$ git switch -c fix/readme origin/main
Switched to a new branch 'fix/readme'
branch 'fix/readme' set up to track 'origin/main'.
$ echo r > r.txt && git add r.txt && git commit -qm 'fix/readme'
$ git push
fatal: The upstream branch of your current branch does not match
the name of your current branch. To push to the upstream branch
on the remote, use
git push origin HEAD:main
To push to the branch of the same name on the remote, use
git push origin HEAD
To choose either option permanently, see push.default in 'git help config'.
To avoid automatically configuring an upstream branch when its name
won't match the local branch, see option 'simple' of branch.autoSetupMerge
in 'git help config'.
$ git config push.default current
$ git push
To /tmp/g256/br/origin.git
* [new branch] fix/readme -> fix/readme
$ git branch -vv --list fix/readme
* fix/readme 08b47a1 [origin/main: ahead 1] fix/readme
push.default=simple, the default, refuses to push a branch to a name that differs from its upstream. push.default=current pushes fix/readme to origin/fix/readme and leaves the upstream on origin/main. Now git status says how far you are from main, which is the comparison you care about for a topic. Once the work is merged, --delete-merged origin will recognise it.
Case 3: Preview, then sweep
This script builds a clone with eight topic branches (plus main), covering every state you’ll meet: merged, unfinished, squash-merged, tracking its own remote branch, upstream deleted, checked out in another worktree, opted out, and tracking a release branch that never got the fix:
#!/usr/bin/env bash
# setup-branches.sh — a clone with eight local branches in different states
set -euo pipefail
rm -rf /tmp/g256/br && mkdir -p /tmp/g256/br && cd /tmp/g256/br
git init -q --bare origin.git
git clone -q origin.git work 2>/dev/null && cd work
echo base > app.txt && git add app.txt && git commit -qm "base" && git push -q origin main
topic() { # topic <branch> <upstream>: branch from origin/main tracking <upstream>, one commit
git switch -qc "$1" --track "$2" 2>/dev/null || git switch -qc "$1" origin/main
echo "$1" > "${1//\//-}.txt" && git add . && git commit -qm "$1"
}
# 1. merged: tracks origin/main, merged upstream with a merge commit
topic fix/typo origin/main; git push -q origin fix/typo
# 2. not merged yet
topic feat/wip origin/main
# 3. squash-merged upstream (different commit, same content)
topic feat/squashed origin/main; git push -q origin feat/squashed
# 4. tracks its own remote branch, merged, then the remote branch was deleted
git switch -qc feat/gone origin/main && echo gone > gone.txt && git add . && git commit -qm "feat/gone" && git push -qu origin feat/gone
# 5. tracks its own remote branch, merged, remote branch still there
git switch -qc feat/same-name origin/main && echo same > same.txt && git add . && git commit -qm "feat/same-name" && git push -qu origin feat/same-name
# 6. merged, but checked out in a linked worktree
topic fix/in-worktree origin/main; git push -q origin fix/in-worktree
# 7. merged, but opted out
topic feat/round-two origin/main; git push -q origin feat/round-two
git config branch.feat/round-two.deleteMerged false
# 8. merged into main, tracks a release branch that does not have it
git push -q origin main:release/1.0
git fetch -q
topic fix/backport origin/release/1.0; git push -q origin fix/backport
git switch -q main
# "upstream" merges everything except feat/wip; feat/squashed is squashed
rm -rf ../hub && git clone -q ../origin.git ../hub 2>/dev/null && cd ../hub
for b in fix/typo feat/gone feat/same-name fix/in-worktree feat/round-two fix/backport; do
git merge -q --no-ff --no-edit "origin/$b" >/dev/null
done
git merge -q --squash origin/feat/squashed >/dev/null 2>&1 && git commit -qm "feat/squashed (squashed)"
git push -q origin main
git push -q origin --delete feat/gone
cd ../work
git fetch -q --prune
git worktree add -q ../wt fix/in-worktree$ source env.sh && ./setup-branches.sh && cd /tmp/g256/br/work
$ git branch -vv
feat/gone db5921d [origin/feat/gone: gone] feat/gone
feat/round-two e157f1f [origin/main: behind 12] feat/round-two
feat/same-name 0401489 [origin/feat/same-name] feat/same-name
feat/squashed d74a4cc [origin/main: ahead 1, behind 13] feat/squashed
feat/wip 2fc13cf [origin/main: ahead 1, behind 13] feat/wip
fix/backport d76cc42 [origin/release/1.0: ahead 1] fix/backport
+ fix/in-worktree 15ead81 (/tmp/g256/br/wt) [origin/main: behind 12] fix/in-worktree
fix/typo fdcf8be [origin/main: behind 12] fix/typo
* main 9d063a9 [origin/main: behind 13] base
$ git branch --forked origin/main
feat/round-two
feat/squashed
feat/wip
+ fix/in-worktree
fix/typo
* main
$ git branch --dry-run --delete-merged origin
Skipping 'feat/round-two' (branch.feat/round-two.deleteMerged is false)
Would delete branch fix/typo (was fdcf8be).
$ git branch --dry-run --delete-merged origin 'fix/*'
Would delete branch fix/typo (was fdcf8be).
$ git branch --delete-merged origin
Skipping 'feat/round-two' (branch.feat/round-two.deleteMerged is false)
Deleted branch fix/typo (was fdcf8be).
$ git branch --list
feat/gone
feat/round-two
feat/same-name
feat/squashed
feat/wip
fix/backport
+ fix/in-worktree
* main
Read the result branch by branch:
| Branch | Outcome | Why |
|---|---|---|
fix/typo |
deleted | Tracks origin/main, and its tip is reachable from it |
feat/round-two |
kept, reported | branch.feat/round-two.deleteMerged=false |
fix/in-worktree |
kept, silent | Checked out in /tmp/g256/br/wt |
feat/wip |
kept, silent | Not merged yet |
feat/squashed |
kept, silent | A squash merge creates a new commit, so the branch tip is never reachable from origin/main |
feat/same-name, main |
kept, silent | git push would update their own upstream. Right after a pull, such a branch is always “merged”, so Git refuses to guess |
feat/gone |
kept, silent | Its upstream origin/feat/gone was pruned after the merge |
fix/backport |
kept, silent | Merged into main, but its upstream origin/release/1.0 doesn’t have it |
Passing origin means “the branch origin/HEAD points at”, so origin/main here. 'origin/*' widens the match to every upstream on that remote, and you can repeat --delete-merged to name several. A trailing 'fix/*' limits which local branches are even considered. --forked uses the same upstream match as a listing filter, so git branch --forked origin/main is a cheap way to see the candidates before anything is checked for reachability.
Deleted branches print their old tip (was fdcf8be). Until gc prunes the commits, git branch fix/typo fdcf8be puts it back.
Case 4: A bisect that cleans up after itself
git bisect run used to leave you on a detached HEAD in the middle of the bisection state until you remembered git bisect reset. Now it can finish the job:
#!/usr/bin/env bash
# setup-bisect.sh — 16 commits; commit 11 breaks check.sh
set -euo pipefail
rm -rf /tmp/g256/bisect && mkdir -p /tmp/g256/bisect && cd /tmp/g256/bisect
git init -q
printf '#!/bin/sh\ngrep -q "^limit=10$" limits.conf\n' > check.sh && chmod +x check.sh
echo "limit=10" > limits.conf
git add . && git commit -qm "c1: add check"
for i in $(seq 2 16); do
[ "$i" -eq 11 ] && echo "limit=100" > limits.conf
echo "change $i" >> CHANGELOG && git add . && git commit -qm "c$i"
done
git switch -qc topic && git switch -q main$ source env.sh && ./setup-bisect.sh && cd /tmp/g256/bisect
$ git switch -q topic
$ git bisect start main main~15
Bisecting: 7 revisions left to test after this (roughly 3 steps)
[977dff1cd080a1d2dc267fd03fad3d5bd34aedae] c8
$ git bisect run --reset-when-found ./check.sh
running './check.sh'
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[17edb82aa1019346897ab8763f8baf74af113808] c12
running './check.sh'
Bisecting: 1 revision left to test after this (roughly 1 step)
[e7ccdbe0a5c6e71b2fea6e0f448dc4aba80c7f52] c10
running './check.sh'
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[09ee7e9ff3cff397a539e7e73d2b9ac5d3a6ff5f] c11
running './check.sh'
09ee7e9ff3cff397a539e7e73d2b9ac5d3a6ff5f is the first 'bad' commit
commit 09ee7e9ff3cff397a539e7e73d2b9ac5d3a6ff5f
Author: Dev <dev@example.com>
Date: Thu Oct 1 09:00:00 2026 +0000
c11
CHANGELOG | 1 +
limits.conf | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
bisect found first 'bad' commit
$ git status --short --branch
## topic
$ git bisect log
error: We are not bisecting.
$ git bisect start --reset-when-found=found main main~15
Bisecting: 7 revisions left to test after this (roughly 3 steps)
[977dff1cd080a1d2dc267fd03fad3d5bd34aedae] c8
$ git bisect run ./check.sh >/dev/null
$ git status --short --branch
## HEAD (no branch)
$ git log -1 --oneline
09ee7e9 c11
--reset-when-found (that is, =original) put us back on topic, with no bisection state left: We are not bisecting. =found leaves the culprit checked out (detached at 09ee7e9 c11) and still clears the state. That’s what you want when the next step is git show or a build of the bad commit. You can give the option to bisect start, as in the second run, or to bisect run. It’s refused with --no-checkout (options '--reset-when-found' and '--no-checkout' cannot be used together), because there is no checkout to return from.
Case 5: Drop one commit without an interactive rebase
git history is still marked experimental. 2.56 adds drop next to reword, split, and fixup:
#!/usr/bin/env bash
# setup-history.sh — a short topic with one commit that should never have happened
set -euo pipefail
rm -rf /tmp/g256/hist && mkdir -p /tmp/g256/hist && cd /tmp/g256/hist
git init -q
echo "app" > app.txt && git add . && git commit -qm "base"
git switch -qc feat/report
echo "query" > query.sql && git add . && git commit -qm "add report query"
echo "debug=1" > .env.local && git add . && git commit -qm "WIP: local debug settings"
echo "render" > render.py && git add . && git commit -qm "render report"
git branch feat/report-ui # a branch stacked on top
echo "ui" > ui.js && git switch -q feat/report-ui && git add . && git commit -qm "report UI"
git switch -q feat/report$ source env.sh && ./setup-history.sh && cd /tmp/g256/hist
$ git log --oneline --decorate --graph feat/report-ui
* 4ff5f05 (feat/report-ui) report UI
* 6021dd1 (HEAD -> feat/report) render report
* e3d4de1 WIP: local debug settings
* 99efa11 add report query
* e135d0b (main) base
$ git history drop HEAD~1 --dry-run
update refs/heads/feat/report 5c85b08fe93e8dcbebb35bcd5e0fd6b7b8b725df 6021dd1b2c922ee1fa03f10a65d75d3012da1a89
update refs/heads/feat/report-ui 83fe96025062c342ad0ad189b968ba7766bd58f1 4ff5f05abfefe16eb8dd35c9382d9f8b17c0f457
$ git history drop HEAD~1
$ git log --oneline --decorate --graph feat/report-ui
* 83fe960 (feat/report-ui) report UI
* 5c85b08 (HEAD -> feat/report) render report
* 99efa11 add report query
* e135d0b (main) base
$ ls
app.txt
query.sql
render.py
--dry-run prints the ref updates it would make, in update-ref --stdin form (update <ref> <new> <old>). The real run made the same updates: feat/report and the stacked feat/report-ui both moved, so the stack stayed in one piece. To move only the current branch, pass --update-refs=head. Unlike rebase -i, it never stops halfway. If a descendant doesn’t replay cleanly, nothing changes at all:
$ echo 'app v2' > app.txt && git commit -qam 'bump to v2'
$ echo 'app v3' > app.txt && git commit -qam 'bump to v3'
$ git history drop HEAD~1; echo "exit=$?"
error: failed replaying descendants
exit=255
$ git log --oneline -3
9be6c5a bump to v3
97e8f92 bump to v2
5c85b08 render report
The 9be6c5a on top is the same commit as before the failed command. No sequencer state is left behind and there is no --abort to remember. When the drop needs a real conflict resolution, use git rebase -i.
What you get for free
There are two speedups with no new syntax, both from the release notes:
git merge-basenow stops walking once one side’s exclusive commits run out, which makes queries with a one-sided history much faster. Merges, rebases, andgit log A...Ball compute merge bases. The same release also fixes a case wheregit merge-base(without--all) could return a wrong answer on repositories with v1 commit-graphs and clock skew.git branch --containsandgit for-each-ref --containsnow use the memoised traversal thatgit tag --containsalready had.
The release notes describe these as significant speedups without benchmark numbers. Measure on your own repository before you quote one.
The trap
--resolved checks for markers. It does not check that you resolved anything. Three common conflicts break that assumption in three ways:
#!/usr/bin/env bash
# setup-traps.sh — three conflicts that have no conflict markers to find
set -euo pipefail
rm -rf /tmp/g256/traps && mkdir -p /tmp/g256/traps && cd /tmp/g256/traps
git init -q
printf 'Release notes\n=======\n\n- v1\n' > NOTES.md
printf 'logo\0v1' > logo.png
echo "legacy" > legacy.cfg
git add . && git commit -qm "base"
git switch -qc side
printf 'Release notes\n=======\n\n- v2\n' > NOTES.md
printf 'logo\0v2' > logo.png
echo "legacy, patched" > legacy.cfg
git commit -qam "side: notes, new logo, patch legacy.cfg"
git switch -q main
printf 'Release notes\n=======\n\n- v3\n' > NOTES.md
printf 'logo\0v3' > logo.png
git rm -q legacy.cfg
git commit -qam "main: notes, other logo, drop legacy.cfg"
git merge side || true$ source env.sh && ./setup-traps.sh >/dev/null && cd /tmp/g256/traps
$ git status --short
UU NOTES.md
DU legacy.cfg
UU logo.png
$ printf 'Release notes\n=======\n\n- v2\n- v3\n' > NOTES.md
$ git add --resolved
fatal: the following paths still have conflict markers:
NOTES.md
$ git add --resolved logo.png legacy.cfg
$ git status --short
UU NOTES.md
A legacy.cfg
$ git diff --cached --stat HEAD
NOTES.md | Unmerged
legacy.cfg | 1 +
1 file changed, 1 insertion(+)
- False positive.
NOTES.mdis cleanly resolved, but its Markdown heading is underlined with exactly seven=. That is the same shape as a conflict separator, so--resolvedrefuses. Either stage that file explicitly after reading it (git add NOTES.md), or setNOTES.md conflict-marker-size=32in.gitattributesbefore merging. Git then writes 32-character markers, and seven=no longer looks like one. - Silent “resolution” of a binary.
logo.pngwasUU, and now it isn’t listed at all.--resolvedstaged the working-tree copy, which for a binary conflict is ours. The staged diff againstHEADis empty, so their logo is gone without a trace. - Silent resurrection.
legacy.cfgwas deleted onmainand modified onside(DU). It has no markers, so--resolvedstaged the surviving copy (A legacy.cfg) and brought back a file your branch had deleted on purpose.
The other trap is the flip side of Case 3: --delete-merged is quiet about everything it doesn’t delete. If your team’s flow is “push to origin/<same-name>, squash-merge on the forge, delete the remote branch”, none of your branches qualify. They all track their own push destination, are squash-merged, or have a pruned upstream. The command prints nothing and deletes nothing, which looks the same as having nothing to clean up.
The boring rule
After a conflicted merge, run
git status --shortand deal with everyDU,UD,AA, and binaryUUdeliberately (git checkout --ours/--theirs <path>, orgit rm). Then usegit add --resolvedfor the text files and read any refusal it gives you. Nevergit add -uorgit add .in the middle of a merge.Start topics from
origin/main, setpush.default=current, and clean up withgit fetch --prune && git branch --dry-run --delete-merged originbefore the real run. Mark long-lived topics withbranch.<name>.deleteMerged=false. Delete squash-merged and same-name branches yourself withgit branch -Dafter checkinggit log origin/main..<branch>.
Try this
- Rerun Case 1, but after resolving both files use
git add -uinstead of--resolved. Commit withgit commit --no-edit, then rungit show --stat HEADand findREADME.md, your unrelated note, inside the merge commit. - Copy
setup-traps.shand addecho 'NOTES.md conflict-marker-size=32' > .gitattributesjust before thebasecommit. Rerun it, resolveNOTES.mdthe same way, and confirm thatgit add --resolved NOTES.mdnow succeeds whilelogo.pngandlegacy.cfgstay unmerged. - In the Case 3 clone, run
git branch --forked 'origin/*' -vvand predict, before running it, whatgit branch --dry-run --delete-merged 'origin/*'will print. - In the Case 3 clone, run
git cherry origin/main feat/squashedandgit cherry origin/main feat/wip. A-line means an equivalent patch is already upstream, which is the evidence you want beforegit branch -Don a squash-merged branch. - Write a
git sweepalias that runsfetch --prune, thenbranch --dry-run --delete-merged origin, asksy/N, and only then runs the real thing.
Sources
- Git 2.56.0 release notes: https://github.com/git/git/blob/v2.56.0/Documentation/RelNotes/2.56.0.adoc
git-add(1),--resolved: https://github.com/git/git/blob/v2.56.0/Documentation/git-add.adocgit-branch(1),--delete-merged,--dry-run,--forked: https://github.com/git/git/blob/v2.56.0/Documentation/git-branch.adocgit-config(1),branch.<name>.deleteMerged: https://github.com/git/git/blob/v2.56.0/Documentation/config/branch.adocgit-bisect(1),--reset-when-found: https://github.com/git/git/blob/v2.56.0/Documentation/git-bisect.adocgit-history(1),drop: https://github.com/git/git/blob/v2.56.0/Documentation/git-history.adocgitattributes(5),conflict-marker-size: https://git-scm.com/docs/gitattributes#_conflict_marker_size