Skip to content

branch: delete-merged

Harald Nordgren via GitGitGadget git 25 versions – In review

Get series with b4
b4 am [email protected]

Versions newest first · linked by author and title

  1. v25 Latest7 patches 2 repliesIn review
  2. v247 patches 3 repliesSuperseded
  3. v237 patches 5 repliesSuperseded
Earlier versions · 22
VersionPostedPatchesRepliesStatus
v227 patches1 replySuperseded
v217 patches1 replySuperseded
v207 patches2 repliesSuperseded
v197 patches5 repliesSuperseded
v187 patches19 repliesSuperseded
v177 patches1 replySuperseded
v167 patches0 repliesSuperseded
v157 patches6 repliesSuperseded
v146 patches24 repliesSuperseded
v136 patches4 repliesSuperseded
v126 patches6 repliesSuperseded
v116 patches2 repliesSuperseded
v104 patches11 repliesSuperseded
v95 patches9 repliesSuperseded
v85 patches1 replySuperseded
v75 patches1 replySuperseded
v65 patches1 replySuperseded
v55 patches1 replySuperseded
v46 patches1 replySuperseded
v36 patches0 repliesSuperseded
v26 patches1 replySuperseded
v11 patch2 repliesSuperseded

Changes in v25 from the cover letter

* Wrap long lines in tests.

Patches in v25 No applies recorded yet

#Subject and filesSizeRepliesApplied
1/7branch: add --forked filter for --list modeDocumentation/git-branch.adoc builtin/branch.c ref-filter.c +2 more+234 -30 replies
2/7branch: convert delete_branches() to a flags argumentbuiltin/branch.c+24 -160 replies
3/7branch: let delete_branches skip unmerged branches on bulk refusalbuiltin/branch.c+10 -50 replies
4/7branch: prepare delete_branches for a bulk callerbuiltin/branch.c+9 -40 replies
5/7branch: add --delete-merged <pattern>Documentation/git-branch.adoc builtin/branch.c t/t3200-branch.sh+445 -20 replies
6/7branch: add branch.<name>.deleteMerged opt-outDocumentation/config/branch.adoc Documentation/git-branch.adoc builtin/branch.c +1 more+59 -10 replies
7/7branch: add --dry-run for --delete-mergedDocumentation/git-branch.adoc builtin/branch.c t/t3200-branch.sh+66 -60 replies

Reviews trailers per patch

No Reviewed-by, Acked-by or Tested-by trailers for v25 yet.

Read the v25 thread

Cover letter [PATCH v25 0/7] branch: delete-merged

Delete branches that have already been merged on upstream.

Changes in v25:

* Wrap long lines in tests.

Changes in v24:

* Map branches through the configured push and fetch refspecs before deciding whether pushing would update their upstream.
* Protect only the direct upstream of surviving work, delete deeper merged bases and clear stale upstream configuration.
* Clarify that --delete-merged takes upstream patterns in docs.

Changes in v23:

* Use a loop-local size_t iterator for --forked patterns.
* Simplify upstream-prefix stripping into an explicit fallback.

Changes in v22:

* Loop variable int when appropriate to avoid typecast.

Changes in v21:

* Change loop variables to prevent CI timeout.

Changes in v20:

* Protect branches transitively required by a surviving local upstream stack. Traverse upstream chains once and defer delete-set mutation until traversal completes.
* Make stacked-branch handling independent of ref iteration order and update the documentation accordingly.
* Clarify variable names with regards to branch names (short) to reduce confusion.

Changes in v19:

* Fix bug where dry-run would still remove config, added test coverage.
* Redesigned --delete-merged as a repeatable upstream selector with optional positional patterns limiting deletion scope.
* Protect same-name upstream branches independently of push-default configuration.
* Simplified flags handling where local caching became complicated when mutating values.
* Clarified assertions in tests.

Changes in v18:

* Instead of keeping the whole chain of upstream branches, keep only the ones an unmerged branch still needs. When a kept (merged) branch in turn tracks a branch that is being deleted, clear its now-stale upstream config.
* Rework spare_stacked_bases() to record the kept bases and, in a second pass, clear the upstream of any whose own base is going away. Build the to-delete list with strset_for_each_entry() instead of re-walking the candidate array.

Changes in v17:

* Keep a merged branch when another surviving branch still tracks it as its upstream, so --delete-merged no longer deletes a branch out from under one stacked on top of it.
* Move the --dry-run and branch.<name>.deleteMerged opt-out fully into their own commits.

Changes in v16:

* Convert delete_merged_branches() to take an unsigned int flags argument instead of separate quiet/dry_run booleans, matching delete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_reset per iteration, single strbuf_release after) instead of allocating and freeing it each time
* Rewrite the --delete-merged tests as integration tests: branches that land commits upstream, with deletion and the checked-out, upstream-gone, and push-equals-upstream safety cases exercised together in one run and output asserted via test_cmp
* Collapse the many per-aspect test repos into a single reused repo set up by a setup_repo_for_delete_merged helper, and rename helpers off the old pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of prefixing every command with -C

Changes in v15:

* Renamed --prune-merged to --delete-merged throughout. Not necessarily final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of warning.
* Initialized the delete_branches() flag locals where declared. Only force stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f, --forked coverage), renamed the misleading trunk fixture, and replaced the misnamed detached branch with git checkout --detach.

Changes in v14:

* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the remotes path set a local force but not the DELETE_BRANCH_FORCE bit that check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other branches too, hopefully not related
(https://github.com/git/git/pull/2285).

Changes in v13:

* Reworked --forked into a real ref-filter applied in apply_ref_filter() instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the abbreviated upstream), and dropped the old helper machinery, forward declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git branch --prune-merged origin/main 'feature*') instead of repeating the option.

Changes in v12:

* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST options.
* Dropped the bare-remote-name resolution for --forked, the argument is now a ref or a glob.

Changes in v11:

* The flags now take a branch, not a remote. --forked and --prune-merged accept a literal upstream short name like origin/main or a wildmatch pattern like origin/. The old --all-remotes flag is gone, since origin/ covers that case.
* The prune guard now compares @{push} against @{upstream}. A branch is spared when these are equal. That is the trunk like case, such as local main tracking and pushing to origin/main, where "fully merged to upstream" cannot be told apart from "just pulled". Only branches that push somewhere other than their upstream, typically fork based topics, are candidates. The earlier /HEAD by name guard that the reviewer rejected is gone.
* New --dry-run for --prune-merged.

Changes in v10:

* --forked / --prune-merged now take a branch glob instead of a remote name — origin, origin/*, origin/release-- all work. This replaces the remote-only form and subsumes the old --all-remotes flag, which has been dropped.
* New --dry-run for --prune-merged.

Changes in v9:

* --force no longer has special meaning with --prune-merged; reachability is always enforced. Use git branch -D to delete an unmerged branch. Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.

Changes in v8:

* Delete only when the branch's work is actually reachable from its upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place

Changes in v7:

* --prune-merged now checks if a branch is merged into its own upstream first. If the upstream is gone, it checks against the remote's default branch instead. If neither exists, the branch is refused (use --force to delete anyway).

Changes in v6:

* --prune-merged now measures merged-ness against the remote's default branch instead of the candidate's upstream — so the decision no longer depends on which branch happens to be checked out locally.
* delete_branches() / check_branch_commit() gained a per-candidate override that lets a caller substitute a different "what counts as merged" reference (or skip the check). branch -d callers pass NULL and keep their existing semantics.
* prune_merged_branches() resolves each candidate's push-remote HEAD and threads it through, so --prune-merged --all-remotes measures each candidate against its own remote rather than a single global reference.

Changes in v5:

* Drop commit 'fetch: add --prune-merged'

Changes in v4:

* Resolve each remote's HEAD and collect the targets into a protected_default_refs set in collect_forked_set.
* In prune_merged_branches, skip a candidate when its upstream is a protected default ref and the local branch name matches the default branch's leaf name (so a local main tracking origin/main is spared, but a renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name (not by upstream alone); spare a branch whose push ref is the remote default.

Changes in v3:

* s/remote-tracking refs/remote-tracking branches/g

Changes in v2:

* The whole feature moved out of git fetch and into git branch. git fetch --prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their tip is reachable from the upstream tracking ref; --force skips that safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running topic branch) even with --force; doesn't affect explicit git branch -d.
* delete_branches() got a warn_only mode so bulk deletion prints a one-line warning per skipped branch instead of the noisy four-line hint that git branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention --prune-merged.
* New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh shrunk since most logic moved.

Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <pattern>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged

 Documentation/config/branch.adoc |   7 +
 Documentation/git-branch.adoc    |  52 +++-
 builtin/branch.c                 | 312 ++++++++++++++++++++---
 ref-filter.c                     |  70 +++++
 ref-filter.h                     |  10 +
 t/t3200-branch.sh                | 421 +++++++++++++++++++++++++++++++
 6 files changed, 841 insertions(+), 31 deletions(-)

base-commit: 5b2471720c93ee30e5764a19f3d3b3ae9ec9712a
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v25
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v25
Pull-Request: https://github.com/git/git/pull/2285

Range-diff vs v24:

1: 59157142b3 ! 1: 2f8953b339 branch: add --forked filter for --list mode

     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +'
      +
      +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
     -+	git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
     ++	git -C forked branch --forked origin/one \
     ++		--format="%(refname:short)" >actual &&
      +	echo local-one >expect &&
      +	test_cmp expect actual
      +'
      +
      +test_expect_success '--forked <glob> filters by wildmatch' '
     -+	git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
     ++	git -C forked branch --forked "origin/*" \
     ++		--format="%(refname:short)" >actual &&
      +	cat >expect <<-\EOF &&
      +	local-one
      +	local-two
     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +'
      +
      +test_expect_success '--forked <local-branch> matches branches with local upstream' '
     -+	git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
     ++	git -C forked branch --forked local-base \
     ++		--format="%(refname:short)" >actual &&
      +	echo local-onbase >expect &&
      +	test_cmp expect actual
      +'
      +
      +test_expect_success '--forked can be repeated to widen the filter' '
     -+	git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
     ++	git -C forked branch --forked origin/one \
     ++		--forked other/foreign \
     ++		--format="%(refname:short)" >actual &&
      +	cat >expect <<-\EOF &&
      +	local-foreign
      +	local-one
     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +'
      +
      +test_expect_success '--forked combines literal and glob arguments' '
     -+	git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
     ++	git -C forked branch --forked local-base \
     ++		--forked "other/*" \
     ++		--format="%(refname:short)" >actual &&
      +	cat >expect <<-\EOF &&
      +	local-foreign
      +	local-onbase
     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +'
      +
      +test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
     -+	git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
     ++	git -C forked branch --forked "*/*" \
     ++		--format="%(refname:short)" >actual &&
      +	cat >expect <<-\EOF &&
      +	local-foreign
      +	local-one
     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +'
      +
      +test_expect_success '--forked composes with --no-merged' '
     -+	git -C forked branch --forked "origin/*" --no-merged origin/one --format="%(refname:short)" >actual &&
     ++	git -C forked branch --forked "origin/*" \
     ++		--no-merged origin/one \
     ++		--format="%(refname:short)" >actual &&
      +	echo local-one >expect &&
      +	test_cmp expect actual
      +'
      +
      +test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '
     -+	git -C forked branch --forked origin --format="%(refname:short)" >actual &&
     ++	git -C forked branch --forked origin \
     ++		--format="%(refname:short)" >actual &&
      +	echo main >expect &&
      +	test_cmp expect actual
      +'
      +
      +test_expect_success '--forked narrows a <pattern> argument' '
     -+	git -C forked branch --forked "origin/*" "local-*" --format="%(refname:short)" >actual &&
     ++	git -C forked branch --forked "origin/*" "local-*" \
     ++		--format="%(refname:short)" >actual &&
      +	cat >expect <<-\EOF &&
      +	local-one
      +	local-two
 2:  5d310da9aa = 2:  2e80fc3791 branch: convert delete_branches() to a flags argument
 3:  263e90f6ed = 3:  4ab81f0b0a branch: let delete_branches skip unmerged branches on bulk refusal
 4:  22e31fd35c = 4:  83cc3fd6a5 branch: prepare delete_branches for a bulk caller
 5:  d9f93e66c9 ! 5:  a5ef60a394 branch: add --delete-merged <pattern>
     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
      +	(
      +		cd repo &&
      +		git checkout -b topic &&
     -+		git config remote.origin.push "refs/heads/topic:refs/heads/published" &&
     ++		git config remote.origin.push \
     ++			"refs/heads/topic:refs/heads/published" &&
      +		git push origin &&
      +		git branch --set-upstream-to=origin/published topic &&
      +		git checkout -b other-topic --track origin/published &&
     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
      +		topic
      +		EOF
      +
     -+		git config --local --get-regexp "branch\\.(feature|topic)\\.(merge|remote)" >actual &&
     ++		pattern="branch\\.(feature|topic)\\.(merge|remote)" &&
     ++		git config --local --get-regexp "$pattern" >actual &&
      +		cat >expect <<-\EOF &&
      +		branch.feature.remote origin
      +		branch.feature.merge refs/heads/next
     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
      +		git commit --allow-empty -m "tip work" &&
      +		sha=$(git rev-parse --short lower) &&
      +
     -+		git branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&
     ++		git branch --delete-merged origin/next \
     ++			--delete-merged lower >actual 2>&1 &&
      +		echo "Deleted branch lower (was $sha)." >expect &&
      +		test_cmp expect actual &&
      +
     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
      +		tip
      +		EOF
      +
     -+		git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
     ++		pattern="branch\\.(mid|tip)\\.(merge|remote)" &&
     ++		git config --local --get-regexp "$pattern" >actual &&
      +		cat >expect <<-\EOF &&
      +		branch.tip.remote .
      +		branch.tip.merge refs/heads/mid
     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
      +		main
      +		EOF
      +
     -+		git branch --delete-merged origin/next --delete-merged "c-*" >actual 2>&1 &&
     ++		git branch --delete-merged origin/next \
     ++			--delete-merged "c-*" >actual 2>&1 &&
      +		test_must_be_empty actual &&
      +
      +		check_branches <<-\EOF
 6:  aa845096d8 = 6:  c2d1fdebe8 branch: add branch.<name>.deleteMerged opt-out
 7:  96ebfd6ec6 ! 7:  5a936bcb93 branch: add --dry-run for --delete-merged
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
       		git checkout -b tracks-other --track other/main &&
       		sha=$(git rev-parse --short merged) &&
       
     -+		git branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&
     ++		git branch --dry-run --delete-merged origin/next merged \
     ++			>actual 2>&1 &&
      +		echo "Would delete branch merged (was $sha)." >expect &&
      +		test_cmp expect actual &&
      +		git rev-parse --verify refs/heads/merged &&
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstr
       		git commit --allow-empty -m "tip work" &&
       		sha=$(git rev-parse --short lower) &&
       
     -+		git branch --dry-run --delete-merged origin/next --delete-merged lower >actual 2>&1 &&
     ++		git branch --dry-run --delete-merged origin/next \
     ++			--delete-merged lower >actual 2>&1 &&
      +		echo "Would delete branch lower (was $sha)." >expect &&
      +		test_cmp expect actual &&
      +
     -+		git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual &&
     ++		pattern="branch\\.(lower|mid|tip)\\.(merge|remote)" &&
     ++		git config --local --get-regexp "$pattern" >actual &&
      +		cat >expect <<-\EOF &&
      +		branch.lower.remote origin
      +		branch.lower.merge refs/heads/next
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstr
      +		EOF
      +		test_cmp expect actual &&
      +
     - 		git branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&
     + 		git branch --delete-merged origin/next \
     + 			--delete-merged lower >actual 2>&1 &&
       		echo "Deleted branch lower (was $sha)." >expect &&
     - 		test_cmp expect actual &&
      @@ t/t3200-branch.sh: test_expect_success "branch -d still deletes a deleteMerged=false branch" '
       	)
       '
Signature · 1 line
gitgitgadget