branch: delete-merged#2285
Conversation
7bb8db6 to
3fce72f
Compare
4d3e620 to
94014b8
Compare
184a37a to
14e3085
Compare
|
/submit |
|
Submitted as pull.2285.git.git.1777671337839.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
Junio C Hamano wrote on the Git mailing list (how to reply to this email): "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> Introduce a tri-state config option that, when --prune (or
> fetch.prune / remote.<name>.prune) removes a remote-tracking
> ref, also deletes local branches whose configured upstream is
> that ref.
>
> Values:
> - false (default): no change in behavior.
> - safe: delete only if the local tip is reachable from the
> upstream tip, preserving any unpushed work.
> - force: delete unconditionally; recoverable only via reflog.
>
> The currently checked-out branch is always preserved.
I do like the feature that allows you to identify which local
branches are already merged and prune them. It will help users keep
their local branch namespace clean.
I however do not like to see the feature tied to "fetch". By this,
I do not mean I do not want an option to trigger the feature when
"git fetch" is run. What I mean is that users should have an option
to prune merged branches without having to fetch first. And you can
then optionally trigger that machinery from "git fetch".
Of course they aleady can do something silly like
$ git branch -d $(git branch --list | sed -e 's/^..//')
and remove all the merged branches, but compared to what is
presented here, one thing missing is that you allow pruning the
local branches that are merged only to remote-tracking branches from
a single remote.
To break the feature down to make it easier to use by our users with
various needs and workflows, we would benefit from having a
collection of smaller features that can be composed, like these:
* "git branch --forked <remote>" lists local branches that build on
something taken from <remote>s. The option can be given multiple
times to make a union of the results from individual "--forked
<remote>".
- <remote> may be a name of a remote, e.g., "origin" to mean all
the remote-tracking branches "refs/remotes/origin/*",
- <remote> may be "origin/master" to name a specific
remote-tracking branch.
- There may be other handy things to cover with <remote>, like
"--all" that may act as if you listed all the available
<remote> on the command line.
* "git branch --prune-merged <remote>..." is a short-hand for "git
branch -d $(git branch --forked <remote>...".
* "git fetch/pull --prune-merged <remote>" can trigger "git branch
--prune-merged <remote>" after "git fetch" successfully updates
the remote-tracking branches, which should be equivalent to what
you have here..
Some local branches that fork from remote and have their initial
round already merged may not want to be pruned, however. You may
have multi-stage development plans for that topic, and you know
already the second phase would want to build on top of the initial
round, not a random version of the mainline with many topics from
other folks merged in. So you'd rather want to keep the topic
branch around after your initial round has been merged to the
upstream before you start the second phase. This is especially true
if your topic is designed to apply to an existing release (in other
words, a bugfix) and you want to keep the second and subsequent
rounds of the topic to be applicable to the same target version
without contaminating the topic with irrelevant features from others
that happened to have been developed and merged upstream around the
same time.
And we'd need to cater to their needs. By this, I do not mean "they
do not have to use --prune-merged", but by giving them a way to say
"this branch should not be auto-pruned with --prune-merged". |
dd4da62 to
66dac97
Compare
|
/submit |
|
Submitted as pull.2285.v2.git.git.1777919250.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
Harald Nordgren wrote on the Git mailing list (how to reply to this email): > I do like the feature that allows you to identify which local
> branches are already merged and prune them. It will help users keep
> their local branch namespace clean.
Nice to hear!
> To break the feature down to make it easier to use by our users with
> various needs and workflows, we would benefit from having a
> collection of smaller features that can be composed, like these:
I gave it a shot to implement these, and then I ran it one some local repos
it works really nicely!
Harald |
|
User |
66dac97 to
6462642
Compare
|
Phillip Wood wrote on the Git mailing list (how to reply to this email): Hi Harald
On 17/06/2026 20:11, Harald Nordgren wrote:
>> Right but you sent that version a few hours after I'd posted a partial
>> review which concluded by saying I'd finish it the next day. If you send
>> a new version when you are waiting for further comments it clutters the
>> list because you know you're going to have to post another revision when
>> you get the rest of the comments. Anyone reviewing the interim version
>> is wasting their time. When you receive review comments, by all means
>> start thinking about them and updating your local copy but please don't
>> post a new version until the discussion on the previous version has
>> settled down.
> > That's fair. Sorry about that.
> > Will you let me know when your review here is finished?
I've just sent a mail with another comment but that concudes this round unless you have any questions about it.
> > I received the same feedback from Junio before, so I'm not unaware of
> this problem. I am trying to slow down. I often prepare the work as
> soon as I get some comments -- I'm on paternity leave so I have a lot
> of time when the baby is sleeping -- Congratulations - I hope the baby is sleeping at night as well in the day!
> then I actively hold off on
> sending to not overload the rest of you. But at the same time I think
> it's valuable to keep up a certain pace. It's a balancing act.
It is worth waiting for the discussion to settle on each round, I'll try and be clear when I've finished looking at each revision. I'm sure other folks would appreciate you looking at their patches and commenting on them while you're waiting for feedback on yours, especially the GSoC project students.
Thanks
Phillip |
|
Harald Nordgren wrote on the Git mailing list (how to reply to this email): > > I received the same feedback from Junio before, so I'm not unaware of
> > this problem. I am trying to slow down. I often prepare the work as
> > soon as I get some comments -- I'm on paternity leave so I have a lot
> > of time when the baby is sleeping --
>
> Congratulations - I hope the baby is sleeping at night as well in the day!
Thanks! It's our third, so hopefully we got the hang of it now.
He sleeps -- some of the time.
> > then I actively hold off on
> > sending to not overload the rest of you. But at the same time I think
> > it's valuable to keep up a certain pace. It's a balancing act.
> It is worth waiting for the discussion to settle on each round, I'll try
> and be clear when I've finished looking at each revision. I'm sure other
> folks would appreciate you looking at their patches and commenting on
> them while you're waiting for feedback on yours, especially the GSoC
> project students.
That's a good point!
Harald |
|
There was a status update in the "Cooking" section about the branch "git branch" command learned "--delete-merged" option to remove local branches that have already been merged to the remote-tracking branches they track. Waiting for response(s) to review comment(s). cf. <78b6dfdd-df61-4c44-96eb-b527cb26243c@gmail.com> cf. <f68e2a11-02a5-47b9-a01a-458eba821c37@gmail.com> cf. <37f2a483-c8bf-4c24-84de-c6233cc20b25@gmail.com> cf. <xmqq33yimsdp.fsf@gitster.g> source: <pull.2285.v16.git.git.1781810729.gitgitgadget@gmail.com> |
|
There was a status update in the "Cooking" section about the branch "git branch" command learned "--delete-merged" option to remove local branches that have already been merged to the remote-tracking branches they track. Waiting for response(s) to review comment(s). cf. <cb6fcdfb-67b4-429d-b820-c4e623f28cfa@gmail.com> source: <pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com> |
|
There was a status update in the "Cooking" section about the branch "git branch" command learned "--delete-merged" option to remove local branches that have already been merged to the remote-tracking branches they track. Waiting for response(s) to review comment(s). cf. <cb6fcdfb-67b4-429d-b820-c4e623f28cfa@gmail.com> source: <pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com> |
|
There was a status update in the "Cooking" section about the branch "git branch" command learned "--delete-merged" option to remove local branches that have already been merged to the remote-tracking branches they track. Needs review. source: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com> |
|
There was a status update in the "Cooking" section about the branch "git branch" command learned "--delete-merged" option to remove local branches that have already been merged to the remote-tracking branches they track. Needs review. source: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com> |
|
There was a status update in the "Cooking" section about the branch "git branch" command learned "--delete-merged" option to remove local branches that have already been merged to the remote-tracking branches they track. Needs review. source: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com> |
|
There was a status update in the "Cooking" section about the branch The 'git branch' command has been taught the '--delete-merged' option to remove local branches that have already been merged to the remote- tracking branches they track. Needs review. source: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com> |
|
There was a status update in the "Cooking" section about the branch The 'git branch' command has been taught the '--delete-merged' option to remove local branches that are already merged to their tracked remote-tracking branches. Needs review. source: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com> |
|
Phillip Wood wrote on the Git mailing list (how to reply to this email): Hi Harald
I've looked through the first five patches and left a few comments and queries. I'll look at the last two at the start of next week so don't re-roll just yet, but my all means reply to the comments, especially where I've asked questions. So far I like what I've seen.
Thanks
Phillip
On 24/06/2026 22:54, Harald Nordgren via GitGitGadget wrote:
> Delete branches that have already been merged on upstream.
> > 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 <branch>
> branch: add branch.<name>.deleteMerged opt-out
> branch: add --dry-run for --delete-merged
> > Documentation/config/branch.adoc | 7 +
> Documentation/git-branch.adoc | 48 ++++-
> builtin/branch.c | 266 +++++++++++++++++++++---
> ref-filter.c | 70 +++++++
> ref-filter.h | 10 +
> t/t3200-branch.sh | 342 +++++++++++++++++++++++++++++++
> 6 files changed, 715 insertions(+), 28 deletions(-)
> > > base-commit: ab776a62a78576513ee121424adb19597fbb7613
> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v18
> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v18
> Pull-Request: https://github.com/git/git/pull/2285
> > Range-diff vs v17:
> > 1: d8cc17bd7f = 1: 3e29ff17bd branch: add --forked filter for --list mode
> 2: d14b0403f0 = 2: cdd4fea4a7 branch: convert delete_branches() to a flags argument
> 3: ef2719dac3 = 3: a0fd5b4a6c branch: let delete_branches skip unmerged branches on bulk refusal
> 4: 80518f5d11 = 4: a56d8fe93e branch: prepare delete_branches for a bulk caller
> 5: 46da7c8140 ! 5: a84c555d99 branch: add --delete-merged <branch>
> @@ Commit message
> upstream. The work has already landed on the upstream they track,
> so the local copy is no longer needed.
> > - Three kinds of branches are not deleted:
> + A branch is not deleted when:
> > - * any branch checked out in any worktree
> - * any branch whose upstream remote-tracking branch no longer
> - exists, since a missing upstream is not by itself a sign of
> - integration
> - * any branch whose push destination equals its upstream
> - (<branch>@{push} is the same as <branch>@{upstream}), such as
> - a local "main" that tracks and pushes to "origin/main". Right
> - after a pull it just looks "fully merged", so it is kept. Only
> - branches that push somewhere other than their upstream,
> - typically topics in a fork workflow, are candidates.
> + * it is checked out in any worktree
> + * its upstream remote-tracking branch no longer exists, since a
> + missing upstream is not by itself a sign of integration
> + * its push destination equals its upstream (<branch>@{push} is
> + the same as <branch>@{upstream}), such as a local "main" that
> + tracks and pushes to "origin/main". Right after a pull it just
> + looks "fully merged", so it is kept. Only branches that push
> + somewhere other than their upstream, typically topics in a fork
> + workflow, are candidates.
> > A branch whose work is not yet merged into its upstream is silently
> skipped, so one unmerged topic does not abort the whole sweep.
> > A branch that another, surviving branch tracks as its upstream is
> also kept, so a branch is never deleted out from under one stacked
> - on top of it. Sparing such a base can in turn protect its own
> - upstream, so the check repeats until the set stops changing.
> + on top of it. Such a kept branch is itself merged, so when its own
> + upstream is being deleted, clear its now-stale upstream config.
> > Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> > @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
> +silently skipped. Delete it with `git branch -D` if you want to
> +remove it anyway.
> ++
> -+A branch that another, surviving branch still tracks as its upstream
> -+is kept, so a branch is never deleted out from under one stacked on
> -+top of it.
> ++A branch that another, surviving branch tracks as its upstream is
> ++kept, so a branch is never deleted out from under one stacked on top
> ++of it. If that kept branch in turn tracks a branch that is being
> ++deleted, its now-stale upstream configuration is cleared.
> +
> `-v`::
> `-vv`::
> @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
> return 0;
> }
> > -+static int collect_upstream(const struct reference *ref, void *cb_data)
> -+{
> -+ struct string_list *upstreams = cb_data;
> -+ struct branch *branch = branch_get(ref->name);
> -+ const char *upstream = branch_get_upstream(branch, NULL);
> ++struct spare_data {
> ++ struct strset *deletable;
> ++ struct strset *spared;
> ++};
> +
> -+ string_list_append(upstreams, ref->name)->util =
> -+ xstrdup_or_null(upstream);
> ++/*
> ++ * A surviving branch stacked on a deletion candidate would lose its
> ++ * upstream, so drop that candidate from the delete set and remember it
> ++ * in "spared" so its own upstream can be tidied up afterwards.
> ++ */
> ++static int spare_stacked_base(const struct reference *ref, void *cb_data)
> ++{
> ++ struct spare_data *data = cb_data;
> ++ struct branch *branch;
> ++ const char *upstream, *up_short;
> ++
> ++ if (strset_contains(data->deletable, ref->name))
> ++ return 0;
> ++ branch = branch_get(ref->name);
> ++ upstream = branch_get_upstream(branch, NULL);
> ++ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
> ++ !strset_contains(data->deletable, up_short))
> ++ return 0;
> ++
> ++ strset_remove(data->deletable, up_short);
> ++ strset_add(data->spared, up_short);
> + return 0;
> +}
> +
> +/*
> -+ * Keep any branch that another, surviving branch tracks as its
> -+ * upstream, so we never delete a branch out from under one stacked on
> -+ * top of it. Sparing a branch makes it a survivor whose own upstream
> -+ * then needs the same protection, so repeat until nothing changes.
> ++ * Keep any branch that a surviving branch tracks as its upstream, so we
> ++ * never delete a branch out from under one stacked on top of it. Such a
> ++ * base is itself merged, so when its own upstream is also going away
> ++ * (no surviving branch tracks it), clear the base's now-stale upstream.
> + */
> +static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
> +{
> -+ struct string_list upstreams = STRING_LIST_INIT_DUP;
> -+ struct string_list_item *item;
> -+ bool spared;
> -+
> -+ refs_for_each_branch_ref(refs, collect_upstream, &upstreams);
> -+ do {
> -+ spared = false;
> -+ for_each_string_list_item(item, &upstreams) {
> -+ const char *up = item->util, *up_short;
> -+
> -+ if (!up || strset_contains(deletable, item->string))
> -+ continue;
> -+ if (!skip_prefix(up, "refs/heads/", &up_short) ||
> -+ !strset_contains(deletable, up_short))
> -+ continue;
> -+
> -+ strset_remove(deletable, up_short);
> -+ spared = true;
> -+ }
> -+ } while (spared);
> -+
> -+ string_list_clear(&upstreams, 1);
> ++ struct strset spared = STRSET_INIT;
> ++ struct spare_data data = { .deletable = deletable, .spared = &spared };
> ++ struct strbuf key = STRBUF_INIT;
> ++ struct hashmap_iter iter;
> ++ struct strmap_entry *entry;
> ++
> ++ refs_for_each_branch_ref(refs, spare_stacked_base, &data);
> ++
> ++ strset_for_each_entry(&spared, &iter, entry) {
> ++ struct branch *branch = branch_get(entry->key);
> ++ const char *upstream = branch_get_upstream(branch, NULL);
> ++ const char *up_short;
> ++
> ++ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
> ++ !strset_contains(deletable, up_short))
> ++ continue;
> ++
> ++ strbuf_reset(&key);
> ++ strbuf_addf(&key, "branch.%s.merge", branch->name);
> ++ repo_config_set_gently(the_repository, key.buf, NULL);
> ++ strbuf_reset(&key);
> ++ strbuf_addf(&key, "branch.%s.remote", branch->name);
> ++ repo_config_set_gently(the_repository, key.buf, NULL);
> ++ }
> ++
> ++ strbuf_release(&key);
> ++ strset_clear(&spared);
> +}
> +
> +static int delete_merged_branches(int argc, const char **argv,
> @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
> + struct ref_array candidates = { 0 };
> + struct strset deletable = STRSET_INIT;
> + struct strvec to_delete = STRVEC_INIT;
> ++ struct hashmap_iter iter;
> ++ struct strmap_entry *entry;
> + int i, ret = 0;
> +
> + if (!argc)
> @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
> +
> + spare_stacked_bases(refs, &deletable);
> +
> -+ for (i = 0; i < candidates.nr; i++) {
> -+ const char *short_name;
> -+
> -+ if (skip_prefix(candidates.items[i]->refname, "refs/heads/",
> -+ &short_name) &&
> -+ strset_contains(&deletable, short_name))
> -+ strvec_push(&to_delete, short_name);
> -+ }
> ++ strset_for_each_entry(&deletable, &iter, entry)
> ++ strvec_push(&to_delete, entry->key);
> +
> + if (to_delete.nr)
> + ret = delete_branches(to_delete.nr, to_delete.v,
> @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
> + git checkout --detach
> + ) &&
> +
> ++ git -C repo branch --dry-run --delete-merged origin/next >out &&
> ++ test_grep ! "feature" out &&
> ++
> + git -C repo branch --delete-merged origin/next 2>err &&
> +
> + test_must_be_empty err &&
> + git -C repo rev-parse --verify refs/heads/feature &&
> -+ git -C repo rev-parse --verify refs/heads/topic
> ++ git -C repo rev-parse --verify refs/heads/topic &&
> ++ echo origin/next >expect &&
> ++ git -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&
> ++ test_cmp expect actual &&
> ++ echo feature >expect &&
> ++ git -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&
> ++ test_cmp expect actual
> +'
> +
> +test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '
> @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
> + EOF
> + test_cmp expect actual
> +'
> ++
> ++test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '
> ++ test_when_finished "rm -rf repo" &&
> ++ setup_repo_for_delete_merged &&
> ++ (
> ++ cd repo &&
> ++ git branch lower origin/next &&
> ++ git branch --set-upstream-to=origin/next lower &&
> ++ git branch mid origin/next &&
> ++ git branch --set-upstream-to=lower mid &&
> ++ git checkout -b tip mid &&
> ++ git commit --allow-empty -m "tip work" &&
> ++ git branch --set-upstream-to=mid tip &&
> ++ git checkout --detach
> ++ ) &&
> ++
> ++ git -C repo branch --delete-merged origin/next lower &&
> ++
> ++ test_must_fail git -C repo rev-parse --verify refs/heads/lower &&
> ++ git -C repo rev-parse --verify refs/heads/mid &&
> ++ test_must_fail git -C repo rev-parse mid@{upstream} &&
> ++ echo mid >expect &&
> ++ git -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&
> ++ test_cmp expect actual
> ++'
> +
> test_done
> 6: 27903fbb1d ! 6: d52d717b70 branch: add branch.<name>.deleteMerged opt-out
> @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
> struct strset deletable = STRSET_INIT;
> struct strvec to_delete = STRVEC_INIT;
> + struct strbuf key = STRBUF_INIT;
> + struct hashmap_iter iter;
> + struct strmap_entry *entry;
> + bool quiet = flags & DELETE_BRANCH_QUIET;
> int i, ret = 0;
> > @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
> ref_array_clear(&candidates);
> > ## t/t3200-branch.sh ##
> -@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch
> +@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the upstream of a kept base whose ow
> test_cmp expect actual
> '
> > 7: 49c1bcf1fb ! 7: 8d0323f4b3 branch: add --dry-run for --delete-merged
> @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
> > DESCRIPTION
> -----------
> -@@ Documentation/git-branch.adoc: A branch that another, surviving branch still tracks as its upstream
> - is kept, so a branch is never deleted out from under one stacked on
> - top of it.
> +@@ Documentation/git-branch.adoc: kept, so a branch is never deleted out from under one stacked on top
> + of it. If that kept branch in turn tracks a branch that is being
> + deleted, its now-stale upstream configuration is cleared.
> > +`--dry-run`::
> + With `--delete-merged`, print which branches would be
> |
Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master"), a remote name like
"origin" for the branch its origin/HEAD points at, or a shell glob
(e.g. "origin/*"), and may be repeated to widen the filter.
It is an ordinary list filter, so it combines with the others:
git branch --merged origin/main --forked 'origin/*'
lists branches forked from origin that are already merged into
origin/main, and --no-merged inverts the question.
This is the building block for --delete-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
delete_branches() takes separate force and quiet parameters, while check_branch_commit() takes force. The next commits would grow this collection further. Replace them with a single unsigned flags argument and an enum. Test the FORCE and QUIET bits directly from flags at each use site so that mutating or forwarding flags cannot leave cached values stale. No change in behavior. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
|
4796beta 024
…On Fri, Jul 10, 2026, 11:06 PM gitgitgadget-git[bot] < ***@***.***> wrote:
***@***.***[bot]* commented on this pull request.
------------------------------
In builtin/branch.c
<#2285 (comment)>:
> @@ -189,10 +189,16 @@ static int branch_merged(int kind, const char *name,
return merged;
*Harald Nordgren* wrote on the Git mailing list
***@***.***>
(how to reply to this email <https://gitgitgadget.github.io/reply-to-this>
):
> This means we have two sources of truth because we modify "flags" later.> The idea of replacing the old function parameters with local variables> only works if we're not passing the flags variable on to another> function so I think we should replace all instances of "force" and> "quiet" with flags & DELETE_BRANCH_FORCE/QUIET. That way we have a> single source of truth and should avoid any future regressions like the> one we saw in an earlier iteration.
Yes, it makes sense.
I just hope we remember this discussion, so another reviewer doesn't
push in the other direction later because it seems like low-hanging
fruit.
Harald
—
Reply to this email directly, view it on GitHub
<#2285?email_source=notifications&email_token=CGHVZU72C7D7BFDAK4D7FST5EESQVA5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTINRXGM3DKMBWHA3KM4TFMFZW63VKON2WE43DOJUWEZLEUVSXMZLOOSWGM33PORSXEX3DNRUWG2Y#discussion_r3560833447>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/CGHVZUYWAEKA6TSBQICNKIL5EESQVAVCNFSNUABBKJSXA33TNF2G64TZHMZTMNJQGI5US43TOVSTWNBTGQ4TCNZXGAYDDILWAI>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/CGHVZU4Z6Y3DPKIMDTAMWMD5EESQVA5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTINRXGM3DKMBWHA3KM4TFMFZW63VKON2WE43DOJUWEZLEUVSXMZLOOSVGM33PORSXEX3JN5ZQ>
and Android
<https://github.com/notifications/mobile/android/CGHVZUZRWBKCEC5TYB2TURT5EESQVA5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTINRXGM3DKMBWHA3KM4TFMFZW63VKON2WE43DOJUWEZLEUVSXMZLOOSXGM33PORSXEX3BNZSHE33JMQ>.
Download it today!
You are receiving this because you are subscribed to this thread.Message
ID: ***@***.***>
|
Add a skip-unmerged mode to delete_branches() and check_branch_commit() so a bulk caller can silently skip branches that are not fully merged and carry on, rather than erroring with the "use 'git branch -D'" advice that the plain "git branch -d" path emits. Existing callers are unaffected. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
|
There was a status update in the "Cooking" section about the branch The 'git branch' command has been taught the '--delete-merged' option to remove local branches that are already merged to their tracked remote-tracking branches. Expecting a reroll. cf. <CAHwyqnWspUTSnqmkMyXtWuAnENDSzrRLhhUR=Ljtt1xer3tphA@mail.gmail.com> source: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com> |
| @@ -24,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> | |||
| git branch (-c|-C) [<old-branch>] <new-branch> | |||
There was a problem hiding this comment.
Phillip Wood wrote on the Git mailing list (how to reply to this email):
Hi Harald
On 24/06/2026 22:55, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > With --dry-run, --delete-merged prints the local branches it would
> delete, one "Would delete branch <name>" line each, and exits
> without touching any ref. The same filtering applies, so the output
> is exactly the set that the real run would delete.
The same filtering as what? I think something like
"git branch --dry-run --delete-merged ..." prints one line per ref that would be deleted without modifing any refs.
would be sufficient
> --dry-run is only meaningful together with --delete-merged and is
> rejected otherwise.
Good
> @@ -346,13 +348,20 @@ static int delete_branches(int argc, const char **argv, int kinds,
> free(target);
> }
> > - if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
> + if (!dry_run &&
> + refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
> ret = 1;
> > for_each_string_list_item(item, &refs_to_delete) {
> char *describe_ref = item->util;
> char *name = item->string;
> - if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
> + if (dry_run) {
> + if (!quiet)
This matches what we do without '--dry-run' but what use is '--dry-run --quiet' if it does not print anything?
> + printf(remote_branch
> + ? _("Would delete remote-tracking branch %s (was %s).\n")
> + : _("Would delete branch %s (was %s).\n"),
> + name + branch_name_pos, describe_ref);
> + } else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index b7595610d9..cddcde341d 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1892,8 +1892,12 @@ test_expect_success '--delete-merged deletes merged branches and spares the rest
> ) &&
> sha=$(git -C repo rev-parse --short merged) &&
> > - git -C repo branch --delete-merged origin/next >actual 2>&1 &&
> + git -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 &&
> + echo "Would delete branch merged (was $sha)." >expect &&
> + test_cmp expect actual &&
> + git -C repo rev-parse --verify refs/heads/merged &&
> > + git -C repo branch --delete-merged origin/next >actual 2>&1 &&
I was wondering why the diff shows the line above being deleted and then added, it is because previously there was a blank line after it. The test for --dry-run looks good.
> echo "Deleted branch merged (was $sha)." >expect &&
> test_cmp expect actual &&
> git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
> @@ -2050,4 +2054,9 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" '
> test_must_fail git -C repo rev-parse --verify refs/heads/kept
> '
> > +test_expect_success '--dry-run without --delete-merged is rejected' '
> + test_must_fail git -C forked branch --dry-run 2>err &&
> + test_grep "requires --delete-merged" err
Nice
Thanks
PhillipThere was a problem hiding this comment.
Harald Nordgren wrote on the Git mailing list (how to reply to this email):
> This matches what we do without '--dry-run' but what use is '--dry-run
> --quiet' if it does not print anything?
Yeah, it's a bit weird. What do you suggest we do instead, reject the
combination or turn it into a no-op?
Harald| @@ -102,3 +102,10 @@ for details). | |||
| `git branch --edit-description`. Branch description is | |||
There was a problem hiding this comment.
Phillip Wood wrote on the Git mailing list (how to reply to this email):
Hi Harald
On 24/06/2026 22:55, Harald Nordgren via GitGitGadget wrote:
> > diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 047ba54778..b7595610d9 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -2024,4 +2024,30 @@ test_expect_success '--delete-merged clears the upstream of a kept base whose ow
> test_cmp expect actual
> '
> > +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '
> + test_when_finished "rm -rf repo" &&
> + setup_repo_for_delete_merged &&
> + merged_branch deleted origin/next &&
> + merged_branch kept origin/next &&
> + git -C repo config branch.kept.deleteMerged false &&
> + git -C repo checkout --detach &&
> +
> + git -C repo branch --delete-merged origin/next 2>err &&
> +
> + test_grep "Skipping .kept." err &&
> + test_must_fail git -C repo rev-parse --verify refs/heads/deleted &&
> + git -C repo rev-parse --verify refs/heads/kept
As with the previous patches, I think this would be nicer if we checked the output for for-each-ref. Everything else looks fine.
Thanks
Phillip
> +'
> +
> +test_expect_success "branch -d still deletes a deleteMerged=false branch" '
> + test_when_finished "rm -rf repo" &&
> + setup_repo_for_delete_merged &&
> + merged_branch kept origin/next &&
> + git -C repo config branch.kept.deleteMerged false &&
> + git -C repo checkout --detach &&
> +
> + git -C repo branch -d kept &&
> + test_must_fail git -C repo rev-parse --verify refs/heads/kept
> +'
> +
> test_doneThere was a problem hiding this comment.
Harald Nordgren wrote on the Git mailing list (how to reply to this email):
> > +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '
> > + test_when_finished "rm -rf repo" &&
> > + setup_repo_for_delete_merged &&
> > + merged_branch deleted origin/next &&
> > + merged_branch kept origin/next &&
> > + git -C repo config branch.kept.deleteMerged false &&
> > + git -C repo checkout --detach &&
> > +
> > + git -C repo branch --delete-merged origin/next 2>err &&
> > +
> > + test_grep "Skipping .kept." err &&
> > + test_must_fail git -C repo rev-parse --verify refs/heads/deleted &&
> > + git -C repo rev-parse --verify refs/heads/kept
>
> As with the previous patches, I think this would be nicer if we checked
> the output for for-each-ref. Everything else looks fine.
Thanks! Yes, I have already created a helper for it, and it makes it a
lot clearer. Thanks again for a good suggestion.
HaraldTeach delete_branches() a new mode for the upcoming --delete-merged caller that checks whether a branch is merged into its upstream without falling back to HEAD when there is no upstream. Existing callers keep their current behavior. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
git branch (--delete-merged <branch>)... [<pattern>...]
deletes local branches matching the optional patterns when their
configured upstream matches one of the --delete-merged arguments and
their tip is reachable from that upstream. The work has already landed
on the upstream they track, so the local copy is no longer needed.
The option can be repeated to widen the upstream match. Keeping the
candidate patterns as positional arguments lets users bound the set of
local branches that may be deleted independently of the upstream
selection.
A branch is not deleted when:
* it is checked out in any worktree
* its configured upstream ref no longer exists, since a missing
upstream is not by itself a sign of integration
* pushing it by name to the remote configured by
branch.<name>.remote would update its upstream, as determined by
mapping the branch ref through that remote's fetch refspec. For
example, a local "main" that tracks "origin/main" is kept even when
remote.pushDefault names a fork. Right after a pull it merely looks
fully merged.
A branch whose work is not yet merged into its upstream is silently
skipped, so one unmerged topic does not abort the whole sweep.
A branch that another, surviving branch tracks as its upstream is
also kept, so a branch is never deleted out from under one stacked
on top of it. Such a kept branch is itself merged, so when its own
upstream is being deleted, clear its now-stale upstream config.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.deleteMerged=false exempts that branch from "git branch --delete-merged", which is useful for a topic you want to keep developing after an early round of it has been merged upstream. Unless --quiet is given, each skip is reported so the user knows why their topic was kept. Explicit deletion with "git branch -d" still uses the normal merge check and ignores this setting. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
"git branch --dry-run --delete-merged ..." prints one line per ref that would be deleted without modifying refs or branch configuration. --dry-run is only meaningful together with --delete-merged and is rejected otherwise. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
|
/submit |
|
Submitted as pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
There was a status update in the "Cooking" section about the branch The 'git branch' command has been taught the '--delete-merged' option to remove local branches that are already merged to their tracked remote-tracking branches. Needs review. source: <pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com> |
Delete branches that have already been merged on upstream.
Changes in v19:
--delete-mergedas a repeatable upstream selector with optional positional patterns limiting deletion scope.Changes in v18:
Changes in v17:
--delete-mergedno longer deletes a branch out from under one stacked on top of it.--dry-runandbranch.<name>.deleteMergedopt-out fully into their own commits.Changes in v16:
delete_merged_branches()to take anunsigned int flagsargument instead of separatequiet/dry_runbooleans, matchingdelete_branches()strbufacross the skip-config loop (strbuf_resetper iteration, singlestrbuf_releaseafter) instead of allocating and freeing it each time--delete-mergedtests 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 viatest_cmpreposet up by asetup_repo_for_delete_mergedhelper, and rename helpers off the oldpm_/prunenaming( cd ... )subshells instead of prefixing every command with-CChanges in v15:
final, but something to advance the discussion.
warning.
stays deferred.
branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the
unreachable non-branch ref, and reworked --delete-merged doc wording.
--forked coverage), renamed the misleading trunk
fixture, and replaced the misnamed detached branch with git checkout
--detach.
Changes in v14:
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.
the derived locals can't disagree.
branches too, hopefully not related
(branch: delete-merged #2285).
Changes in v13:
instead of a post-pass, so non-matching branches are never allocated.
abbreviated upstream), and dropped the old helper machinery, forward
declaration, and string_list in favor of a strvec.
delete_branches()/check_branch_commit() with a single unsigned int flags.
own branch walk.
(e.g. git branch --prune-merged origin/main 'feature*') instead of
repeating the option.
Changes in v12:
options.
a ref or a glob.
Changes in v11:
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.
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.
Changes in v10:
— origin, origin/*, origin/release-- all work. This replaces the
remote-only form and subsumes the old --all-remotes flag, which has been
dropped.
Changes in v9:
is always enforced. Use git branch -D to delete an unmerged branch.
Matches how git branch's other read/safe actions treat --force.
Changes in v8:
upstream
Changes in v7:
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:
branch instead of the candidate's upstream — so the decision no longer
depends on which branch happens to be checked out locally.
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.
threads it through, so --prune-merged --all-remotes measures each
candidate against its own remote rather than a single global reference.
Changes in v5:
Changes in v4:
protected_default_refs set in collect_forked_set.
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).
ref, so a topic branch configured to push to origin/main is never pruned.
(not by upstream alone); spare a branch whose push ref is the remote
default.
Changes in v3:
Changes in v2:
--prune-merged now just calls git branch --prune-merged after fetching.
options are gone, replaced by per-branch opt-out via
branch..pruneMerged.
lives on the given remote (read-only building block).
if their tip is reachable from the upstream tracking ref; --force skips
that safety check.
every configured remote at once.
long-running topic branch) even with --force; doesn't affect explicit git
branch -d.
warning per skipped branch instead of the noisy four-line hint that git
branch -d shows.
--prune-merged.
shrunk since most logic moved.
cc: "Kristoffer Haugsbakk" kristofferhaugsbakk@fastmail.com
cc: Johannes Sixt j6t@kdbg.org
cc: Phillip Wood phillip.wood123@gmail.com