Git development
 help / color / mirror / Atom feed
* [PATCH] branch: let --delete-merged find squash merged branches
@ 2026-09-29  7:30 Harald Nordgren via GitGitGadget
  2026-09-29  7:46 ` Kristoffer Haugsbakk
                   ` (4 more replies)
  0 siblings, 5 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-09-29  7:30 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren, Harald Nordgren

From: Harald Nordgren <haraldnordgren@gmail.com>

Branches merged on GitHub with "Squash and merge" or "Rebase and
merge" are never deleted by "git branch --delete-merged". The upstream
holds a rewritten copy of their work, so their tips are not reachable
from it and they look unmerged forever.

Treat such a branch as merged when some upstream commit since the fork
point contains all of its changes, so that merging the branch into
that commit would change nothing. Name that commit in the output so
the user can see where the work went:

    Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).

The first upstream commit that contains the changes is used, so the
branch is deleted even if upstream later reverted or reworked them.
Nothing is lost, since that commit keeps them in the upstream history.
A branch whose changes only partly landed is kept.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
    branch: let --delete-merged find squash merged branches
    
    Branches merged on GitHub with "Squash and merge" or "Rebase and merge"
    are never deleted by git branch --delete-merged, because their tips are
    not reachable from the upstream. This treats such a branch as merged
    when some upstream commit contains all of its changes, and names that
    commit in the output:
    
    Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
    
    
    After the release of 2.56, I saw people liking the --delete-merged
    feature, but asking for this. A lot of people, me included prefer
    squash-merge and it currently doesn't work with --delete-merged.

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2425%2FHaraldNordgren%2Fbranch-delete-squashed-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2425/HaraldNordgren/branch-delete-squashed-v1
Pull-Request: https://github.com/git/git/pull/2425

 Documentation/git-branch.adoc |  15 +--
 builtin/branch.c              | 183 ++++++++++++++++++++++++++++++++--
 t/t3200-branch.sh             |  74 ++++++++++++++
 3 files changed, 259 insertions(+), 13 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index bfdf459329..0427324de1 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -204,12 +204,15 @@ This option is only applicable in non-verbose mode.
 
 `--delete-merged <pattern>`::
 	Delete local branches whose configured upstream matches
-	_<pattern>_, but only when their tip is reachable from that
-	upstream. In other words, the work on the branch has already
-	landed on the upstream it tracks, so the local copy is no longer
-	needed. _<pattern>_ may name a ref, a remote (using the branch its
-	`HEAD` points at), or a shell-style glob. The option can be
-	repeated to widen the upstream match.
+	_<pattern>_, but only when their work has already landed on that
+	upstream, so the local copy is no longer needed. This is the case
+	when their tip is reachable from the upstream, or when some
+	upstream commit contains all of their changes, as happens after
+	a squash or rebase merge, even if those changes were later
+	reverted. The message for such a branch names the first upstream
+	commit that contains its changes. _<pattern>_ may name a ref, a
+	remote (using the branch its `HEAD` points at), or a shell-style
+	glob. The option can be repeated to widen the upstream match.
 	Optional _<branch-pattern>_ arguments limit which local branches
 	are considered, e.g. `git branch --delete-merged 'origin/*'
 	'topic-*'`.
diff --git a/builtin/branch.c b/builtin/branch.c
index a613148fc7..982a8abe24 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -29,6 +29,13 @@
 #include "help.h"
 #include "advice.h"
 #include "commit-reach.h"
+#include "diff.h"
+#include "diffcore.h"
+#include "hex.h"
+#include "merge-ll.h"
+#include "revision.h"
+#include "tree-walk.h"
+#include "xdiff-interface.h"
 
 static const char * const builtin_branch_usage[] = {
 	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
@@ -236,7 +243,7 @@ static void delete_branch_config(const char *branchname)
 }
 
 static int delete_branches(int argc, const char **argv, int kinds,
-			   unsigned int flags)
+			   unsigned int flags, struct strmap *landed_commits)
 {
 	struct commit *head_rev = NULL;
 	struct object_id oid;
@@ -334,6 +341,8 @@ static int delete_branches(int argc, const char **argv, int kinds,
 		}
 
 		if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
+		    !(landed_commits &&
+		      strmap_contains(landed_commits, bname.buf)) &&
 		    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
 					flags)) {
 			if (!(flags & DELETE_BRANCH_SKIP_UNMERGED))
@@ -357,15 +366,33 @@ static int delete_branches(int argc, const char **argv, int kinds,
 	for_each_string_list_item(item, &refs_to_delete) {
 		char *describe_ref = item->util;
 		char *name = item->string;
+		struct commit *landed = landed_commits ?
+			strmap_get(landed_commits, name + branch_name_pos) : NULL;
+		const char *landed_abbrev = landed ?
+			repo_find_unique_abbrev(the_repository,
+						&landed->object.oid,
+						DEFAULT_ABBREV) : NULL;
+
 		if (flags & DELETE_BRANCH_DRY_RUN) {
-			if (!(flags & DELETE_BRANCH_QUIET))
+			if (flags & DELETE_BRANCH_QUIET)
+				;
+			else if (landed)
+				printf(_("Would delete branch %s (was %s, landed as %s).\n"),
+				       name + branch_name_pos, describe_ref,
+				       landed_abbrev);
+			else
 				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)) {
 			char *refname = name + branch_name_pos;
-			if (!(flags & DELETE_BRANCH_QUIET))
+			if (flags & DELETE_BRANCH_QUIET)
+				;
+			else if (landed)
+				printf(_("Deleted branch %s (was %s, landed as %s).\n"),
+				       refname, describe_ref, landed_abbrev);
+			else
 				printf(remote_branch
 					? _("Deleted remote-tracking branch %s (was %s).\n")
 					: _("Deleted branch %s (was %s).\n"),
@@ -824,6 +851,134 @@ static int branch_pushes_to_upstream(struct branch *branch,
 	return ret;
 }
 
+struct branch_change {
+	char *path;
+	struct object_id base_oid, branch_oid;
+	unsigned short branch_mode;
+};
+
+static void collect_branch_changes(struct commit *base, struct commit *rev,
+				   struct branch_change **changes,
+				   size_t *nr, size_t *alloc)
+{
+	struct diff_options opt;
+
+	repo_diff_setup(the_repository, &opt);
+	opt.flags.recursive = 1;
+	opt.output_format = DIFF_FORMAT_NO_OUTPUT;
+	diff_setup_done(&opt);
+	diff_tree_oid(get_commit_tree_oid(base), get_commit_tree_oid(rev),
+		      "", &opt);
+	for (int i = 0; i < diff_queued_diff.nr; i++) {
+		struct diff_filepair *p = diff_queued_diff.queue[i];
+		struct branch_change *change;
+
+		ALLOC_GROW(*changes, *nr + 1, *alloc);
+		change = &(*changes)[(*nr)++];
+		change->path = xstrdup(p->two->path);
+		oidcpy(&change->base_oid, DIFF_FILE_VALID(p->one) ?
+		       &p->one->oid : null_oid(the_hash_algo));
+		oidcpy(&change->branch_oid, DIFF_FILE_VALID(p->two) ?
+		       &p->two->oid : null_oid(the_hash_algo));
+		change->branch_mode = p->two->mode;
+	}
+	diff_flush(&opt);
+}
+
+static int merge_keeps_upstream(const struct branch_change *change,
+				const struct object_id *upstream_oid)
+{
+	mmfile_t base, upstream, branch;
+	mmbuffer_t result = { 0 };
+	int ret;
+
+	read_mmblob(&base, the_repository->objects, &change->base_oid);
+	read_mmblob(&upstream, the_repository->objects, upstream_oid);
+	read_mmblob(&branch, the_repository->objects, &change->branch_oid);
+	ret = ll_merge(&result, change->path, &base, "base",
+		       &upstream, "upstream", &branch, "branch",
+		       the_repository->index, NULL) == LL_MERGE_OK &&
+	      result.size == upstream.size &&
+	      !memcmp(result.ptr, upstream.ptr, upstream.size);
+
+	free(base.ptr);
+	free(upstream.ptr);
+	free(branch.ptr);
+	free(result.ptr);
+	return ret;
+}
+
+static int change_landed(const struct branch_change *change,
+			 struct commit *commit)
+{
+	struct object_id oid;
+	unsigned short mode;
+
+	if (get_tree_entry(the_repository, get_commit_tree_oid(commit),
+			   change->path, &oid, &mode))
+		return is_null_oid(&change->branch_oid);
+	if (oideq(&oid, &change->branch_oid))
+		return mode == change->branch_mode;
+	if (is_null_oid(&change->base_oid) ||
+	    is_null_oid(&change->branch_oid) ||
+	    oideq(&oid, &change->base_oid) ||
+	    mode != change->branch_mode || !S_ISREG(mode))
+		return 0;
+	return merge_keeps_upstream(change, &oid);
+}
+
+static struct commit *find_landed_commit(struct commit *rev,
+					 struct commit *upstream)
+{
+	struct commit_list *merge_bases = NULL;
+	struct branch_change *changes = NULL;
+	size_t changes_nr = 0, changes_alloc = 0;
+	struct commit *commit, *landed = NULL;
+	struct strvec args = STRVEC_INIT;
+	struct rev_info revs;
+
+	if (repo_get_merge_bases(the_repository, upstream, rev,
+				 &merge_bases) < 0)
+		exit(128);
+	if (!merge_bases)
+		return NULL;
+	collect_branch_changes(merge_bases->item, rev, &changes,
+			       &changes_nr, &changes_alloc);
+	commit_list_free(merge_bases);
+	if (!changes_nr)
+		return NULL;
+
+	strvec_pushl(&args, "rev-list", "--reverse",
+		     oid_to_hex(&upstream->object.oid), NULL);
+	strvec_pushf(&args, "^%s", oid_to_hex(&rev->object.oid));
+	strvec_push(&args, "--");
+	for (size_t i = 0; i < changes_nr; i++)
+		strvec_pushf(&args, ":(literal)%s", changes[i].path);
+
+	repo_init_revisions(the_repository, &revs, NULL);
+	setup_revisions_from_strvec(&args, &revs, NULL);
+	if (prepare_revision_walk(&revs))
+		die(_("revision walk setup failed"));
+	while (!landed && (commit = get_revision(&revs))) {
+		size_t i;
+
+		for (i = 0; i < changes_nr; i++)
+			if (!change_landed(&changes[i], commit))
+				break;
+		if (i == changes_nr)
+			landed = commit;
+	}
+	release_revisions(&revs);
+	clear_commit_marks(upstream, ALL_REV_FLAGS);
+	clear_commit_marks(rev, ALL_REV_FLAGS);
+	strvec_clear(&args);
+
+	for (size_t i = 0; i < changes_nr; i++)
+		free(changes[i].path);
+	free(changes);
+	return landed;
+}
+
 static int delete_merged_branches(const struct strvec *upstreams,
 				 const char **argv, unsigned int flags)
 {
@@ -832,6 +987,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
 	struct ref_array candidates = { 0 };
 	struct strset deletable_branch_names = STRSET_INIT;
 	struct strset protected_branch_names = STRSET_INIT;
+	struct strmap landed_commits = STRMAP_INIT;
 	struct strvec branches_to_delete = STRVEC_INIT;
 	struct strbuf key = STRBUF_INIT;
 	struct hashmap_iter iter;
@@ -852,6 +1008,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
 		const char *branch_name;
 		struct branch *branch;
 		const char *upstream_refname;
+		struct commit *landed = NULL;
 		int opt_out;
 
 		if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
@@ -867,8 +1024,17 @@ static int delete_merged_branches(const struct strvec *upstreams,
 			continue;
 		if (check_branch_commit(branch_name, branch_name,
 					&candidates.items[i]->objectname, NULL,
-					FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
-			continue;
+					FILTER_REFS_BRANCHES,
+					DELETE_BRANCH_SKIP_UNMERGED)) {
+			struct commit *rev = lookup_commit_reference(
+				the_repository, &candidates.items[i]->objectname);
+			struct commit *upstream = lookup_commit_reference_by_name(
+				upstream_refname);
+
+			if (!rev || !upstream ||
+			    !(landed = find_landed_commit(rev, upstream)))
+				continue;
+		}
 
 		strbuf_reset(&key);
 		strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
@@ -882,6 +1048,8 @@ static int delete_merged_branches(const struct strvec *upstreams,
 		}
 
 		strset_add(&deletable_branch_names, branch_name);
+		if (landed)
+			strmap_put(&landed_commits, branch_name, landed);
 	}
 
 	protect_stacked_branch_bases(refs, &deletable_branch_names,
@@ -895,7 +1063,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
 				      FILTER_REFS_BRANCHES,
 				      DELETE_BRANCH_SKIP_UNMERGED |
 				      DELETE_BRANCH_NO_HEAD_FALLBACK |
-				      flags);
+				      flags, &landed_commits);
 
 	if (!ret && !(flags & DELETE_BRANCH_DRY_RUN))
 		clear_deleted_upstreams(&protected_branch_names,
@@ -903,6 +1071,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
 
 	strbuf_release(&key);
 	strvec_clear(&branches_to_delete);
+	strmap_clear(&landed_commits, 0);
 	strset_clear(&protected_branch_names);
 	strset_clear(&deletable_branch_names);
 	ref_array_clear(&candidates);
@@ -1135,7 +1304,7 @@ int cmd_branch(int argc,
 			die(_("branch name required"));
 		ret = delete_branches(argc, argv, filter.kind,
 				      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
-				      (quiet ? DELETE_BRANCH_QUIET : 0));
+				      (quiet ? DELETE_BRANCH_QUIET : 0), NULL);
 		goto out;
 	} else if (delete_merged.nr) {
 		ret = delete_merged_branches(&delete_merged, argv,
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index cdb6c6a634..3b05718bab 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1979,6 +1979,80 @@ test_expect_success '--delete-merged deletes only selected merged branches' '
 	)
 '
 
+push_topic () {
+	branch=$1 &&
+	shift &&
+	(
+		cd repo &&
+		git checkout -b "$branch" --track origin/next &&
+		for commit in "$@"
+		do
+			test_commit "$commit" || return 1
+		done &&
+		git push origin "$branch" &&
+		git checkout --detach
+	)
+}
+
+squash_merge_upstream () {
+	(
+		cd upstream &&
+		git checkout next &&
+		git merge --squash "$1" &&
+		git commit -m "Squash merge of $1" &&
+		git checkout main
+	)
+}
+
+test_expect_success '--delete-merged deletes a squash merged branch' '
+	setup_repo_for_delete_merged &&
+	push_topic squashed squashed-one squashed-two &&
+	push_topic partial partial-landed partial-pending &&
+	squash_merge_upstream partial~1 &&
+	squash_merge_upstream squashed &&
+	squash=$(git -C upstream rev-parse --short next) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short squashed) &&
+
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		echo "Deleted branch squashed (was $sha, landed as $squash)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		partial
+		EOF
+	)
+'
+
+test_expect_success '--delete-merged deletes a squash merged branch that was reverted' '
+	setup_repo_for_delete_merged &&
+	push_topic reverted reverted-work &&
+	squash_merge_upstream reverted &&
+	squash=$(git -C upstream rev-parse --short next) &&
+	(
+		cd upstream &&
+		git checkout next &&
+		git revert --no-edit HEAD &&
+		git checkout main
+	) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short reverted) &&
+
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		echo "Deleted branch reverted (was $sha, landed as $squash)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		EOF
+	)
+'
+
 test_expect_success '--delete-merged keeps main despite a different default push remote' '
 	setup_repo_for_delete_merged &&
 	create_merged_branch on-next &&

base-commit: a018953688f1b10bddf91bff8747068f5f4746a4
-- 
gitgitgadget

^ permalink raw reply related	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-09-29  7:30 [PATCH] branch: let --delete-merged find squash merged branches Harald Nordgren via GitGitGadget
@ 2026-09-29  7:46 ` Kristoffer Haugsbakk
  2026-09-29  7:52   ` Harald Nordgren
  2026-09-29 11:26 ` D. Ben Knoble
                   ` (3 subsequent siblings)
  4 siblings, 1 reply; 60+ messages in thread
From: Kristoffer Haugsbakk @ 2026-09-29  7:46 UTC (permalink / raw)
  To: git, GITGITGADGET; +Cc: Harald Nordgren

On Tue, Sep 29, 2026, at 09:30, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> Branches merged on GitHub with "Squash and merge" or "Rebase and
> merge" are never deleted by "git branch --delete-merged". The upstream
> holds a rewritten copy of their work, so their tips are not reachable
> from it and they look unmerged forever.

An example closer to git(1)’s home:

    git merge --squash
    git commit

>[snip]

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-09-29  7:46 ` Kristoffer Haugsbakk
@ 2026-09-29  7:52   ` Harald Nordgren
  2026-09-29  8:08     ` Kristoffer Haugsbakk
  0 siblings, 1 reply; 60+ messages in thread
From: Harald Nordgren @ 2026-09-29  7:52 UTC (permalink / raw)
  To: Kristoffer Haugsbakk; +Cc: git, GITGITGADGET

On Tue, Sep 29, 2026 at 9:47 AM Kristoffer Haugsbakk
<kristofferhaugsbakk@fastmail.com> wrote:
>
> On Tue, Sep 29, 2026, at 09:30, Harald Nordgren via GitGitGadget wrote:
> > From: Harald Nordgren <haraldnordgren@gmail.com>
> >
> > Branches merged on GitHub with "Squash and merge" or "Rebase and
> > merge" are never deleted by "git branch --delete-merged". The upstream
> > holds a rewritten copy of their work, so their tips are not reachable
> > from it and they look unmerged forever.
>
> An example closer to git(1)’s home:
>
>     git merge --squash
>     git commit

True. But likely it opens up the question of _why_ would anyone on
upstream be doing such destructive actions? Well, then the answer is
of course that millions of users (including) me do that via GitHub all
the time.

Maybe I should include both examples in my text.


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-09-29  7:52   ` Harald Nordgren
@ 2026-09-29  8:08     ` Kristoffer Haugsbakk
  2026-09-29 11:18       ` D. Ben Knoble
  0 siblings, 1 reply; 60+ messages in thread
From: Kristoffer Haugsbakk @ 2026-09-29  8:08 UTC (permalink / raw)
  To: Harald Nordgren; +Cc: git, GGG

On Tue, Sep 29, 2026, at 09:52, Harald Nordgren wrote:
> On Tue, Sep 29, 2026 at 9:47 AM Kristoffer Haugsbakk
> <kristofferhaugsbakk@fastmail.com> wrote:
>>
>> On Tue, Sep 29, 2026, at 09:30, Harald Nordgren via GitGitGadget wrote:
>> > From: Harald Nordgren <haraldnordgren@gmail.com>
>> >
>> > Branches merged on GitHub with "Squash and merge" or "Rebase and
>> > merge" are never deleted by "git branch --delete-merged". The upstream
>> > holds a rewritten copy of their work, so their tips are not reachable
>> > from it and they look unmerged forever.
>>
>> An example closer to git(1)’s home:
>>
>>     git merge --squash
>>     git commit
>
> True. But likely it opens up the question of _why_ would anyone on
> upstream be doing such destructive actions? Well, then the answer is
> of course that millions of users (including) me do that via GitHub all
> the time.
>
> Maybe I should include both examples in my text.

My *guess* is that `git merge --squash` inspired the forge squashes.
But the forges popularized it.

The apparent `git merge --squash` approach of using `git log` for
concatenating the commit messages isn’t that nice in my opinion. So I
wonder how much it is used.

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-09-29  8:08     ` Kristoffer Haugsbakk
@ 2026-09-29 11:18       ` D. Ben Knoble
  2026-09-29 13:31         ` Kristoffer Haugsbakk
  0 siblings, 1 reply; 60+ messages in thread
From: D. Ben Knoble @ 2026-09-29 11:18 UTC (permalink / raw)
  To: Kristoffer Haugsbakk; +Cc: Harald Nordgren, git, GGG

On Tue, Sep 29, 2026 at 4:17 AM Kristoffer Haugsbakk
<kristofferhaugsbakk@fastmail.com> wrote:
>
> On Tue, Sep 29, 2026, at 09:52, Harald Nordgren wrote:
> > On Tue, Sep 29, 2026 at 9:47 AM Kristoffer Haugsbakk
> > <kristofferhaugsbakk@fastmail.com> wrote:
> >>
> >> On Tue, Sep 29, 2026, at 09:30, Harald Nordgren via GitGitGadget wrote:
> >> > From: Harald Nordgren <haraldnordgren@gmail.com>
> >> >
> >> > Branches merged on GitHub with "Squash and merge" or "Rebase and
> >> > merge" are never deleted by "git branch --delete-merged". The upstream
> >> > holds a rewritten copy of their work, so their tips are not reachable
> >> > from it and they look unmerged forever.
> >>
> >> An example closer to git(1)’s home:
> >>
> >>     git merge --squash
> >>     git commit
> >
> > True. But likely it opens up the question of _why_ would anyone on
> > upstream be doing such destructive actions? Well, then the answer is
> > of course that millions of users (including) me do that via GitHub all
> > the time.
> >
> > Maybe I should include both examples in my text.
>
> My *guess* is that `git merge --squash` inspired the forge squashes.
> But the forges popularized it.
>
> The apparent `git merge --squash` approach of using `git log` for
> concatenating the commit messages isn’t that nice in my opinion. So I
> wonder how much it is used.

The same behavior is present in GitHub's default squash merge message,
and almost no one I work with bothers to edit it.

It's really sad to lose the opportunity to have good commit messages
when using squash-and-merge on a forge---not because we *cannot*, but
because the defaults do not *encourage* it (and we all know how
defaults affect user behavior!).

Anyway, see https://benknoble.github.io/blog/2024/08/02/github-squash/
for a distillation of my thoughts from working around folks that
squash carelessly.

So, anecdotally: it is used widely due to defaults. Blech.

-- 
D. Ben Knoble

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-09-29  7:30 [PATCH] branch: let --delete-merged find squash merged branches Harald Nordgren via GitGitGadget
  2026-09-29  7:46 ` Kristoffer Haugsbakk
@ 2026-09-29 11:26 ` D. Ben Knoble
  2026-09-29 13:44   ` Harald Nordgren
                     ` (2 more replies)
  2026-10-11  9:41 ` [PATCH v2 0/2] " Harald Nordgren via GitGitGadget
                   ` (2 subsequent siblings)
  4 siblings, 3 replies; 60+ messages in thread
From: D. Ben Knoble @ 2026-09-29 11:26 UTC (permalink / raw)
  To: Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren

Without looking too much further…

On Tue, Sep 29, 2026 at 3:33 AM Harald Nordgren via GitGitGadget
<gitgitgadget@gmail.com> wrote:
>
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> Branches merged on GitHub with "Squash and merge" or "Rebase and
> merge" are never deleted by "git branch --delete-merged". The upstream
> holds a rewritten copy of their work, so their tips are not reachable
> from it and they look unmerged forever.
>
> Treat such a branch as merged when some upstream commit since the fork
> point contains all of its changes, so that merging the branch into
> that commit would change nothing. Name that commit in the output so
> the user can see where the work went:
>
>     Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
>
> The first upstream commit that contains the changes is used, so the
> branch is deleted even if upstream later reverted or reworked them.
> Nothing is lost, since that commit keeps them in the upstream history.
> A branch whose changes only partly landed is kept.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>

…in the rebase case, I would expect something like git-log's
--cherry-mark option (or really the algorithm behind it, git-cherry,
and git-range-diff) to be useful for identifying rebased branches. But
of course even rebase-merged branches can end up with minor
differences (say, a commit was made upstream before that branch was
rebased with an identical change; no conflict occurs, but the new
commit differs from the old by not having that change).

In the squash case, I suppose the best we can do is check that all our
changes were applied at some point between the merge-base and the tip.
There probably won't be any tree-same commits, though maybe a
(premature?) optimization can return early if the trees match exactly.

It looked like you don't distinguish the 2 cases in the code, and I
think that's reasonable: we wouldn't know a priori whether to check
for a rebased series or a squashed commit, so we'd have to run both
checks, and the latter presumably subsumes the former.

Anyway, I can see how this would all be fairly expensive---on one repo
I work in, git-range-diff can be somewhat slow depending on how many
commits are in the range, I think. I don't know if it's worth trying
to state that for folks, though? If we ever make improvements to
performance, we'd have to remember to remove the "this may be slow"
text.

> After the release of 2.56, I saw people liking the --delete-merged
>    feature, but asking for this. A lot of people, me included prefer
>    squash-merge and it currently doesn't work with --delete-merged.

Btw, I wonder if you can share where you saw this? 2.56 was released
so recently I'm (pleasantly) surprised there's already feedback on
this!

-- 
D. Ben Knoble

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-09-29 11:18       ` D. Ben Knoble
@ 2026-09-29 13:31         ` Kristoffer Haugsbakk
  0 siblings, 0 replies; 60+ messages in thread
From: Kristoffer Haugsbakk @ 2026-09-29 13:31 UTC (permalink / raw)
  To: D. Ben Knoble; +Cc: Harald Nordgren, git, GITGITGITGITGADGET

On Tue, Sep 29, 2026, at 13:18, D. Ben Knoble wrote:
> On Tue, Sep 29, 2026 at 4:17 AM Kristoffer Haugsbakk
> <kristofferhaugsbakk@fastmail.com> wrote:
>>
>> On Tue, Sep 29, 2026, at 09:52, Harald Nordgren wrote:
>> > [snip]
>> >
>> > Maybe I should include both examples in my text.
>>
>> My *guess* is that `git merge --squash` inspired the forge squashes.
>> But the forges popularized it.
>>
>> The apparent `git merge --squash` approach of using `git log` for
>> concatenating the commit messages isn’t that nice in my opinion. So I
>> wonder how much it is used.
>
> The same behavior is present in GitHub's default squash merge message,
> and almost no one I work with bothers to edit it.

The asterisk bullet points on GitHub are better than `git merge
--squash`:

    Squashed commit of the following:

    [just `git log` of the commits in the range]

> It's really sad to lose the opportunity to have good commit messages
> when using squash-and-merge on a forge---not because we *cannot*, but
> because the defaults do not *encourage* it (and we all know how
> defaults affect user behavior!).

In my opinion squash merges cannot be implemented in a good way, in a
way that leads to good commits. Fundamentally not. It’s the button to
both squash “oops” and the incremental, valuable commits, resulting in a
blob where even a manually written commit message cannot document all
the changes properly. And the reasons why are laid out in your article,
I think...

>
> Anyway, see https://benknoble.github.io/blog/2024/08/02/github-squash/
> for a distillation of my thoughts from working around folks that
> squash carelessly.
>
> So, anecdotally: it is used widely due to defaults. Blech.

Which is excellent. Thanks for writing it.

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-09-29 11:26 ` D. Ben Knoble
@ 2026-09-29 13:44   ` Harald Nordgren
  2026-09-29 13:52   ` Harald Nordgren
  2026-10-04  9:54   ` Phillip Wood
  2 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren @ 2026-09-29 13:44 UTC (permalink / raw)
  To: D. Ben Knoble; +Cc: Harald Nordgren via GitGitGadget, git

> > After the release of 2.56, I saw people liking the --delete-merged
> >    feature, but asking for this. A lot of people, me included prefer
> >    squash-merge and it currently doesn't work with --delete-merged.
>
> Btw, I wonder if you can share where you saw this? 2.56 was released
> so recently I'm (pleasantly) surprised there's already feedback on
> this!

Reddit thread: https://www.reddit.com/r/git/comments/1wsnrl9/comment/pcncf5t

There was only one person asking to clean up squashed branches. But I
also started thinking about it the other day, when 2.56 drew closer,
and I realized that friends that work in companies using squash merge
won't get any benefit from this.

Blog posts that is drawing attention to this new feature (not
necessary feedbacking on it):

- https://github.blog/open-source/git/highlights-from-git-2-56/
- https://about.gitlab.com/blog/whats-new-in-git-2-56-0/
- https://9to5linux.com/git-2-56-adds-new-options-for-cleaning-up-branches-and-resolving-conflicts
- https://linuxiac.com/git-2-56-released-with-safer-conflict-resolution-and-performance-gains/

Btw, I love squash merge!


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-09-29 11:26 ` D. Ben Knoble
  2026-09-29 13:44   ` Harald Nordgren
@ 2026-09-29 13:52   ` Harald Nordgren
  2026-09-29 15:28     ` D. Ben Knoble
  2026-10-04  9:54   ` Phillip Wood
  2 siblings, 1 reply; 60+ messages in thread
From: Harald Nordgren @ 2026-09-29 13:52 UTC (permalink / raw)
  To: D. Ben Knoble; +Cc: Harald Nordgren via GitGitGadget, git

> Anyway, I can see how this would all be fairly expensive---on one repo
> I work in, git-range-diff can be somewhat slow depending on how many
> commits are in the range, I think. I don't know if it's worth trying
> to state that for folks, though? If we ever make improvements to
> performance, we'd have to remember to remove the "this may be slow"
> text.

It was much slower in my first iterations, so running this on my local
Git repo now does not feel painfully slow. Although slower than
without this feature.

We might hide it behind a feature flag?


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-09-29 13:52   ` Harald Nordgren
@ 2026-09-29 15:28     ` D. Ben Knoble
  2026-09-29 18:34       ` Harald Nordgren
  0 siblings, 1 reply; 60+ messages in thread
From: D. Ben Knoble @ 2026-09-29 15:28 UTC (permalink / raw)
  To: Harald Nordgren; +Cc: Harald Nordgren via GitGitGadget, git

On Tue, Sep 29, 2026 at 9:53 AM Harald Nordgren
<haraldnordgren@gmail.com> wrote:
>
> > Anyway, I can see how this would all be fairly expensive---on one repo
> > I work in, git-range-diff can be somewhat slow depending on how many
> > commits are in the range, I think. I don't know if it's worth trying
> > to state that for folks, though? If we ever make improvements to
> > performance, we'd have to remember to remove the "this may be slow"
> > text.
>
> It was much slower in my first iterations, so running this on my local
> Git repo now does not feel painfully slow. Although slower than
> without this feature.
>
> We might hide it behind a feature flag?

I could imagine wanting a CSV-style value for the option, like
"--delete-merged=squashed,rebased" vs. "--delete-merged=merged"
(current default), or something. But I'd have to think about whether
I'm suggesting that only to work around performance or to also support
actual use cases. Mostly I think people just want to go "gah, delete
merged branches" and not think about it further… hm.

-- 
D. Ben Knoble

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-09-29 15:28     ` D. Ben Knoble
@ 2026-09-29 18:34       ` Harald Nordgren
  0 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren @ 2026-09-29 18:34 UTC (permalink / raw)
  To: D. Ben Knoble; +Cc: Harald Nordgren via GitGitGadget, git

> > We might hide it behind a feature flag?
>
> I could imagine wanting a CSV-style value for the option, like
> "--delete-merged=squashed,rebased" vs. "--delete-merged=merged"
> (current default), or something. But I'd have to think about whether
> I'm suggesting that only to work around performance or to also support
> actual use cases. Mostly I think people just want to go "gah, delete
> merged branches" and not think about it further… hm.

For me, the only reason to hide this would be for performance.

Yes, I think most people (me included) would rather have the simplest
possible command. I'd rather not have to specify the pattern so that
these are to the same:

    git branch --delete-merged
    git branch --delete-merged '*/*'


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-09-29 11:26 ` D. Ben Knoble
  2026-09-29 13:44   ` Harald Nordgren
  2026-09-29 13:52   ` Harald Nordgren
@ 2026-10-04  9:54   ` Phillip Wood
  2026-10-04 22:28     ` Harald Nordgren
  2026-10-08 17:51     ` D. Ben Knoble
  2 siblings, 2 replies; 60+ messages in thread
From: Phillip Wood @ 2026-10-04  9:54 UTC (permalink / raw)
  To: D. Ben Knoble, Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren

On 29/09/2026 12:26, D. Ben Knoble wrote:
> On Tue, Sep 29, 2026 at 3:33 AM Harald Nordgren via GitGitGadget
> <gitgitgadget@gmail.com> wrote:
> 
> …in the rebase case, I would expect something like git-log's
> --cherry-mark option (or really the algorithm behind it, git-cherry,
> and git-range-diff) 

That's what I was expecting as well. It would be worth carefully 
studying the implementation of git-cherry. "git cherry A...B" 
precalculates the patch-ids from the side of the merge base that has the 
fewest commits and then walks the other side to compare them. While it 
is walking the other side I think it also looks at which paths were 
changed to avoid calculating the patch-id for commits that cannot match. 
It also batches fetches the blobs it needs in partial clones.

As far as I can see the implementation here makes a separate upstream 
revision walk for each branch, and recalculates the upstream diffs each 
time which seems less efficient than it could be.
> to be useful for identifying rebased branches. But
> of course even rebase-merged branches can end up with minor
> differences (say, a commit was made upstream before that branch was
> rebased with an identical change; no conflict occurs, but the new
> commit differs from the old by not having that change).

Yes if a branch has been rebased before it is merged it may be altered 
such that we cannot detect it.
> In the squash case, I suppose the best we can do is check that all our
> changes were applied at some point between the merge-base and the tip.
> There probably won't be any tree-same commits, though maybe a
> (premature?) optimization can return early if the trees match exactly.

If we have

(topic)  D - C - B - A
                       \
  (main)    M - Q - P - O -
             \          /
               - - S - -

where M is a squashed merge of topic I think we have

     M^2^{tree} == topic^{tree}
     Merge-base(M^1, M^2) == Merge-base(topic, topic@{upstream})
     $(git rev-list --count --right-only M^1...M^2) == 1

If you know your repository only has squash merges that were not rebased 
it would be a lot more efficient to just look at the trees and 
merge-bases, especially in a blobless clone. Having an option to turn 
off the patch-id based detection would probably be useful in that case.

I think detecting branches that have been squashed and/or rebased is a 
useful improvement, but it needs careful implementation to be efficient 
enough that it is practical in large repositories and I'm unlikely to 
have time to closely review it.

Thanks

Phillip

> It looked like you don't distinguish the 2 cases in the code, and I
> think that's reasonable: we wouldn't know a priori whether to check
> for a rebased series or a squashed commit, so we'd have to run both
> checks, and the latter presumably subsumes the former.
> 
> Anyway, I can see how this would all be fairly expensive---on one repo
> I work in, git-range-diff can be somewhat slow depending on how many
> commits are in the range, I think. I don't know if it's worth trying
> to state that for folks, though? If we ever make improvements to
> performance, we'd have to remember to remove the "this may be slow"
> text.
> 
>> After the release of 2.56, I saw people liking the --delete-merged
>>     feature, but asking for this. A lot of people, me included prefer
>>     squash-merge and it currently doesn't work with --delete-merged.
> 
> Btw, I wonder if you can share where you saw this? 2.56 was released
> so recently I'm (pleasantly) surprised there's already feedback on
> this!
> 


^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-10-04  9:54   ` Phillip Wood
@ 2026-10-04 22:28     ` Harald Nordgren
  2026-10-08 15:25       ` Phillip Wood
  2026-10-08 17:51     ` D. Ben Knoble
  1 sibling, 1 reply; 60+ messages in thread
From: Harald Nordgren @ 2026-10-04 22:28 UTC (permalink / raw)
  To: phillip.wood; +Cc: D. Ben Knoble, Harald Nordgren via GitGitGadget, git

> As far as I can see the implementation here makes a separate upstream
> revision walk for each branch, and recalculates the upstream diffs each
> time which seems less efficient than it could be.

Yes, that can be improved!

> > In the squash case, I suppose the best we can do is check that all our
> > changes were applied at some point between the merge-base and the tip.
> > There probably won't be any tree-same commits, though maybe a
> > (premature?) optimization can return early if the trees match exactly.
...
> If you know your repository only has squash merges that were not rebased
> it would be a lot more efficient to just look at the trees and
> merge-bases, especially in a blobless clone. Having an option to turn
> off the patch-id based detection would probably be useful in that case.

Maybe yes, but for users I imagine they want the interface to be as
simple as possible.

I'm iterating on the code on my side (sharing logic between branches,
etc) and it became fast on my local Git repo. If there are no
performance concerns then would we still want the option to turn it
off?


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-10-04 22:28     ` Harald Nordgren
@ 2026-10-08 15:25       ` Phillip Wood
  2026-10-08 18:42         ` Harald Nordgren
  0 siblings, 1 reply; 60+ messages in thread
From: Phillip Wood @ 2026-10-08 15:25 UTC (permalink / raw)
  To: Harald Nordgren, phillip.wood
  Cc: D. Ben Knoble, Harald Nordgren via GitGitGadget, git

Hi Harald

On 04/10/2026 23:28, Harald Nordgren wrote:
> ...
>> If you know your repository only has squash merges that were not rebased
>> it would be a lot more efficient to just look at the trees and
>> merge-bases, especially in a blobless clone. Having an option to turn
>> off the patch-id based detection would probably be useful in that case.
> 
> Maybe yes, but for users I imagine they want the interface to be as
> simple as possible.

Agreed, they also don't want to wait to find squashed merges they know 
they do not have.

> I'm iterating on the code on my side (sharing logic between branches,
> etc) and it became fast on my local Git repo. If there are no
> performance concerns then would we still want the option to turn it
> off?

Even an efficient implementation is going to be a lot slower when it is 
trying to find branches that have been squashed, so I think we probably 
do want a way to turn it off. That's especially true in partial clones 
where we'll have to download a bunch of blobs to do the squash 
detection. So long as it isn't diabolically slow enabling it by default 
is probably fine.

Thanks

Phillip


^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-10-04  9:54   ` Phillip Wood
  2026-10-04 22:28     ` Harald Nordgren
@ 2026-10-08 17:51     ` D. Ben Knoble
  2026-10-09 13:34       ` Phillip Wood
  1 sibling, 1 reply; 60+ messages in thread
From: D. Ben Knoble @ 2026-10-08 17:51 UTC (permalink / raw)
  To: phillip.wood; +Cc: Harald Nordgren via GitGitGadget, git, Harald Nordgren

On Sun, Oct 4, 2026 at 5:54 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
>
> On 29/09/2026 12:26, D. Ben Knoble wrote:
> > On Tue, Sep 29, 2026 at 3:33 AM Harald Nordgren via GitGitGadget
> > <gitgitgadget@gmail.com> wrote:
> >
> > …in the rebase case, I would expect something like git-log's
> > --cherry-mark option (or really the algorithm behind it, git-cherry,
> > and git-range-diff)
>
> That's what I was expecting as well. It would be worth carefully
> studying the implementation of git-cherry. "git cherry A...B"
> precalculates the patch-ids from the side of the merge base that has the
> fewest commits and then walks the other side to compare them. While it
> is walking the other side I think it also looks at which paths were
> changed to avoid calculating the patch-id for commits that cannot match.
> It also batches fetches the blobs it needs in partial clones.
>
> As far as I can see the implementation here makes a separate upstream
> revision walk for each branch, and recalculates the upstream diffs each
> time which seems less efficient than it could be.

Thanks for spelling that out!

> > to be useful for identifying rebased branches. But
> > of course even rebase-merged branches can end up with minor
> > differences (say, a commit was made upstream before that branch was
> > rebased with an identical change; no conflict occurs, but the new
> > commit differs from the old by not having that change).
>
> Yes if a branch has been rebased before it is merged it may be altered
> such that we cannot detect it.

Yep. I'm not sure what Harald (or we) would want to do here.
git-range-diff has trouble detecting these scenarios today, so maybe
matching that and later finding a way to improve is ok.

> > In the squash case, I suppose the best we can do is check that all our
> > changes were applied at some point between the merge-base and the tip.
> > There probably won't be any tree-same commits, though maybe a
> > (premature?) optimization can return early if the trees match exactly.
>
> If we have
>
> (topic)  D - C - B - A
>                        \
>   (main)    M - Q - P - O -
>              \          /
>                - - S - -
>
> where M is a squashed merge of topic I think we have
>
>      M^2^{tree} == topic^{tree}
>      Merge-base(M^1, M^2) == Merge-base(topic, topic@{upstream})
>      $(git rev-list --count --right-only M^1...M^2) == 1

Perhaps we are thinking of 2 different things? In practice when I see
a merge created using GitHub's squash and merge option (and, I think,
also when using `merge --squash`), there is no second parent. You
could instead just get

    (main) S - Q - P - O

where S is A+B+C+D applied to Q (i.e., closer to a cherry-pick with
--no-commit).

And we know that S is not necessarily tree-same to topic's D, so…?

-- 
D. Ben Knoble

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-10-08 15:25       ` Phillip Wood
@ 2026-10-08 18:42         ` Harald Nordgren
  2026-10-09  8:16           ` Kristoffer Haugsbakk
                             ` (2 more replies)
  0 siblings, 3 replies; 60+ messages in thread
From: Harald Nordgren @ 2026-10-08 18:42 UTC (permalink / raw)
  To: phillip.wood; +Cc: D. Ben Knoble, Harald Nordgren via GitGitGadget, git

> Even an efficient implementation is going to be a lot slower when it is
> trying to find branches that have been squashed, so I think we probably
> do want a way to turn it off. That's especially true in partial clones
> where we'll have to download a bunch of blobs to do the squash
> detection. So long as it isn't diabolically slow enabling it by default
> is probably fine.

A bit slower (depends on how much!) could be worth it for improved
usability. This is not a command that users will run multiple times a
day.

I would hope we could have it on by default and add a flag to turn it
off instead.

For partial or shallow clones, we should probably warn the user that
detection won't be able to search the full history, and offer them to
unshallow, etc. It's very reasonable that some parts are turned off,
when the user doesn't want to hold the complete repo.



Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-10-08 18:42         ` Harald Nordgren
@ 2026-10-09  8:16           ` Kristoffer Haugsbakk
  2026-10-09 11:11             ` Harald Nordgren
  2026-10-09 11:18           ` Harald Nordgren
  2026-10-09 13:37           ` Phillip Wood
  2 siblings, 1 reply; 60+ messages in thread
From: Kristoffer Haugsbakk @ 2026-10-09  8:16 UTC (permalink / raw)
  To: Harald Nordgren, Phillip Wood; +Cc: D. Ben Knoble, git, GGG

On Thu, Oct 8, 2026, at 20:42, Harald Nordgren wrote:
>> Even an efficient implementation is going to be a lot slower when it is
>> trying to find branches that have been squashed, so I think we probably
>> do want a way to turn it off. That's especially true in partial clones
>> where we'll have to download a bunch of blobs to do the squash
>> detection. So long as it isn't diabolically slow enabling it by default
>> is probably fine.
>
> A bit slower (depends on how much!) could be worth it for improved
> usability. This is not a command that users will run multiple times a
> day.

I would really like a solid delete operation that I can run, say,
overnight. I have better hygiene practices today but I didn’t back then,
so things have piled up.

One big cleanup is worth a nightly run. So I don’t care about how slow
it is.

>[snip]

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-10-09  8:16           ` Kristoffer Haugsbakk
@ 2026-10-09 11:11             ` Harald Nordgren
  0 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren @ 2026-10-09 11:11 UTC (permalink / raw)
  To: Kristoffer Haugsbakk; +Cc: Phillip Wood, D. Ben Knoble, git, GGG

On Fri, Oct 9, 2026 at 10:16 AM Kristoffer Haugsbakk
<kristofferhaugsbakk@fastmail.com> wrote:
>
> On Thu, Oct 8, 2026, at 20:42, Harald Nordgren wrote:
> >> Even an efficient implementation is going to be a lot slower when it is
> >> trying to find branches that have been squashed, so I think we probably
> >> do want a way to turn it off. That's especially true in partial clones
> >> where we'll have to download a bunch of blobs to do the squash
> >> detection. So long as it isn't diabolically slow enabling it by default
> >> is probably fine.
> >
> > A bit slower (depends on how much!) could be worth it for improved
> > usability. This is not a command that users will run multiple times a
> > day.
>
> I would really like a solid delete operation that I can run, say,
> overnight. I have better hygiene practices today but I didn’t back then,
> so things have piled up.
>
> One big cleanup is worth a nightly run. So I don’t care about how slow
> it is.

I would love to have a `full-clean` command that bundles
`deleted-merged` and all `prune` commands, and uses sensible defaults
for each one. (I have a hard time believing that it would need to run
overnight anyway).

I have known people who just delete the whole repo on any signs of
trouble. People out there are so scared of Git, so let's give them
sensible defaults to work with.


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-10-08 18:42         ` Harald Nordgren
  2026-10-09  8:16           ` Kristoffer Haugsbakk
@ 2026-10-09 11:18           ` Harald Nordgren
  2026-10-09 13:40             ` Phillip Wood
  2026-10-09 13:37           ` Phillip Wood
  2 siblings, 1 reply; 60+ messages in thread
From: Harald Nordgren @ 2026-10-09 11:18 UTC (permalink / raw)
  To: phillip.wood; +Cc: D. Ben Knoble, Harald Nordgren via GitGitGadget, git

Interesting to note is that merged branches inside our repo change
when Junio signs off on them. So without this functionality they are
not cleaned either.


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-10-08 17:51     ` D. Ben Knoble
@ 2026-10-09 13:34       ` Phillip Wood
  0 siblings, 0 replies; 60+ messages in thread
From: Phillip Wood @ 2026-10-09 13:34 UTC (permalink / raw)
  To: D. Ben Knoble, phillip.wood
  Cc: Harald Nordgren via GitGitGadget, git, Harald Nordgren

Hi Ben

On 08/10/2026 18:51, D. Ben Knoble wrote:
> On Sun, Oct 4, 2026 at 5:54 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
>>
>> If we have
>>
>> (topic)  D - C - B - A
>>                         \
>>    (main)    M - Q - P - O -
>>               \          /
>>                 - - S - -
>>
>> where M is a squashed merge of topic I think we have
>>
>>       M^2^{tree} == topic^{tree}
>>       Merge-base(M^1, M^2) == Merge-base(topic, topic@{upstream})
>>       $(git rev-list --count --right-only M^1...M^2) == 1
> 
> Perhaps we are thinking of 2 different things? In practice when I see
> a merge created using GitHub's squash and merge option (and, I think,
> also when using `merge --squash`), there is no second parent. You
> could instead just get
> 
>      (main) S - Q - P - O
> 
> where S is A+B+C+D applied to Q (i.e., closer to a cherry-pick with
> --no-commit).

Oh, you're right, I'd misremembered how "merge --squash" worked.

> And we know that S is not necessarily tree-same to topic's D, so…?

I agree we need to use patch ids rather than trees in that case.

Thanks

Phillip


^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-10-08 18:42         ` Harald Nordgren
  2026-10-09  8:16           ` Kristoffer Haugsbakk
  2026-10-09 11:18           ` Harald Nordgren
@ 2026-10-09 13:37           ` Phillip Wood
  2026-10-09 15:27             ` Harald Nordgren
  2 siblings, 1 reply; 60+ messages in thread
From: Phillip Wood @ 2026-10-09 13:37 UTC (permalink / raw)
  To: Harald Nordgren, phillip.wood
  Cc: D. Ben Knoble, Harald Nordgren via GitGitGadget, git

Hi Harald

On 08/10/2026 19:42, Harald Nordgren wrote:
>> Even an efficient implementation is going to be a lot slower when it is
>> trying to find branches that have been squashed, so I think we probably
>> do want a way to turn it off. That's especially true in partial clones
>> where we'll have to download a bunch of blobs to do the squash
>> detection. So long as it isn't diabolically slow enabling it by default
>> is probably fine.
> 
> A bit slower (depends on how much!) could be worth it for improved
> usability. This is not a command that users will run multiple times a
> day.
> 
> I would hope we could have it on by default and add a flag to turn it
> off instead.

That's my hope too

> For partial or shallow clones, we should probably warn the user that
> detection won't be able to search the full history, and offer them to
> unshallow, etc. It's very reasonable that some parts are turned off,
> when the user doesn't want to hold the complete repo.
I've not really though much about shallow clones, but for partial clones 
I think it is perfectly reasonable to go and fetch the blobs we need to 
calculate the patch ids and warn in the documentation that it can be slow.

Thanks

Phillip

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-10-09 11:18           ` Harald Nordgren
@ 2026-10-09 13:40             ` Phillip Wood
  0 siblings, 0 replies; 60+ messages in thread
From: Phillip Wood @ 2026-10-09 13:40 UTC (permalink / raw)
  To: Harald Nordgren, phillip.wood
  Cc: D. Ben Knoble, Harald Nordgren via GitGitGadget, git

Hi Harald

On 09/10/2026 12:18, Harald Nordgren wrote:
> Interesting to note is that merged branches inside our repo change
> when Junio signs off on them. So without this functionality they are
> not cleaned either.
That's true for a patched based workflow like the one on this list 
whether or not the maintainer signs off the commits. It is slightly 
different from the squash merge case because you need to look for the 
individual commits upstream, rather than the combined changes from the 
branch.

Thanks

Phillip

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH] branch: let --delete-merged find squash merged branches
  2026-10-09 13:37           ` Phillip Wood
@ 2026-10-09 15:27             ` Harald Nordgren
  0 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren @ 2026-10-09 15:27 UTC (permalink / raw)
  To: phillip.wood; +Cc: D. Ben Knoble, Harald Nordgren via GitGitGadget, git

> I've not really though much about shallow clones, but for partial clones
> I think it is perfectly reasonable to go and fetch the blobs we need to
> calculate the patch ids and warn in the documentation that it can be slow.

Would you say the blobless case should be handled now, or can be left
as a follow-up topic?


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* [PATCH v2 0/2] branch: let --delete-merged find squash merged branches
  2026-09-29  7:30 [PATCH] branch: let --delete-merged find squash merged branches Harald Nordgren via GitGitGadget
  2026-09-29  7:46 ` Kristoffer Haugsbakk
  2026-09-29 11:26 ` D. Ben Knoble
@ 2026-10-11  9:41 ` Harald Nordgren via GitGitGadget
  2026-10-11  9:41   ` [PATCH v2 1/2] " Harald Nordgren via GitGitGadget
                     ` (2 more replies)
  2026-10-21 12:28 ` [PATCH v3 0/3] " Harald Nordgren via GitGitGadget
  2026-10-26  8:33 ` [PATCH v4 0/4] " Harald Nordgren via GitGitGadget
  4 siblings, 3 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-10-11  9:41 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood,
	Harald Nordgren

Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are
never deleted by git branch --delete-merged, because their tips are not
reachable from the upstream. This treats such a branch as merged when some
upstream commit contains all of its changes, and names that commit in the
output:

Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).


Changes in v2:

 * Diff each upstream commit once and share the result between all branches,
   faster than v1.
 * Fix a branch merged with a merge commit being reported as "landed as" the
   merge when another branch was searched first, and add a test for it. Show
   the topic commit itself in "landed as" for work that arrived through a
   merge.
 * Use git merge --squash as the example in the commit message and mention
   GitHub's squash and rebase buttons.
 * Add --no-squashed to skip the squash search.

Harald Nordgren (2):
  branch: let --delete-merged find squash merged branches
  branch: add --no-squashed to skip the squash search

 Documentation/git-branch.adoc |  23 ++-
 builtin/branch.c              | 342 ++++++++++++++++++++++++++++++++--
 t/t3200-branch.sh             | 170 +++++++++++++++++
 3 files changed, 509 insertions(+), 26 deletions(-)


base-commit: 6de20f6092dcf9bdb1c8efe03db4b70c82b423dd
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2425%2FHaraldNordgren%2Fbranch-delete-squashed-v2
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2425/HaraldNordgren/branch-delete-squashed-v2
Pull-Request: https://github.com/git/git/pull/2425

Range-diff vs v1:

 1:  c9ab56d8b2 ! 1:  94a6d23f8b branch: let --delete-merged find squash merged branches
     @@ Metadata
       ## Commit message ##
          branch: let --delete-merged find squash merged branches
      
     -    Branches merged on GitHub with "Squash and merge" or "Rebase and
     -    merge" are never deleted by "git branch --delete-merged". The upstream
     -    holds a rewritten copy of their work, so their tips are not reachable
     -    from it and they look unmerged forever.
     +    A branch that upstream integrated with
     +
     +        git merge --squash topic
     +        git commit
     +
     +    is never deleted by "git branch --delete-merged". The same goes for a
     +    branch that was rebased or cherry-picked onto upstream. The upstream
     +    holds a rewritten copy of its work, so its tip is not reachable from
     +    it and it looks unmerged forever.
     +
     +    This happens all the time on hosting services. The "Squash and merge"
     +    and "Rebase and merge" buttons on GitHub land pull requests this way,
     +    so contributors to such projects collect local branches that
     +    "--delete-merged" never cleans up.
      
          Treat such a branch as merged when some upstream commit since the fork
          point contains all of its changes, so that merging the branch into
     @@ builtin/branch.c
       #include "help.h"
       #include "advice.h"
       #include "commit-reach.h"
     ++#include "commit-slab.h"
      +#include "diff.h"
      +#include "diffcore.h"
      +#include "hex.h"
     @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
       	return ret;
       }
       
     ++enum change_state {
     ++	CHANGE_MISSING,
     ++	CHANGE_LANDED,
     ++	CHANGE_NEEDS_MERGE,
     ++};
     ++
      +struct branch_change {
      +	char *path;
     -+	struct object_id base_oid, branch_oid;
     ++	struct object_id base_oid, branch_oid, upstream_oid;
      +	unsigned short branch_mode;
     ++	enum change_state state;
     ++};
     ++
     ++struct landing_search {
     ++	const char *branch_name;
     ++	struct commit *rev;
     ++	struct commit *upstream;
     ++	struct branch_change *changes;
     ++	size_t changes_nr, changes_alloc;
      +};
      +
     -+static void collect_branch_changes(struct commit *base, struct commit *rev,
     -+				   struct branch_change **changes,
     -+				   size_t *nr, size_t *alloc)
     ++define_commit_slab(changed_paths, struct string_list *);
     ++
     ++static void collect_branch_changes(struct landing_search *search,
     ++				   struct commit *base)
      +{
      +	struct diff_options opt;
      +
     @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
      +	opt.flags.recursive = 1;
      +	opt.output_format = DIFF_FORMAT_NO_OUTPUT;
      +	diff_setup_done(&opt);
     -+	diff_tree_oid(get_commit_tree_oid(base), get_commit_tree_oid(rev),
     -+		      "", &opt);
     ++	diff_tree_oid(get_commit_tree_oid(base),
     ++		      get_commit_tree_oid(search->rev), "", &opt);
      +	for (int i = 0; i < diff_queued_diff.nr; i++) {
      +		struct diff_filepair *p = diff_queued_diff.queue[i];
      +		struct branch_change *change;
      +
     -+		ALLOC_GROW(*changes, *nr + 1, *alloc);
     -+		change = &(*changes)[(*nr)++];
     ++		ALLOC_GROW(search->changes, search->changes_nr + 1,
     ++			   search->changes_alloc);
     ++		change = &search->changes[search->changes_nr++];
      +		change->path = xstrdup(p->two->path);
      +		oidcpy(&change->base_oid, DIFF_FILE_VALID(p->one) ?
      +		       &p->one->oid : null_oid(the_hash_algo));
     @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
      +	diff_flush(&opt);
      +}
      +
     -+static int merge_keeps_upstream(const struct branch_change *change,
     -+				const struct object_id *upstream_oid)
     ++static int merge_keeps_upstream(const struct branch_change *change)
      +{
      +	mmfile_t base, upstream, branch;
      +	mmbuffer_t result = { 0 };
      +	int ret;
      +
      +	read_mmblob(&base, the_repository->objects, &change->base_oid);
     -+	read_mmblob(&upstream, the_repository->objects, upstream_oid);
     ++	read_mmblob(&upstream, the_repository->objects, &change->upstream_oid);
      +	read_mmblob(&branch, the_repository->objects, &change->branch_oid);
      +	ret = ll_merge(&result, change->path, &base, "base",
      +		       &upstream, "upstream", &branch, "branch",
     @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
      +	return ret;
      +}
      +
     -+static int change_landed(const struct branch_change *change,
     -+			 struct commit *commit)
     ++static enum change_state change_state_at(struct branch_change *change,
     ++					 struct commit *commit)
      +{
     -+	struct object_id oid;
      +	unsigned short mode;
      +
      +	if (get_tree_entry(the_repository, get_commit_tree_oid(commit),
     -+			   change->path, &oid, &mode))
     -+		return is_null_oid(&change->branch_oid);
     -+	if (oideq(&oid, &change->branch_oid))
     -+		return mode == change->branch_mode;
     ++			   change->path, &change->upstream_oid, &mode))
     ++		return is_null_oid(&change->branch_oid) ?
     ++			CHANGE_LANDED : CHANGE_MISSING;
     ++	if (oideq(&change->upstream_oid, &change->branch_oid))
     ++		return mode == change->branch_mode ?
     ++			CHANGE_LANDED : CHANGE_MISSING;
      +	if (is_null_oid(&change->base_oid) ||
      +	    is_null_oid(&change->branch_oid) ||
     -+	    oideq(&oid, &change->base_oid) ||
     ++	    oideq(&change->upstream_oid, &change->base_oid) ||
      +	    mode != change->branch_mode || !S_ISREG(mode))
     -+		return 0;
     -+	return merge_keeps_upstream(change, &oid);
     ++		return CHANGE_MISSING;
     ++	return CHANGE_NEEDS_MERGE;
      +}
      +
     -+static struct commit *find_landed_commit(struct commit *rev,
     -+					 struct commit *upstream)
     ++static int changes_landed(struct landing_search *search,
     ++			  struct commit *commit)
     ++{
     ++	for (size_t i = 0; i < search->changes_nr; i++) {
     ++		struct branch_change *change = &search->changes[i];
     ++
     ++		change->state = change_state_at(change, commit);
     ++		if (change->state == CHANGE_MISSING)
     ++			return 0;
     ++	}
     ++	for (size_t i = 0; i < search->changes_nr; i++) {
     ++		struct branch_change *change = &search->changes[i];
     ++
     ++		if (change->state == CHANGE_NEEDS_MERGE &&
     ++		    !merge_keeps_upstream(change))
     ++			return 0;
     ++	}
     ++	return 1;
     ++}
     ++
     ++static struct string_list *changed_paths_of(struct changed_paths *cache,
     ++					    struct diff_options *opt,
     ++					    struct commit *commit)
     ++{
     ++	struct string_list **paths = changed_paths_at(cache, commit);
     ++	struct commit *parent;
     ++
     ++	if (*paths)
     ++		return *paths;
     ++
     ++	CALLOC_ARRAY(*paths, 1);
     ++	string_list_init_dup(*paths);
     ++	parent = commit->parents ? commit->parents->item : NULL;
     ++	if (parent && repo_parse_commit(the_repository, parent))
     ++		die(_("could not parse commit %s"),
     ++		    oid_to_hex(&parent->object.oid));
     ++	diff_tree_oid(parent ? get_commit_tree_oid(parent) : NULL,
     ++		      get_commit_tree_oid(commit), "", opt);
     ++	for (int i = 0; i < diff_queued_diff.nr; i++) {
     ++		struct diff_filepair *p = diff_queued_diff.queue[i];
     ++
     ++		string_list_append(*paths, p->two->path);
     ++	}
     ++	diff_flush(opt);
     ++	string_list_sort(*paths);
     ++	return *paths;
     ++}
     ++
     ++static void free_changed_paths(struct string_list **paths)
     ++{
     ++	if (!*paths)
     ++		return;
     ++	string_list_clear(*paths, 0);
     ++	free(*paths);
     ++}
     ++
     ++static int touches_changes(struct string_list *paths,
     ++			   const struct landing_search *search)
     ++{
     ++	for (size_t i = 0; i < search->changes_nr; i++)
     ++		if (string_list_has_string(paths, search->changes[i].path))
     ++			return 1;
     ++	return 0;
     ++}
     ++
     ++static struct commit *find_landed_commit(struct landing_search *search,
     ++					 struct changed_paths *cache,
     ++					 struct diff_options *opt)
      +{
     -+	struct commit_list *merge_bases = NULL;
     -+	struct branch_change *changes = NULL;
     -+	size_t changes_nr = 0, changes_alloc = 0;
      +	struct commit *commit, *landed = NULL;
      +	struct strvec args = STRVEC_INIT;
      +	struct rev_info revs;
      +
     -+	if (repo_get_merge_bases(the_repository, upstream, rev,
     -+				 &merge_bases) < 0)
     -+		exit(128);
     -+	if (!merge_bases)
     -+		return NULL;
     -+	collect_branch_changes(merge_bases->item, rev, &changes,
     -+			       &changes_nr, &changes_alloc);
     -+	commit_list_free(merge_bases);
     -+	if (!changes_nr)
     -+		return NULL;
     -+
      +	strvec_pushl(&args, "rev-list", "--reverse",
     -+		     oid_to_hex(&upstream->object.oid), NULL);
     -+	strvec_pushf(&args, "^%s", oid_to_hex(&rev->object.oid));
     -+	strvec_push(&args, "--");
     -+	for (size_t i = 0; i < changes_nr; i++)
     -+		strvec_pushf(&args, ":(literal)%s", changes[i].path);
     ++		     oid_to_hex(&search->upstream->object.oid), NULL);
     ++	strvec_pushf(&args, "^%s", oid_to_hex(&search->rev->object.oid));
      +
      +	repo_init_revisions(the_repository, &revs, NULL);
      +	setup_revisions_from_strvec(&args, &revs, NULL);
      +	if (prepare_revision_walk(&revs))
      +		die(_("revision walk setup failed"));
     -+	while (!landed && (commit = get_revision(&revs))) {
     -+		size_t i;
     -+
     -+		for (i = 0; i < changes_nr; i++)
     -+			if (!change_landed(&changes[i], commit))
     -+				break;
     -+		if (i == changes_nr)
     ++	while (!landed && (commit = get_revision(&revs)))
     ++		if (touches_changes(changed_paths_of(cache, opt, commit),
     ++				    search) &&
     ++		    changes_landed(search, commit))
      +			landed = commit;
     -+	}
      +	release_revisions(&revs);
     -+	clear_commit_marks(upstream, ALL_REV_FLAGS);
     -+	clear_commit_marks(rev, ALL_REV_FLAGS);
     ++	clear_commit_marks(search->upstream, ALL_REV_FLAGS);
     ++	clear_commit_marks(search->rev, ALL_REV_FLAGS);
      +	strvec_clear(&args);
     -+
     -+	for (size_t i = 0; i < changes_nr; i++)
     -+		free(changes[i].path);
     -+	free(changes);
      +	return landed;
      +}
     ++
     ++static void find_landed_commits(struct landing_search *searches,
     ++				size_t searches_nr,
     ++				struct strmap *landed_commits)
     ++{
     ++	struct changed_paths cache;
     ++	struct diff_options opt;
     ++
     ++	for (size_t i = 0; i < searches_nr; i++) {
     ++		struct landing_search *search = &searches[i];
     ++		struct commit_list *merge_bases = NULL;
     ++
     ++		if (repo_get_merge_bases(the_repository, search->upstream,
     ++					 search->rev, &merge_bases) < 0)
     ++			exit(128);
     ++		if (merge_bases)
     ++			collect_branch_changes(search, merge_bases->item);
     ++		commit_list_free(merge_bases);
     ++	}
     ++
     ++	repo_diff_setup(the_repository, &opt);
     ++	opt.flags.recursive = 1;
     ++	opt.output_format = DIFF_FORMAT_NO_OUTPUT;
     ++	opt.no_free = 1;
     ++	diff_setup_done(&opt);
     ++	init_changed_paths(&cache);
     ++
     ++	for (size_t i = 0; i < searches_nr; i++) {
     ++		struct commit *landed;
     ++
     ++		if (!searches[i].changes_nr)
     ++			continue;
     ++		landed = find_landed_commit(&searches[i], &cache, &opt);
     ++		if (landed)
     ++			strmap_put(landed_commits, searches[i].branch_name,
     ++				   landed);
     ++	}
     ++
     ++	deep_clear_changed_paths(&cache, free_changed_paths);
     ++	opt.no_free = 0;
     ++	diff_free(&opt);
     ++}
     ++
     ++static int branch_opted_out(struct strbuf *key, const char *branch_name,
     ++			    unsigned int flags)
     ++{
     ++	int opt_out;
     ++
     ++	strbuf_reset(key);
     ++	strbuf_addf(key, "branch.%s.deletemerged", branch_name);
     ++	if (repo_config_get_bool(the_repository, key->buf, &opt_out) ||
     ++	    opt_out)
     ++		return 0;
     ++	if (!(flags & DELETE_BRANCH_QUIET))
     ++		fprintf(stderr,
     ++			_("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
     ++			branch_name, branch_name);
     ++	return 1;
     ++}
      +
       static int delete_merged_branches(const struct strvec *upstreams,
       				 const char **argv, unsigned int flags)
     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
       	struct strset deletable_branch_names = STRSET_INIT;
       	struct strset protected_branch_names = STRSET_INIT;
      +	struct strmap landed_commits = STRMAP_INIT;
     ++	struct landing_search *searches = NULL;
     ++	size_t searches_nr = 0, searches_alloc = 0;
       	struct strvec branches_to_delete = STRVEC_INIT;
       	struct strbuf key = STRBUF_INIT;
       	struct hashmap_iter iter;
     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
       		const char *branch_name;
       		struct branch *branch;
       		const char *upstream_refname;
     -+		struct commit *landed = NULL;
     - 		int opt_out;
     +-		int opt_out;
       
       		if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
     + 			BUG("filter returned non-branch ref '%s'", branch_refname);
      @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
       			continue;
       		if (check_branch_commit(branch_name, branch_name,
       					&candidates.items[i]->objectname, NULL,
      -					FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
      -			continue;
     +-
     +-		strbuf_reset(&key);
     +-		strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
     +-		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
     +-		    !opt_out) {
     +-			if (!(flags & DELETE_BRANCH_QUIET))
     +-				fprintf(stderr,
     +-					_("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
     +-					branch_name, branch_name);
      +					FILTER_REFS_BRANCHES,
      +					DELETE_BRANCH_SKIP_UNMERGED)) {
      +			struct commit *rev = lookup_commit_reference(
     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
      +			struct commit *upstream = lookup_commit_reference_by_name(
      +				upstream_refname);
      +
     -+			if (!rev || !upstream ||
     -+			    !(landed = find_landed_commit(rev, upstream)))
     ++			if (!rev || !upstream)
      +				continue;
     -+		}
     - 
     - 		strbuf_reset(&key);
     - 		strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
     -@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
     ++			ALLOC_GROW(searches, searches_nr + 1, searches_alloc);
     ++			searches[searches_nr++] = (struct landing_search) {
     ++				.branch_name = branch_name,
     ++				.rev = rev,
     ++				.upstream = upstream,
     ++			};
     + 			continue;
       		}
       
     - 		strset_add(&deletable_branch_names, branch_name);
     -+		if (landed)
     -+			strmap_put(&landed_commits, branch_name, landed);
     +-		strset_add(&deletable_branch_names, branch_name);
     ++		if (!branch_opted_out(&key, branch_name, flags))
     ++			strset_add(&deletable_branch_names, branch_name);
     ++	}
     ++
     ++	find_landed_commits(searches, searches_nr, &landed_commits);
     ++	for (size_t i = 0; i < searches_nr; i++) {
     ++		const char *branch_name = searches[i].branch_name;
     ++
     ++		if (strmap_contains(&landed_commits, branch_name) &&
     ++		    !branch_opted_out(&key, branch_name, flags))
     ++			strset_add(&deletable_branch_names, branch_name);
       	}
       
       	protect_stacked_branch_bases(refs, &deletable_branch_names,
     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
       
       	strbuf_release(&key);
       	strvec_clear(&branches_to_delete);
     ++	for (size_t i = 0; i < searches_nr; i++) {
     ++		for (size_t j = 0; j < searches[i].changes_nr; j++)
     ++			free(searches[i].changes[j].path);
     ++		free(searches[i].changes);
     ++	}
     ++	free(searches);
      +	strmap_clear(&landed_commits, 0);
       	strset_clear(&protected_branch_names);
       	strset_clear(&deletable_branch_names);
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
      +test_expect_success '--delete-merged deletes a squash merged branch' '
      +	setup_repo_for_delete_merged &&
      +	push_topic squashed squashed-one squashed-two &&
     -+	push_topic partial partial-landed partial-pending &&
     ++	push_topic partial lands-upstream stays-local &&
      +	squash_merge_upstream partial~1 &&
      +	squash_merge_upstream squashed &&
      +	squash=$(git -C upstream rev-parse --short next) &&
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
      +		EOF
      +	)
      +'
     ++
     ++test_expect_success '--delete-merged deletes a squash merged branch after upstream changed the same file' '
     ++	(
     ++		cd upstream &&
     ++		git checkout next &&
     ++		test_write_lines 1 2 3 4 5 6 7 8 9 10 >shared &&
     ++		git add shared &&
     ++		git commit -m "add shared" &&
     ++		git checkout main
     ++	) &&
     ++	setup_repo_for_delete_merged &&
     ++	(
     ++		cd repo &&
     ++		git checkout -b edits-shared --track origin/next &&
     ++		test_write_lines 1 two 3 4 5 6 7 8 9 10 >shared &&
     ++		git commit -a -m "edit line 2" &&
     ++		git push origin edits-shared &&
     ++		git checkout --detach
     ++	) &&
     ++	(
     ++		cd upstream &&
     ++		git checkout next &&
     ++		test_write_lines 1 2 3 4 five 6 7 8 9 10 >shared &&
     ++		git commit -a -m "edit line 5" &&
     ++		git checkout main
     ++	) &&
     ++	squash_merge_upstream edits-shared &&
     ++	squash=$(git -C upstream rev-parse --short next) &&
     ++	(
     ++		cd repo &&
     ++		git fetch origin &&
     ++		sha=$(git rev-parse --short edits-shared) &&
     ++
     ++		git branch --delete-merged origin/next >actual 2>&1 &&
     ++		echo "Deleted branch edits-shared (was $sha, landed as $squash)." >expect &&
     ++		test_cmp expect actual &&
     ++
     ++		check_branches <<-\EOF
     ++		main
     ++		EOF
     ++	)
     ++'
     ++
     ++test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' '
     ++	setup_repo_for_delete_merged &&
     ++	push_topic a-unmerged unmerged-work &&
     ++	push_topic b-merged merged-work &&
     ++	(
     ++		cd upstream &&
     ++		git checkout next &&
     ++		test_commit upstream-work &&
     ++		git merge --no-ff b-merged &&
     ++		git checkout main
     ++	) &&
     ++	(
     ++		cd repo &&
     ++		git fetch origin &&
     ++		sha=$(git rev-parse --short b-merged) &&
     ++
     ++		git branch --delete-merged origin/next >actual 2>&1 &&
     ++		echo "Deleted branch b-merged (was $sha)." >expect &&
     ++		test_cmp expect actual &&
     ++
     ++		check_branches <<-\EOF
     ++		a-unmerged
     ++		main
     ++		EOF
     ++	)
     ++'
      +
       test_expect_success '--delete-merged keeps main despite a different default push remote' '
       	setup_repo_for_delete_merged &&
 -:  ---------- > 2:  ec88bde252 branch: add --no-squashed to skip the squash search

-- 
gitgitgadget

^ permalink raw reply	[flat|nested] 60+ messages in thread

* [PATCH v2 1/2] branch: let --delete-merged find squash merged branches
  2026-10-11  9:41 ` [PATCH v2 0/2] " Harald Nordgren via GitGitGadget
@ 2026-10-11  9:41   ` Harald Nordgren via GitGitGadget
  2026-10-11  9:41   ` [PATCH v2 2/2] branch: add --no-squashed to skip the squash search Harald Nordgren via GitGitGadget
  2026-10-12  0:30   ` [PATCH v2 0/2] branch: let --delete-merged find squash merged branches Junio C Hamano
  2 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-10-11  9:41 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood,
	Harald Nordgren, Harald Nordgren

From: Harald Nordgren <haraldnordgren@gmail.com>

A branch that upstream integrated with

    git merge --squash topic
    git commit

is never deleted by "git branch --delete-merged". The same goes for a
branch that was rebased or cherry-picked onto upstream. The upstream
holds a rewritten copy of its work, so its tip is not reachable from
it and it looks unmerged forever.

This happens all the time on hosting services. The "Squash and merge"
and "Rebase and merge" buttons on GitHub land pull requests this way,
so contributors to such projects collect local branches that
"--delete-merged" never cleans up.

Treat such a branch as merged when some upstream commit since the fork
point contains all of its changes, so that merging the branch into
that commit would change nothing. Name that commit in the output so
the user can see where the work went:

    Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).

The first upstream commit that contains the changes is used, so the
branch is deleted even if upstream later reverted or reworked them.
Nothing is lost, since that commit keeps them in the upstream history.
A branch whose changes only partly landed is kept.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc |  15 +-
 builtin/branch.c              | 332 ++++++++++++++++++++++++++++++++--
 t/t3200-branch.sh             | 143 +++++++++++++++
 3 files changed, 466 insertions(+), 24 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index bfdf459329..0427324de1 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -204,12 +204,15 @@ This option is only applicable in non-verbose mode.
 
 `--delete-merged <pattern>`::
 	Delete local branches whose configured upstream matches
-	_<pattern>_, but only when their tip is reachable from that
-	upstream. In other words, the work on the branch has already
-	landed on the upstream it tracks, so the local copy is no longer
-	needed. _<pattern>_ may name a ref, a remote (using the branch its
-	`HEAD` points at), or a shell-style glob. The option can be
-	repeated to widen the upstream match.
+	_<pattern>_, but only when their work has already landed on that
+	upstream, so the local copy is no longer needed. This is the case
+	when their tip is reachable from the upstream, or when some
+	upstream commit contains all of their changes, as happens after
+	a squash or rebase merge, even if those changes were later
+	reverted. The message for such a branch names the first upstream
+	commit that contains its changes. _<pattern>_ may name a ref, a
+	remote (using the branch its `HEAD` points at), or a shell-style
+	glob. The option can be repeated to widen the upstream match.
 	Optional _<branch-pattern>_ arguments limit which local branches
 	are considered, e.g. `git branch --delete-merged 'origin/*'
 	'topic-*'`.
diff --git a/builtin/branch.c b/builtin/branch.c
index a613148fc7..513c39763b 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -29,6 +29,14 @@
 #include "help.h"
 #include "advice.h"
 #include "commit-reach.h"
+#include "commit-slab.h"
+#include "diff.h"
+#include "diffcore.h"
+#include "hex.h"
+#include "merge-ll.h"
+#include "revision.h"
+#include "tree-walk.h"
+#include "xdiff-interface.h"
 
 static const char * const builtin_branch_usage[] = {
 	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
@@ -236,7 +244,7 @@ static void delete_branch_config(const char *branchname)
 }
 
 static int delete_branches(int argc, const char **argv, int kinds,
-			   unsigned int flags)
+			   unsigned int flags, struct strmap *landed_commits)
 {
 	struct commit *head_rev = NULL;
 	struct object_id oid;
@@ -334,6 +342,8 @@ static int delete_branches(int argc, const char **argv, int kinds,
 		}
 
 		if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
+		    !(landed_commits &&
+		      strmap_contains(landed_commits, bname.buf)) &&
 		    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
 					flags)) {
 			if (!(flags & DELETE_BRANCH_SKIP_UNMERGED))
@@ -357,15 +367,33 @@ static int delete_branches(int argc, const char **argv, int kinds,
 	for_each_string_list_item(item, &refs_to_delete) {
 		char *describe_ref = item->util;
 		char *name = item->string;
+		struct commit *landed = landed_commits ?
+			strmap_get(landed_commits, name + branch_name_pos) : NULL;
+		const char *landed_abbrev = landed ?
+			repo_find_unique_abbrev(the_repository,
+						&landed->object.oid,
+						DEFAULT_ABBREV) : NULL;
+
 		if (flags & DELETE_BRANCH_DRY_RUN) {
-			if (!(flags & DELETE_BRANCH_QUIET))
+			if (flags & DELETE_BRANCH_QUIET)
+				;
+			else if (landed)
+				printf(_("Would delete branch %s (was %s, landed as %s).\n"),
+				       name + branch_name_pos, describe_ref,
+				       landed_abbrev);
+			else
 				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)) {
 			char *refname = name + branch_name_pos;
-			if (!(flags & DELETE_BRANCH_QUIET))
+			if (flags & DELETE_BRANCH_QUIET)
+				;
+			else if (landed)
+				printf(_("Deleted branch %s (was %s, landed as %s).\n"),
+				       refname, describe_ref, landed_abbrev);
+			else
 				printf(remote_branch
 					? _("Deleted remote-tracking branch %s (was %s).\n")
 					: _("Deleted branch %s (was %s).\n"),
@@ -824,6 +852,251 @@ static int branch_pushes_to_upstream(struct branch *branch,
 	return ret;
 }
 
+enum change_state {
+	CHANGE_MISSING,
+	CHANGE_LANDED,
+	CHANGE_NEEDS_MERGE,
+};
+
+struct branch_change {
+	char *path;
+	struct object_id base_oid, branch_oid, upstream_oid;
+	unsigned short branch_mode;
+	enum change_state state;
+};
+
+struct landing_search {
+	const char *branch_name;
+	struct commit *rev;
+	struct commit *upstream;
+	struct branch_change *changes;
+	size_t changes_nr, changes_alloc;
+};
+
+define_commit_slab(changed_paths, struct string_list *);
+
+static void collect_branch_changes(struct landing_search *search,
+				   struct commit *base)
+{
+	struct diff_options opt;
+
+	repo_diff_setup(the_repository, &opt);
+	opt.flags.recursive = 1;
+	opt.output_format = DIFF_FORMAT_NO_OUTPUT;
+	diff_setup_done(&opt);
+	diff_tree_oid(get_commit_tree_oid(base),
+		      get_commit_tree_oid(search->rev), "", &opt);
+	for (int i = 0; i < diff_queued_diff.nr; i++) {
+		struct diff_filepair *p = diff_queued_diff.queue[i];
+		struct branch_change *change;
+
+		ALLOC_GROW(search->changes, search->changes_nr + 1,
+			   search->changes_alloc);
+		change = &search->changes[search->changes_nr++];
+		change->path = xstrdup(p->two->path);
+		oidcpy(&change->base_oid, DIFF_FILE_VALID(p->one) ?
+		       &p->one->oid : null_oid(the_hash_algo));
+		oidcpy(&change->branch_oid, DIFF_FILE_VALID(p->two) ?
+		       &p->two->oid : null_oid(the_hash_algo));
+		change->branch_mode = p->two->mode;
+	}
+	diff_flush(&opt);
+}
+
+static int merge_keeps_upstream(const struct branch_change *change)
+{
+	mmfile_t base, upstream, branch;
+	mmbuffer_t result = { 0 };
+	int ret;
+
+	read_mmblob(&base, the_repository->objects, &change->base_oid);
+	read_mmblob(&upstream, the_repository->objects, &change->upstream_oid);
+	read_mmblob(&branch, the_repository->objects, &change->branch_oid);
+	ret = ll_merge(&result, change->path, &base, "base",
+		       &upstream, "upstream", &branch, "branch",
+		       the_repository->index, NULL) == LL_MERGE_OK &&
+	      result.size == upstream.size &&
+	      !memcmp(result.ptr, upstream.ptr, upstream.size);
+
+	free(base.ptr);
+	free(upstream.ptr);
+	free(branch.ptr);
+	free(result.ptr);
+	return ret;
+}
+
+static enum change_state change_state_at(struct branch_change *change,
+					 struct commit *commit)
+{
+	unsigned short mode;
+
+	if (get_tree_entry(the_repository, get_commit_tree_oid(commit),
+			   change->path, &change->upstream_oid, &mode))
+		return is_null_oid(&change->branch_oid) ?
+			CHANGE_LANDED : CHANGE_MISSING;
+	if (oideq(&change->upstream_oid, &change->branch_oid))
+		return mode == change->branch_mode ?
+			CHANGE_LANDED : CHANGE_MISSING;
+	if (is_null_oid(&change->base_oid) ||
+	    is_null_oid(&change->branch_oid) ||
+	    oideq(&change->upstream_oid, &change->base_oid) ||
+	    mode != change->branch_mode || !S_ISREG(mode))
+		return CHANGE_MISSING;
+	return CHANGE_NEEDS_MERGE;
+}
+
+static int changes_landed(struct landing_search *search,
+			  struct commit *commit)
+{
+	for (size_t i = 0; i < search->changes_nr; i++) {
+		struct branch_change *change = &search->changes[i];
+
+		change->state = change_state_at(change, commit);
+		if (change->state == CHANGE_MISSING)
+			return 0;
+	}
+	for (size_t i = 0; i < search->changes_nr; i++) {
+		struct branch_change *change = &search->changes[i];
+
+		if (change->state == CHANGE_NEEDS_MERGE &&
+		    !merge_keeps_upstream(change))
+			return 0;
+	}
+	return 1;
+}
+
+static struct string_list *changed_paths_of(struct changed_paths *cache,
+					    struct diff_options *opt,
+					    struct commit *commit)
+{
+	struct string_list **paths = changed_paths_at(cache, commit);
+	struct commit *parent;
+
+	if (*paths)
+		return *paths;
+
+	CALLOC_ARRAY(*paths, 1);
+	string_list_init_dup(*paths);
+	parent = commit->parents ? commit->parents->item : NULL;
+	if (parent && repo_parse_commit(the_repository, parent))
+		die(_("could not parse commit %s"),
+		    oid_to_hex(&parent->object.oid));
+	diff_tree_oid(parent ? get_commit_tree_oid(parent) : NULL,
+		      get_commit_tree_oid(commit), "", opt);
+	for (int i = 0; i < diff_queued_diff.nr; i++) {
+		struct diff_filepair *p = diff_queued_diff.queue[i];
+
+		string_list_append(*paths, p->two->path);
+	}
+	diff_flush(opt);
+	string_list_sort(*paths);
+	return *paths;
+}
+
+static void free_changed_paths(struct string_list **paths)
+{
+	if (!*paths)
+		return;
+	string_list_clear(*paths, 0);
+	free(*paths);
+}
+
+static int touches_changes(struct string_list *paths,
+			   const struct landing_search *search)
+{
+	for (size_t i = 0; i < search->changes_nr; i++)
+		if (string_list_has_string(paths, search->changes[i].path))
+			return 1;
+	return 0;
+}
+
+static struct commit *find_landed_commit(struct landing_search *search,
+					 struct changed_paths *cache,
+					 struct diff_options *opt)
+{
+	struct commit *commit, *landed = NULL;
+	struct strvec args = STRVEC_INIT;
+	struct rev_info revs;
+
+	strvec_pushl(&args, "rev-list", "--reverse",
+		     oid_to_hex(&search->upstream->object.oid), NULL);
+	strvec_pushf(&args, "^%s", oid_to_hex(&search->rev->object.oid));
+
+	repo_init_revisions(the_repository, &revs, NULL);
+	setup_revisions_from_strvec(&args, &revs, NULL);
+	if (prepare_revision_walk(&revs))
+		die(_("revision walk setup failed"));
+	while (!landed && (commit = get_revision(&revs)))
+		if (touches_changes(changed_paths_of(cache, opt, commit),
+				    search) &&
+		    changes_landed(search, commit))
+			landed = commit;
+	release_revisions(&revs);
+	clear_commit_marks(search->upstream, ALL_REV_FLAGS);
+	clear_commit_marks(search->rev, ALL_REV_FLAGS);
+	strvec_clear(&args);
+	return landed;
+}
+
+static void find_landed_commits(struct landing_search *searches,
+				size_t searches_nr,
+				struct strmap *landed_commits)
+{
+	struct changed_paths cache;
+	struct diff_options opt;
+
+	for (size_t i = 0; i < searches_nr; i++) {
+		struct landing_search *search = &searches[i];
+		struct commit_list *merge_bases = NULL;
+
+		if (repo_get_merge_bases(the_repository, search->upstream,
+					 search->rev, &merge_bases) < 0)
+			exit(128);
+		if (merge_bases)
+			collect_branch_changes(search, merge_bases->item);
+		commit_list_free(merge_bases);
+	}
+
+	repo_diff_setup(the_repository, &opt);
+	opt.flags.recursive = 1;
+	opt.output_format = DIFF_FORMAT_NO_OUTPUT;
+	opt.no_free = 1;
+	diff_setup_done(&opt);
+	init_changed_paths(&cache);
+
+	for (size_t i = 0; i < searches_nr; i++) {
+		struct commit *landed;
+
+		if (!searches[i].changes_nr)
+			continue;
+		landed = find_landed_commit(&searches[i], &cache, &opt);
+		if (landed)
+			strmap_put(landed_commits, searches[i].branch_name,
+				   landed);
+	}
+
+	deep_clear_changed_paths(&cache, free_changed_paths);
+	opt.no_free = 0;
+	diff_free(&opt);
+}
+
+static int branch_opted_out(struct strbuf *key, const char *branch_name,
+			    unsigned int flags)
+{
+	int opt_out;
+
+	strbuf_reset(key);
+	strbuf_addf(key, "branch.%s.deletemerged", branch_name);
+	if (repo_config_get_bool(the_repository, key->buf, &opt_out) ||
+	    opt_out)
+		return 0;
+	if (!(flags & DELETE_BRANCH_QUIET))
+		fprintf(stderr,
+			_("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
+			branch_name, branch_name);
+	return 1;
+}
+
 static int delete_merged_branches(const struct strvec *upstreams,
 				 const char **argv, unsigned int flags)
 {
@@ -832,6 +1105,9 @@ static int delete_merged_branches(const struct strvec *upstreams,
 	struct ref_array candidates = { 0 };
 	struct strset deletable_branch_names = STRSET_INIT;
 	struct strset protected_branch_names = STRSET_INIT;
+	struct strmap landed_commits = STRMAP_INIT;
+	struct landing_search *searches = NULL;
+	size_t searches_nr = 0, searches_alloc = 0;
 	struct strvec branches_to_delete = STRVEC_INIT;
 	struct strbuf key = STRBUF_INIT;
 	struct hashmap_iter iter;
@@ -852,7 +1128,6 @@ static int delete_merged_branches(const struct strvec *upstreams,
 		const char *branch_name;
 		struct branch *branch;
 		const char *upstream_refname;
-		int opt_out;
 
 		if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
 			BUG("filter returned non-branch ref '%s'", branch_refname);
@@ -867,21 +1142,35 @@ static int delete_merged_branches(const struct strvec *upstreams,
 			continue;
 		if (check_branch_commit(branch_name, branch_name,
 					&candidates.items[i]->objectname, NULL,
-					FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
-			continue;
-
-		strbuf_reset(&key);
-		strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
-		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
-		    !opt_out) {
-			if (!(flags & DELETE_BRANCH_QUIET))
-				fprintf(stderr,
-					_("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
-					branch_name, branch_name);
+					FILTER_REFS_BRANCHES,
+					DELETE_BRANCH_SKIP_UNMERGED)) {
+			struct commit *rev = lookup_commit_reference(
+				the_repository, &candidates.items[i]->objectname);
+			struct commit *upstream = lookup_commit_reference_by_name(
+				upstream_refname);
+
+			if (!rev || !upstream)
+				continue;
+			ALLOC_GROW(searches, searches_nr + 1, searches_alloc);
+			searches[searches_nr++] = (struct landing_search) {
+				.branch_name = branch_name,
+				.rev = rev,
+				.upstream = upstream,
+			};
 			continue;
 		}
 
-		strset_add(&deletable_branch_names, branch_name);
+		if (!branch_opted_out(&key, branch_name, flags))
+			strset_add(&deletable_branch_names, branch_name);
+	}
+
+	find_landed_commits(searches, searches_nr, &landed_commits);
+	for (size_t i = 0; i < searches_nr; i++) {
+		const char *branch_name = searches[i].branch_name;
+
+		if (strmap_contains(&landed_commits, branch_name) &&
+		    !branch_opted_out(&key, branch_name, flags))
+			strset_add(&deletable_branch_names, branch_name);
 	}
 
 	protect_stacked_branch_bases(refs, &deletable_branch_names,
@@ -895,7 +1184,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
 				      FILTER_REFS_BRANCHES,
 				      DELETE_BRANCH_SKIP_UNMERGED |
 				      DELETE_BRANCH_NO_HEAD_FALLBACK |
-				      flags);
+				      flags, &landed_commits);
 
 	if (!ret && !(flags & DELETE_BRANCH_DRY_RUN))
 		clear_deleted_upstreams(&protected_branch_names,
@@ -903,6 +1192,13 @@ static int delete_merged_branches(const struct strvec *upstreams,
 
 	strbuf_release(&key);
 	strvec_clear(&branches_to_delete);
+	for (size_t i = 0; i < searches_nr; i++) {
+		for (size_t j = 0; j < searches[i].changes_nr; j++)
+			free(searches[i].changes[j].path);
+		free(searches[i].changes);
+	}
+	free(searches);
+	strmap_clear(&landed_commits, 0);
 	strset_clear(&protected_branch_names);
 	strset_clear(&deletable_branch_names);
 	ref_array_clear(&candidates);
@@ -1135,7 +1431,7 @@ int cmd_branch(int argc,
 			die(_("branch name required"));
 		ret = delete_branches(argc, argv, filter.kind,
 				      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
-				      (quiet ? DELETE_BRANCH_QUIET : 0));
+				      (quiet ? DELETE_BRANCH_QUIET : 0), NULL);
 		goto out;
 	} else if (delete_merged.nr) {
 		ret = delete_merged_branches(&delete_merged, argv,
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index cdb6c6a634..adf1bc2233 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1979,6 +1979,149 @@ test_expect_success '--delete-merged deletes only selected merged branches' '
 	)
 '
 
+push_topic () {
+	branch=$1 &&
+	shift &&
+	(
+		cd repo &&
+		git checkout -b "$branch" --track origin/next &&
+		for commit in "$@"
+		do
+			test_commit "$commit" || return 1
+		done &&
+		git push origin "$branch" &&
+		git checkout --detach
+	)
+}
+
+squash_merge_upstream () {
+	(
+		cd upstream &&
+		git checkout next &&
+		git merge --squash "$1" &&
+		git commit -m "Squash merge of $1" &&
+		git checkout main
+	)
+}
+
+test_expect_success '--delete-merged deletes a squash merged branch' '
+	setup_repo_for_delete_merged &&
+	push_topic squashed squashed-one squashed-two &&
+	push_topic partial lands-upstream stays-local &&
+	squash_merge_upstream partial~1 &&
+	squash_merge_upstream squashed &&
+	squash=$(git -C upstream rev-parse --short next) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short squashed) &&
+
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		echo "Deleted branch squashed (was $sha, landed as $squash)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		partial
+		EOF
+	)
+'
+
+test_expect_success '--delete-merged deletes a squash merged branch that was reverted' '
+	setup_repo_for_delete_merged &&
+	push_topic reverted reverted-work &&
+	squash_merge_upstream reverted &&
+	squash=$(git -C upstream rev-parse --short next) &&
+	(
+		cd upstream &&
+		git checkout next &&
+		git revert --no-edit HEAD &&
+		git checkout main
+	) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short reverted) &&
+
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		echo "Deleted branch reverted (was $sha, landed as $squash)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		EOF
+	)
+'
+
+test_expect_success '--delete-merged deletes a squash merged branch after upstream changed the same file' '
+	(
+		cd upstream &&
+		git checkout next &&
+		test_write_lines 1 2 3 4 5 6 7 8 9 10 >shared &&
+		git add shared &&
+		git commit -m "add shared" &&
+		git checkout main
+	) &&
+	setup_repo_for_delete_merged &&
+	(
+		cd repo &&
+		git checkout -b edits-shared --track origin/next &&
+		test_write_lines 1 two 3 4 5 6 7 8 9 10 >shared &&
+		git commit -a -m "edit line 2" &&
+		git push origin edits-shared &&
+		git checkout --detach
+	) &&
+	(
+		cd upstream &&
+		git checkout next &&
+		test_write_lines 1 2 3 4 five 6 7 8 9 10 >shared &&
+		git commit -a -m "edit line 5" &&
+		git checkout main
+	) &&
+	squash_merge_upstream edits-shared &&
+	squash=$(git -C upstream rev-parse --short next) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short edits-shared) &&
+
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		echo "Deleted branch edits-shared (was $sha, landed as $squash)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		EOF
+	)
+'
+
+test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' '
+	setup_repo_for_delete_merged &&
+	push_topic a-unmerged unmerged-work &&
+	push_topic b-merged merged-work &&
+	(
+		cd upstream &&
+		git checkout next &&
+		test_commit upstream-work &&
+		git merge --no-ff b-merged &&
+		git checkout main
+	) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short b-merged) &&
+
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		echo "Deleted branch b-merged (was $sha)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		a-unmerged
+		main
+		EOF
+	)
+'
+
 test_expect_success '--delete-merged keeps main despite a different default push remote' '
 	setup_repo_for_delete_merged &&
 	create_merged_branch on-next &&
-- 
gitgitgadget


^ permalink raw reply related	[flat|nested] 60+ messages in thread

* [PATCH v2 2/2] branch: add --no-squashed to skip the squash search
  2026-10-11  9:41 ` [PATCH v2 0/2] " Harald Nordgren via GitGitGadget
  2026-10-11  9:41   ` [PATCH v2 1/2] " Harald Nordgren via GitGitGadget
@ 2026-10-11  9:41   ` Harald Nordgren via GitGitGadget
  2026-10-12  0:30   ` [PATCH v2 0/2] branch: let --delete-merged find squash merged branches Junio C Hamano
  2 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-10-11  9:41 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood,
	Harald Nordgren, Harald Nordgren

From: Harald Nordgren <haraldnordgren@gmail.com>

Finding branches that were squash or rebase merged makes "git branch
--delete-merged" slower, and in a partial clone it may have to
download blobs. Users who do not need it have no way to skip that
search.

Add --no-squashed, which only deletes branches whose tip is reachable
from their upstream.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc |  8 +++++++-
 builtin/branch.c              | 12 ++++++++++--
 t/t3200-branch.sh             | 27 +++++++++++++++++++++++++++
 3 files changed, 44 insertions(+), 3 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 0427324de1..90960384c4 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
 git branch (-c|-C) [<old-branch>] <new-branch>
 git branch (-d|-D) [-r] <branch-name>...
 git branch --edit-description [<branch-name>]
-git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]
+git branch [--dry-run] [--no-squashed] (--delete-merged <pattern>)... [<branch-pattern>...]
 
 DESCRIPTION
 -----------
@@ -244,6 +244,12 @@ remove it anyway.
 	sanity-checking a wide pattern like `'origin/*'` before
 	committing to the deletion.
 
+`--no-squashed`::
+	With `--delete-merged`, only delete branches whose tip is
+	reachable from their upstream. This skips the search for
+	branches that were squash or rebase merged, which takes longer
+	and may have to download blobs in a partial clone.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 513c39763b..96f10a7546 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -209,6 +209,7 @@ enum delete_branch_flags {
 	DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
 	DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
 	DELETE_BRANCH_DRY_RUN = (1 << 4),
+	DELETE_BRANCH_NO_SQUASHED = (1 << 5),
 };
 
 static int check_branch_commit(const char *branchname, const char *refname,
@@ -1149,7 +1150,8 @@ static int delete_merged_branches(const struct strvec *upstreams,
 			struct commit *upstream = lookup_commit_reference_by_name(
 				upstream_refname);
 
-			if (!rev || !upstream)
+			if ((flags & DELETE_BRANCH_NO_SQUASHED) ||
+			    !rev || !upstream)
 				continue;
 			ALLOC_GROW(searches, searches_nr + 1, searches_alloc);
 			searches[searches_nr++] = (struct landing_search) {
@@ -1272,6 +1274,7 @@ int cmd_branch(int argc,
 	    unset_upstream = 0, show_current = 0, edit_description = 0;
 	struct strvec delete_merged = STRVEC_INIT;
 	int dry_run = 0;
+	int no_squashed = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -1330,6 +1333,8 @@ int cmd_branch(int argc,
 			PARSE_OPT_NONEG, parse_opt_strvec),
 		OPT_BOOL(0, "dry-run", &dry_run,
 			N_("with --delete-merged, only print which branches would be deleted")),
+		OPT_BOOL(0, "no-squashed", &no_squashed,
+			N_("with --delete-merged, skip the search for squash merged branches")),
 		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
 		OPT_MERGED(&filter, N_("print only branches that are merged")),
 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1394,6 +1399,8 @@ int cmd_branch(int argc,
 
 	if (dry_run && !delete_merged.nr)
 		die(_("--dry-run requires --delete-merged"));
+	if (no_squashed && !delete_merged.nr)
+		die(_("--no-squashed requires --delete-merged"));
 
 	if (recurse_submodules_explicit) {
 		if (!submodule_propagate_branches)
@@ -1436,7 +1443,8 @@ int cmd_branch(int argc,
 	} else if (delete_merged.nr) {
 		ret = delete_merged_branches(&delete_merged, argv,
 					     (quiet ? DELETE_BRANCH_QUIET : 0) |
-					     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
+					     (dry_run ? DELETE_BRANCH_DRY_RUN : 0) |
+					     (no_squashed ? DELETE_BRANCH_NO_SQUASHED : 0));
 		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index adf1bc2233..70a38663f1 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2095,6 +2095,28 @@ test_expect_success '--delete-merged deletes a squash merged branch after upstre
 	)
 '
 
+test_expect_success '--delete-merged --no-squashed deletes only reachable branches' '
+	setup_repo_for_delete_merged &&
+	create_merged_branch merged &&
+	push_topic squash-merged squash-merged-work &&
+	squash_merge_upstream squash-merged &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short merged) &&
+
+		git branch --delete-merged origin/next --no-squashed \
+			>actual 2>&1 &&
+		echo "Deleted branch merged (was $sha)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		squash-merged
+		EOF
+	)
+'
+
 test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' '
 	setup_repo_for_delete_merged &&
 	push_topic a-unmerged unmerged-work &&
@@ -2316,6 +2338,11 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" '
 	)
 '
 
+test_expect_success '--no-squashed without --delete-merged is rejected' '
+	test_must_fail git -C forked branch --no-squashed 2>err &&
+	test_grep "requires --delete-merged" err
+'
+
 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
-- 
gitgitgadget

^ permalink raw reply related	[flat|nested] 60+ messages in thread

* Re: [PATCH v2 0/2] branch: let --delete-merged find squash merged branches
  2026-10-11  9:41 ` [PATCH v2 0/2] " Harald Nordgren via GitGitGadget
  2026-10-11  9:41   ` [PATCH v2 1/2] " Harald Nordgren via GitGitGadget
  2026-10-11  9:41   ` [PATCH v2 2/2] branch: add --no-squashed to skip the squash search Harald Nordgren via GitGitGadget
@ 2026-10-12  0:30   ` Junio C Hamano
  2026-10-12  9:50     ` Phillip Wood
  2026-10-12 12:41     ` Harald Nordgren
  2 siblings, 2 replies; 60+ messages in thread
From: Junio C Hamano @ 2026-10-12  0:30 UTC (permalink / raw)
  To: Harald Nordgren via GitGitGadget
  Cc: git, Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood,
	Harald Nordgren

"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:

> Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are
> never deleted by git branch --delete-merged, because their tips are not
> reachable from the upstream. This treats such a branch as merged when some
> upstream commit contains all of its changes, and names that commit in the
> output:
>
> Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).

Interesting.

After rebasing, you probably can (re)use the same algorithm as used
in range-diff to match the commits and their rewritten counterparts,
but I wonder how "squash" would go.  Presumably you can locally make
a trial squash merge and probe for a similar commit on the upstream
commit.

On the other hand, unlike deliberate rebasing done during a patch
iteration, rebase done on the upstream to integrate your changes
would not rewrite the log messages, so matching the commits based on
the log message may work better than a range-diff between iteration
vN and v(N+1) in the submitted patches.

>  * Diff each upstream commit once and share the result between all branches,
>    faster than v1.
>  * Fix a branch merged with a merge commit being reported as "landed as" the
>    merge when another branch was searched first, and add a test for it. Show
>    the topic commit itself in "landed as" for work that arrived through a
>    merge.
>  * Use git merge --squash as the example in the commit message and mention
>    GitHub's squash and rebase buttons.
>  * Add --no-squashed to skip the squash search.
>
> Harald Nordgren (2):
>   branch: let --delete-merged find squash merged branches
>   branch: add --no-squashed to skip the squash search

This a bit unexpected two-patch organization; I would have expected
that the cases where our side and upstream side should correspond
one-to-one (i.e., what you call rebase-and-merge) and other cases
(i.e., squash merge) to require very different matching algorithm,
which would lead to two-patch organization where one adds support
for rebase and the other adds support for squash.


^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v2 0/2] branch: let --delete-merged find squash merged branches
  2026-10-12  0:30   ` [PATCH v2 0/2] branch: let --delete-merged find squash merged branches Junio C Hamano
@ 2026-10-12  9:50     ` Phillip Wood
  2026-10-12 14:31       ` Harald Nordgren
  2026-10-12 16:50       ` Junio C Hamano
  2026-10-12 12:41     ` Harald Nordgren
  1 sibling, 2 replies; 60+ messages in thread
From: Phillip Wood @ 2026-10-12  9:50 UTC (permalink / raw)
  To: Junio C Hamano, Harald Nordgren via GitGitGadget
  Cc: git, Kristoffer Haugsbakk, D. Ben Knoble, Harald Nordgren

On 12/10/2026 01:30, Junio C Hamano wrote:
> "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> 
>> Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are
>> never deleted by git branch --delete-merged, because their tips are not
>> reachable from the upstream. This treats such a branch as merged when some
>> upstream commit contains all of its changes, and names that commit in the
>> output:
>>
>> Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
> 
> Interesting.
> 
> After rebasing, you probably can (re)use the same algorithm as used
> in range-diff to match the commits and their rewritten counterparts,
> but I wonder how "squash" would go.  Presumably you can locally make
> a trial squash merge and probe for a similar commit on the upstream
> commit.
> 
> On the other hand, unlike deliberate rebasing done during a patch
> iteration, rebase done on the upstream to integrate your changes
> would not rewrite the log messages, so matching the commits based on
> the log message may work better than a range-diff between iteration
> vN and v(N+1) in the submitted patches.

I think it would be helpful if the cover letter explained in more detail 
what this series actually does to detect squashed merges and compared 
that to other approaches such as using patch ids or test merges. As far 
as I can see the approach here is, to a first approximation, checking

	$(git merge-tree B C) == C^{tree}

to see if C is a squashed merge of B, only without using merge-ort but 
by calling merge_ll() on each path instead. That means we're not 
checking if C is the same as merging B into C^, C may contain additional 
changes as well. I think it also means we're missing out on rename 
detection. It would be good to get a clear idea of what the tradeoffs of 
the various approaches are before any more code is written.

Thanks

Phillip

>>   * Diff each upstream commit once and share the result between all branches,
>>     faster than v1.
>>   * Fix a branch merged with a merge commit being reported as "landed as" the
>>     merge when another branch was searched first, and add a test for it. Show
>>     the topic commit itself in "landed as" for work that arrived through a
>>     merge.
>>   * Use git merge --squash as the example in the commit message and mention
>>     GitHub's squash and rebase buttons.
>>   * Add --no-squashed to skip the squash search.
>>
>> Harald Nordgren (2):
>>    branch: let --delete-merged find squash merged branches
>>    branch: add --no-squashed to skip the squash search
> 
> This a bit unexpected two-patch organization; I would have expected
> that the cases where our side and upstream side should correspond
> one-to-one (i.e., what you call rebase-and-merge) and other cases
> (i.e., squash merge) to require very different matching algorithm,
> which would lead to two-patch organization where one adds support
> for rebase and the other adds support for squash.


^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v2 0/2] branch: let --delete-merged find squash merged branches
  2026-10-12  0:30   ` [PATCH v2 0/2] branch: let --delete-merged find squash merged branches Junio C Hamano
  2026-10-12  9:50     ` Phillip Wood
@ 2026-10-12 12:41     ` Harald Nordgren
  2026-10-12 15:49       ` D. Ben Knoble
  1 sibling, 1 reply; 60+ messages in thread
From: Harald Nordgren @ 2026-10-12 12:41 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	D. Ben Knoble, Phillip Wood

> After rebasing, you probably can (re)use the same algorithm as used
> in range-diff to match the commits and their rewritten counterparts,
> but I wonder how "squash" would go.  Presumably you can locally make
> a trial squash merge and probe for a similar commit on the upstream
> commit.
>
> On the other hand, unlike deliberate rebasing done during a patch
> iteration, rebase done on the upstream to integrate your changes
> would not rewrite the log messages, so matching the commits based on
> the log message may work better than a range-diff between iteration
> vN and v(N+1) in the submitted patches.

Matching on commit messages sounds very brittle to me (GitHub mangles
them on squash), and also unnecessary when we can compare the files
themselves.

> > Harald Nordgren (2):
> >   branch: let --delete-merged find squash merged branches
> >   branch: add --no-squashed to skip the squash search
>
> This a bit unexpected two-patch organization; I would have expected
> that the cases where our side and upstream side should correspond
> one-to-one (i.e., what you call rebase-and-merge) and other cases
> (i.e., squash merge) to require very different matching algorithm,
> which would lead to two-patch organization where one adds support
> for rebase and the other adds support for squash.

Finding squashes is a superset of finding rebase merges. Although
slower, which is why we need to be able to turn it off. So yes, then
we need separate the code paths for squash and merge finding.

Then I'm thinking 3 commits:

    branch: let --delete-merged find rebase merged branches
    branch: let --delete-merged find squash merged branches
    branch: add --no-squashed to skip the squash merge check


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v2 0/2] branch: let --delete-merged find squash merged branches
  2026-10-12  9:50     ` Phillip Wood
@ 2026-10-12 14:31       ` Harald Nordgren
  2026-10-12 16:50       ` Junio C Hamano
  1 sibling, 0 replies; 60+ messages in thread
From: Harald Nordgren @ 2026-10-12 14:31 UTC (permalink / raw)
  To: phillip.wood
  Cc: Junio C Hamano, Harald Nordgren via GitGitGadget, git,
	Kristoffer Haugsbakk, D. Ben Knoble

> I think it would be helpful if the cover letter explained in more detail
> what this series actually does to detect squashed merges and compared
> that to other approaches such as using patch ids or test merges.

Ok.

> As far
> as I can see the approach here is, to a first approximation, checking
>
>         $(git merge-tree B C) == C^{tree}
>
> to see if C is a squashed merge of B, only without using merge-ort but
> by calling merge_ll() on each path instead. That means we're not
> checking if C is the same as merging B into C^, C may contain additional
> changes as well. I think it also means we're missing out on rename
> detection.

The check is "does C already contain everything B changed", not "is C
exactly the squash of B". For deleting the branch it could be enough
since nothing is lost.

But it means "landed as" can point to a commit with other changes in
it as well, maybe that's not good.

I think following renames is a bad basis for assuming merged.
Sometimes the goal of a topic is a rename, and then it should not be
dropped.


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v2 0/2] branch: let --delete-merged find squash merged branches
  2026-10-12 12:41     ` Harald Nordgren
@ 2026-10-12 15:49       ` D. Ben Knoble
  0 siblings, 0 replies; 60+ messages in thread
From: D. Ben Knoble @ 2026-10-12 15:49 UTC (permalink / raw)
  To: Harald Nordgren
  Cc: Junio C Hamano, Harald Nordgren via GitGitGadget, git,
	Kristoffer Haugsbakk, Phillip Wood

On Mon, Oct 12, 2026 at 8:42 AM Harald Nordgren
<haraldnordgren@gmail.com> wrote:
>
> > After rebasing, you probably can (re)use the same algorithm as used
> > in range-diff to match the commits and their rewritten counterparts,
> > but I wonder how "squash" would go.  Presumably you can locally make
> > a trial squash merge and probe for a similar commit on the upstream
> > commit.
> >
> > On the other hand, unlike deliberate rebasing done during a patch
> > iteration, rebase done on the upstream to integrate your changes
> > would not rewrite the log messages, so matching the commits based on
> > the log message may work better than a range-diff between iteration
> > vN and v(N+1) in the submitted patches.
>
> Matching on commit messages sounds very brittle to me (GitHub mangles
> them on squash), and also unnecessary when we can compare the files
> themselves.

Well, I think Junio was talking about the rebase cases for log
messages, but I have been known to cherry-pick and edit the log
message of small contributions that aren't up to my personal standards
to help contributors land their changes, in which cases you *would*
care more about patch ID than log message.

-- 
D. Ben Knoble

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v2 0/2] branch: let --delete-merged find squash merged branches
  2026-10-12  9:50     ` Phillip Wood
  2026-10-12 14:31       ` Harald Nordgren
@ 2026-10-12 16:50       ` Junio C Hamano
  2026-10-12 17:44         ` Harald Nordgren
  2026-10-18 15:09         ` Harald Nordgren
  1 sibling, 2 replies; 60+ messages in thread
From: Junio C Hamano @ 2026-10-12 16:50 UTC (permalink / raw)
  To: Phillip Wood
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	D. Ben Knoble, Harald Nordgren

Phillip Wood <phillip.wood123@gmail.com> writes:

> detection. It would be good to get a clear idea of what the tradeoffs of 
> the various approaches are before any more code is written.

It sounds like a very sensible approach.

At least the one-to-one part of the operation should learn from what
Dscho did in range-diff, especially the part that does take
similarity of the log messages and authorship information, instead
of paying attention only to patch IDs.  I am not convinced that
one-to-one rebase implemented as a subset of squash merge would
perform sufficiently well, either.  From the user interface angle,
it also is not clear to me if a single --delete-merged that enables
all variants by default with individual knob to optionally disable
one variant (squash) would be a good extensible one, when we may
need to deal with the third (and other) kinds of ways to integrate
your work without preserving the commit identity, like selective
cherry-picks.



^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v2 0/2] branch: let --delete-merged find squash merged branches
  2026-10-12 16:50       ` Junio C Hamano
@ 2026-10-12 17:44         ` Harald Nordgren
  2026-10-13  2:23           ` Junio C Hamano
  2026-10-18 15:09         ` Harald Nordgren
  1 sibling, 1 reply; 60+ messages in thread
From: Harald Nordgren @ 2026-10-12 17:44 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Phillip Wood, Harald Nordgren via GitGitGadget, git,
	Kristoffer Haugsbakk, D. Ben Knoble

> At least the one-to-one part of the operation should learn from what
> Dscho did in range-diff

Oh, that's great! I'll take a look.

> especially the part that does take
> similarity of the log messages and authorship information, instead
> of paying attention only to patch IDs.

Right, authorship would at least be a good filter to speed things up,
can't be the same commit if it has two different authors!


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v2 0/2] branch: let --delete-merged find squash merged branches
  2026-10-12 17:44         ` Harald Nordgren
@ 2026-10-13  2:23           ` Junio C Hamano
  2026-10-17 22:23             ` Harald Nordgren
  2026-10-19  4:40             ` Jasper Mayone
  0 siblings, 2 replies; 60+ messages in thread
From: Junio C Hamano @ 2026-10-13  2:23 UTC (permalink / raw)
  To: Harald Nordgren
  Cc: Phillip Wood, Harald Nordgren via GitGitGadget, git,
	Kristoffer Haugsbakk, D. Ben Knoble

Harald Nordgren <haraldnordgren@gmail.com> writes:

>> At least the one-to-one part of the operation should learn from what
>> Dscho did in range-diff
>
> Oh, that's great! I'll take a look.
>
>> especially the part that does take
>> similarity of the log messages and authorship information, instead
>> of paying attention only to patch IDs.
>
> Right, authorship would at least be a good filter to speed things up,
> can't be the same commit if it has two different authors!

After thinking about it a bit more, I no longer am sure.

It eventually boils down to what we are trying to achieve with this
option, but "I have these pending works I asked upstream to take a
look, I want to see which ones landed and remove them from my local
copy of the project" and "I have these pending works, but upstream
has been collecting improvements from many other people in addition
to me.  I want to see which ones are still relevant" are overlapping
but different.  And I think when you start talking about rebasing
and squash merging, you are asking "is my copy still relevant?"

And the latter is strict superset of the former.

You might have instructed your agent to write a two patch series and
asked upstream to pull.  Somebody else used the same AI-gen and
created one patch that solves the same problem, which happened to
have landed first.  Or that somebody may have sent their solution in
two patches similar to yours.  In either case, your two patch series
is now irrelevant.  You would want to drop your branch that is no
longer relevant, regardless of whose work made it irrelevant,

They may be textually similar enough that we can tell mechanically
that there are already the same or similar code in the place you
were trying to touch.  "--delete-irrelevant" would remove your local
copy.  There may be salvageable bits (e.g., yours may have updates
to documentation and tests that theirs lacks), so if we can detect a
case where only some parts of your work has become irrelevant, that
would be very useful, too.

Stepping back a bit, perhaps "--delete-merged" should not be
modified to touch anything that was rebased, cherry-picked, or
squash merged.  It is good to have one stable and strict "if my
commits were merged and safely saved there, delete them from here",
regardless of how much looser definition of "the same patch" is used
for "--delete-irrelevant".


^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v2 0/2] branch: let --delete-merged find squash merged branches
  2026-10-13  2:23           ` Junio C Hamano
@ 2026-10-17 22:23             ` Harald Nordgren
  2026-10-19  4:40             ` Jasper Mayone
  1 sibling, 0 replies; 60+ messages in thread
From: Harald Nordgren @ 2026-10-17 22:23 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Phillip Wood, Harald Nordgren via GitGitGadget, git,
	Kristoffer Haugsbakk, D. Ben Knoble

> You might have instructed your agent to write a two patch series and
> asked upstream to pull.  Somebody else used the same AI-gen and
> created one patch that solves the same problem, which happened to
> have landed first.  Or that somebody may have sent their solution in
> two patches similar to yours.  In either case, your two patch series
> is now irrelevant.  You would want to drop your branch that is no
> longer relevant, regardless of whose work made it irrelevant,
>
> They may be textually similar enough that we can tell mechanically
> that there are already the same or similar code in the place you
> were trying to touch.  "--delete-irrelevant" would remove your local
> copy.  There may be salvageable bits (e.g., yours may have updates
> to documentation and tests that theirs lacks), so if we can detect a
> case where only some parts of your work has become irrelevant, that
> would be very useful, too.

I would be very surprised if my branch gets deleted because someone
else wrote *almost* the same code in parallel.

I think we can achieve a lot here just by focusing on the use case of
my own work being slightly mangled (squashed or merged).

> Stepping back a bit, perhaps "--delete-merged" should not be
> modified to touch anything that was rebased, cherry-picked, or
> squash merged.  It is good to have one stable and strict "if my
> commits were merged and safely saved there, delete them from here",
> regardless of how much looser definition of "the same patch" is used
> for "--delete-irrelevant".

If my branch gets picked up by the mainline, my work there is done. I
see no reason to keep the branch around anymore. How it gets picked up
(squash, merged, or preserved commit hash) is not relevant then
anymore.

Besides, I can easily find the work again if I want, since by
definition it's now in the mainline :)


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v2 0/2] branch: let --delete-merged find squash merged branches
  2026-10-12 16:50       ` Junio C Hamano
  2026-10-12 17:44         ` Harald Nordgren
@ 2026-10-18 15:09         ` Harald Nordgren
  1 sibling, 0 replies; 60+ messages in thread
From: Harald Nordgren @ 2026-10-18 15:09 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Phillip Wood, Harald Nordgren via GitGitGadget, git,
	Kristoffer Haugsbakk, D. Ben Knoble

> At least the one-to-one part of the operation should learn from what
> Dscho did in range-diff, especially the part that does take
> similarity of the log messages and authorship information, instead
> of paying attention only to patch IDs.  I am not convinced that
> one-to-one rebase implemented as a subset of squash merge would
> perform sufficiently well, either.  From the user interface angle,
> it also is not clear to me if a single --delete-merged that enables
> all variants by default with individual knob to optionally disable
> one variant (squash) would be a good extensible one, when we may
> need to deal with the third (and other) kinds of ways to integrate
> your work without preserving the commit identity, like selective
> cherry-picks.

I tried a range-diff solution locally, pairing commits with range-diff
for rebases (and moving the squash search behind an opt-in flag). As
far as I can tell range-diff pairs commits on the diff alone, and the
log message and author only show up in its output, so it didn't help
with matching here.

On speed it went the other way. On my clone with 65 branches, v2 takes
about 0.5s and finds 14 branches to delete. The range-diff version
takes about 10s, finds the same.


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v2 0/2] branch: let --delete-merged find squash merged branches
  2026-10-13  2:23           ` Junio C Hamano
  2026-10-17 22:23             ` Harald Nordgren
@ 2026-10-19  4:40             ` Jasper Mayone
  1 sibling, 0 replies; 60+ messages in thread
From: Jasper Mayone @ 2026-10-19  4:40 UTC (permalink / raw)
  To: gitster
  Cc: ben.knoble, git, gitgitgadget, haraldnordgren,
	kristofferhaugsbakk, phillip.wood123

Junio C Hamano <gitster@pobox.com> writes:

> Stepping back a bit, perhaps "--delete-merged" should not be
> modified to touch anything that was rebased, cherry-picked, or
> squash merged.  It is good to have one stable and strict "if my
> commits were merged and safely saved there, delete them from here",
> regardless of how much looser definition of "the same patch" is used
> for "--delete-irrelevant".

I am in the group of users this series is for. Most repositories I
work in land pull requests with GitHub's "Squash and merge". I built
v2 (ec88bde252) and tested it on generated repositories. The results
support keeping the strict meaning as the default.

v2 did what it says for squash merges, GitHub-style squashes with a
new message and committer, rebase merges, renames, and squashes that
were later reverted. "landed as" named the correct commit every
time. It kept a branch whose work only partly landed.

But it also deleted a branch that was never merged. Another author
had made the same change upstream in an unrelated commit, and v2
reported the branch as "landed as" that commit. That is the
"irrelevant" case from your message, and today v2 handles it the same
way as a squash merge of my own work.

So I agree that the strict meaning should stay the default. I also
want to turn the looser check on once per repository and then forget
about it. Something close to what Ben suggested earlier would do
that:

    git branch --delete-merged origin/main --landed=squash

plus a config variable for the default. A third kind of integration
then becomes one more value, not one more --no-<kind> option.

On speed: with 3056 commits and 80 branches, v2 took 0.078s against
0.030s for 2.56.0, and --no-squashed brought it back to 0.030s. In a
blobless clone the first run took 0.42s and fetched 13 blobs.
--no-squashed fetched none.

One more point. A branch checked out in a worktree is skipped with no
message, but a branch with deleteMerged=false prints "Skipping". I
keep one worktree per pull request, so most of my merged branches
are skipped silently. A "Skipping 'topic' (checked out in <path>)"
line would help. I plan to send a separate RFC for
"git worktree list --merged", so it would also help if the "has this
branch landed?" check were a helper that worktree code can call.

Tested-by: Jasper Mayone <me@jaspermayone.com>

Thanks,
Jasper

^ permalink raw reply	[flat|nested] 60+ messages in thread

* [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-09-29  7:30 [PATCH] branch: let --delete-merged find squash merged branches Harald Nordgren via GitGitGadget
                   ` (2 preceding siblings ...)
  2026-10-11  9:41 ` [PATCH v2 0/2] " Harald Nordgren via GitGitGadget
@ 2026-10-21 12:28 ` Harald Nordgren via GitGitGadget
  2026-10-21 12:28   ` [PATCH v3 1/3] branch: move the deleteMerged opt-out check into a helper Harald Nordgren via GitGitGadget
                     ` (5 more replies)
  2026-10-26  8:33 ` [PATCH v4 0/4] " Harald Nordgren via GitGitGadget
  4 siblings, 6 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-10-21 12:28 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood, Jasper Mayone,
	Harald Nordgren

Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are
never deleted by git branch --delete-merged, because their tips are not
reachable from the upstream. This treats such a branch as merged when some
upstream commit contains all of its changes, and names that commit in the
output:

Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).


This series finds the first upstream commit since the fork point that has
the branch's version of every file the branch changed, comparing only object
ids. With --squashed, a file that upstream also changed counts when merging
the branch's version into upstream's leaves it unchanged. Timed on my clone
with 65 branches, this takes 0.5s.

Other approaches:

 * Full merge-ort test merge per upstream commit: about 9s, because it
   builds a whole tree for every commit.
 * range-diff pairing for rebases: about 10s. It pairs on diffs alone,
   ignoring log messages, so it found nothing extra.
 * Patch ids: not tried. They tell whether a commit is the same patch, but
   deleting a branch only needs a commit that contains its changes. They
   miss a squash that was touched up and changes next to upstream edits, and
   they read file contents for every upstream commit touching the branch's
   files.

Changes in v3:

 * Split into multiple commits, one for rebase merges and one for squash
   merges.
 * By default a branch counts as merged when an upstream commit has its
   exact version of every file it changed. This never reads file contents,
   so it stays cheap in a partial clone. It finds rebase merges, and squash
   merges where upstream left those files alone.
 * Replace --no-squashed with an opt-in --squashed, which also finds
   branches merged after upstream changed the same files by merging those
   files.
 * Add a test for a rebase merge, and check that a squash onto changed files
   is kept without --squashed and deleted with it.

Changes in v2:

 * Diff each upstream commit once and share the result between all branches,
   faster than v1.
 * Fix a branch merged with a merge commit being reported as "landed as" the
   merge when another branch was searched first, and add a test for it. Show
   the topic commit itself in "landed as" for work that arrived through a
   merge.
 * Use git merge --squash as the example in the commit message and mention
   GitHub's squash and rebase buttons.
 * Add --no-squashed to skip the squash search.

Harald Nordgren (3):
  branch: move the deleteMerged opt-out check into a helper
  branch: let --delete-merged find rebase merged branches
  branch: add --squashed to find squash merged branches

 Documentation/git-branch.adoc |  26 ++-
 builtin/branch.c              | 345 ++++++++++++++++++++++++++++++++--
 t/t3200-branch.sh             | 192 +++++++++++++++++++
 3 files changed, 537 insertions(+), 26 deletions(-)


base-commit: 47fdf2208c5b94c8cc01c106b8f7c85efb91c926
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2425%2FHaraldNordgren%2Fbranch-delete-squashed-v3
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2425/HaraldNordgren/branch-delete-squashed-v3
Pull-Request: https://github.com/git/git/pull/2425

Range-diff vs v2:

 -:  ---------- > 1:  82413e5cd7 branch: move the deleteMerged opt-out check into a helper
 1:  94a6d23f8b ! 2:  9a7e88ced6 branch: let --delete-merged find squash merged branches
     @@ Metadata
      Author: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Commit message ##
     -    branch: let --delete-merged find squash merged branches
     +    branch: let --delete-merged find rebase merged branches
      
          A branch that upstream integrated with
      
     -        git merge --squash topic
     -        git commit
     +        git rebase main topic
     +        git checkout main
     +        git merge --ff-only topic
      
     -    is never deleted by "git branch --delete-merged". The same goes for a
     -    branch that was rebased or cherry-picked onto upstream. The upstream
     -    holds a rewritten copy of its work, so its tip is not reachable from
     -    it and it looks unmerged forever.
     -
     -    This happens all the time on hosting services. The "Squash and merge"
     -    and "Rebase and merge" buttons on GitHub land pull requests this way,
     -    so contributors to such projects collect local branches that
     -    "--delete-merged" never cleans up.
     +    is never deleted by "git branch --delete-merged". This is what the
     +    "Rebase and merge" button on GitHub does. The upstream holds rewritten
     +    copies of the branch's commits, so its tip is not reachable from it and
     +    it looks unmerged forever.
      
          Treat such a branch as merged when some upstream commit since the fork
     -    point contains all of its changes, so that merging the branch into
     -    that commit would change nothing. Name that commit in the output so
     -    the user can see where the work went:
     +    point has the branch's version of every file the branch changed. Name
     +    that commit in the output so the user can see where the work went:
      
              Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
      
     -    The first upstream commit that contains the changes is used, so the
     -    branch is deleted even if upstream later reverted or reworked them.
     -    Nothing is lost, since that commit keeps them in the upstream history.
     -    A branch whose changes only partly landed is kept.
     +    The first upstream commit that has them is used, so the branch is
     +    deleted even if upstream later reverted or reworked them. Nothing is
     +    lost, since that commit keeps them in the upstream history. A branch
     +    whose changes only partly landed is kept.
     +
     +    The same check finds a squash merge when upstream did not change the
     +    same files between the fork point and the squash. It never reads file
     +    contents, so it stays cheap in a partial clone.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
      +	_<pattern>_, but only when their work has already landed on that
      +	upstream, so the local copy is no longer needed. This is the case
      +	when their tip is reachable from the upstream, or when some
     -+	upstream commit contains all of their changes, as happens after
     -+	a squash or rebase merge, even if those changes were later
     ++	upstream commit has their version of every file they changed, as
     ++	happens after a rebase merge, even if those changes were later
      +	reverted. The message for such a branch names the first upstream
     -+	commit that contains its changes. _<pattern>_ may name a ref, a
     -+	remote (using the branch its `HEAD` points at), or a shell-style
     -+	glob. The option can be repeated to widen the upstream match.
     ++	commit that has them. _<pattern>_ may name a ref, a remote (using
     ++	the branch its `HEAD` points at), or a shell-style glob. The
     ++	option can be repeated to widen the upstream match.
       	Optional _<branch-pattern>_ arguments limit which local branches
       	are considered, e.g. `git branch --delete-merged 'origin/*'
       	'topic-*'`.
     @@ builtin/branch.c
      +#include "diff.h"
      +#include "diffcore.h"
      +#include "hex.h"
     -+#include "merge-ll.h"
      +#include "revision.h"
      +#include "tree-walk.h"
     -+#include "xdiff-interface.h"
       
       static const char * const builtin_branch_usage[] = {
       	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
     @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
       	return ret;
       }
       
     -+enum change_state {
     -+	CHANGE_MISSING,
     -+	CHANGE_LANDED,
     -+	CHANGE_NEEDS_MERGE,
     -+};
     -+
      +struct branch_change {
      +	char *path;
     -+	struct object_id base_oid, branch_oid, upstream_oid;
     ++	struct object_id branch_oid;
      +	unsigned short branch_mode;
     -+	enum change_state state;
      +};
      +
      +struct landing_search {
     @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
      +			   search->changes_alloc);
      +		change = &search->changes[search->changes_nr++];
      +		change->path = xstrdup(p->two->path);
     -+		oidcpy(&change->base_oid, DIFF_FILE_VALID(p->one) ?
     -+		       &p->one->oid : null_oid(the_hash_algo));
      +		oidcpy(&change->branch_oid, DIFF_FILE_VALID(p->two) ?
      +		       &p->two->oid : null_oid(the_hash_algo));
      +		change->branch_mode = p->two->mode;
     @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
      +	diff_flush(&opt);
      +}
      +
     -+static int merge_keeps_upstream(const struct branch_change *change)
     -+{
     -+	mmfile_t base, upstream, branch;
     -+	mmbuffer_t result = { 0 };
     -+	int ret;
     -+
     -+	read_mmblob(&base, the_repository->objects, &change->base_oid);
     -+	read_mmblob(&upstream, the_repository->objects, &change->upstream_oid);
     -+	read_mmblob(&branch, the_repository->objects, &change->branch_oid);
     -+	ret = ll_merge(&result, change->path, &base, "base",
     -+		       &upstream, "upstream", &branch, "branch",
     -+		       the_repository->index, NULL) == LL_MERGE_OK &&
     -+	      result.size == upstream.size &&
     -+	      !memcmp(result.ptr, upstream.ptr, upstream.size);
     -+
     -+	free(base.ptr);
     -+	free(upstream.ptr);
     -+	free(branch.ptr);
     -+	free(result.ptr);
     -+	return ret;
     -+}
     -+
     -+static enum change_state change_state_at(struct branch_change *change,
     -+					 struct commit *commit)
     ++static int change_landed(const struct branch_change *change,
     ++			 struct commit *commit)
      +{
     ++	struct object_id oid;
      +	unsigned short mode;
      +
      +	if (get_tree_entry(the_repository, get_commit_tree_oid(commit),
     -+			   change->path, &change->upstream_oid, &mode))
     -+		return is_null_oid(&change->branch_oid) ?
     -+			CHANGE_LANDED : CHANGE_MISSING;
     -+	if (oideq(&change->upstream_oid, &change->branch_oid))
     -+		return mode == change->branch_mode ?
     -+			CHANGE_LANDED : CHANGE_MISSING;
     -+	if (is_null_oid(&change->base_oid) ||
     -+	    is_null_oid(&change->branch_oid) ||
     -+	    oideq(&change->upstream_oid, &change->base_oid) ||
     -+	    mode != change->branch_mode || !S_ISREG(mode))
     -+		return CHANGE_MISSING;
     -+	return CHANGE_NEEDS_MERGE;
     ++			   change->path, &oid, &mode))
     ++		return is_null_oid(&change->branch_oid);
     ++	return oideq(&oid, &change->branch_oid) &&
     ++	       mode == change->branch_mode;
      +}
      +
      +static int changes_landed(struct landing_search *search,
      +			  struct commit *commit)
      +{
     -+	for (size_t i = 0; i < search->changes_nr; i++) {
     -+		struct branch_change *change = &search->changes[i];
     -+
     -+		change->state = change_state_at(change, commit);
     -+		if (change->state == CHANGE_MISSING)
     -+			return 0;
     -+	}
     -+	for (size_t i = 0; i < search->changes_nr; i++) {
     -+		struct branch_change *change = &search->changes[i];
     -+
     -+		if (change->state == CHANGE_NEEDS_MERGE &&
     -+		    !merge_keeps_upstream(change))
     ++	for (size_t i = 0; i < search->changes_nr; i++)
     ++		if (!change_landed(&search->changes[i], commit))
      +			return 0;
     -+	}
      +	return 1;
      +}
      +
     @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
      +	diff_free(&opt);
      +}
      +
     -+static int branch_opted_out(struct strbuf *key, const char *branch_name,
     -+			    unsigned int flags)
     -+{
     -+	int opt_out;
     -+
     -+	strbuf_reset(key);
     -+	strbuf_addf(key, "branch.%s.deletemerged", branch_name);
     -+	if (repo_config_get_bool(the_repository, key->buf, &opt_out) ||
     -+	    opt_out)
     -+		return 0;
     -+	if (!(flags & DELETE_BRANCH_QUIET))
     -+		fprintf(stderr,
     -+			_("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
     -+			branch_name, branch_name);
     -+	return 1;
     -+}
     -+
     - static int delete_merged_branches(const struct strvec *upstreams,
     - 				 const char **argv, unsigned int flags)
     + static bool branch_opted_out(struct strbuf *key, const char *branch_name,
     + 			     unsigned int flags)
       {
      @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
       	struct ref_array candidates = { 0 };
     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
       	struct strvec branches_to_delete = STRVEC_INIT;
       	struct strbuf key = STRBUF_INIT;
       	struct hashmap_iter iter;
     -@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
     - 		const char *branch_name;
     - 		struct branch *branch;
     - 		const char *upstream_refname;
     --		int opt_out;
     - 
     - 		if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
     - 			BUG("filter returned non-branch ref '%s'", branch_refname);
      @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
       			continue;
       		if (check_branch_commit(branch_name, branch_name,
       					&candidates.items[i]->objectname, NULL,
      -					FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
     --			continue;
     --
     --		strbuf_reset(&key);
     --		strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
     --		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
     --		    !opt_out) {
     --			if (!(flags & DELETE_BRANCH_QUIET))
     --				fprintf(stderr,
     --					_("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
     --					branch_name, branch_name);
      +					FILTER_REFS_BRANCHES,
      +					DELETE_BRANCH_SKIP_UNMERGED)) {
      +			struct commit *rev = lookup_commit_reference(
     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
      +				.upstream = upstream,
      +			};
       			continue;
     - 		}
     ++		}
     + 
     + 		if (!branch_opted_out(&key, branch_name, flags))
     + 			strset_add(&deletable_branch_names, branch_name);
     + 	}
       
     --		strset_add(&deletable_branch_names, branch_name);
     -+		if (!branch_opted_out(&key, branch_name, flags))
     -+			strset_add(&deletable_branch_names, branch_name);
     -+	}
     -+
      +	find_landed_commits(searches, searches_nr, &landed_commits);
      +	for (size_t i = 0; i < searches_nr; i++) {
      +		const char *branch_name = searches[i].branch_name;
     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
      +		if (strmap_contains(&landed_commits, branch_name) &&
      +		    !branch_opted_out(&key, branch_name, flags))
      +			strset_add(&deletable_branch_names, branch_name);
     - 	}
     - 
     ++	}
     ++
       	protect_stacked_branch_bases(refs, &deletable_branch_names,
     + 				     &protected_branch_names);
     + 
      @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
       				      FILTER_REFS_BRANCHES,
       				      DELETE_BRANCH_SKIP_UNMERGED |
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
      +	)
      +}
      +
     ++rebase_merge_upstream () {
     ++	(
     ++		cd upstream &&
     ++		git rebase next "$1" &&
     ++		git checkout next &&
     ++		git merge --ff-only "$1" &&
     ++		git checkout main
     ++	)
     ++}
     ++
     ++test_expect_success '--delete-merged deletes a rebase merged branch' '
     ++	setup_repo_for_delete_merged &&
     ++	push_topic rebased rebased-one rebased-two &&
     ++	(
     ++		cd upstream &&
     ++		git checkout next &&
     ++		test_commit before-rebase &&
     ++		git checkout main
     ++	) &&
     ++	rebase_merge_upstream rebased &&
     ++	landed=$(git -C upstream rev-parse --short next) &&
     ++	(
     ++		cd repo &&
     ++		git fetch origin &&
     ++		sha=$(git rev-parse --short rebased) &&
     ++
     ++		git branch --delete-merged origin/next >actual 2>&1 &&
     ++		echo "Deleted branch rebased (was $sha, landed as $landed)." >expect &&
     ++		test_cmp expect actual &&
     ++
     ++		check_branches <<-\EOF
     ++		main
     ++		EOF
     ++	)
     ++'
     ++
      +test_expect_success '--delete-merged deletes a squash merged branch' '
      +	setup_repo_for_delete_merged &&
      +	push_topic squashed squashed-one squashed-two &&
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
      +	)
      +'
      +
     -+test_expect_success '--delete-merged deletes a squash merged branch after upstream changed the same file' '
     -+	(
     -+		cd upstream &&
     -+		git checkout next &&
     -+		test_write_lines 1 2 3 4 5 6 7 8 9 10 >shared &&
     -+		git add shared &&
     -+		git commit -m "add shared" &&
     -+		git checkout main
     -+	) &&
     -+	setup_repo_for_delete_merged &&
     -+	(
     -+		cd repo &&
     -+		git checkout -b edits-shared --track origin/next &&
     -+		test_write_lines 1 two 3 4 5 6 7 8 9 10 >shared &&
     -+		git commit -a -m "edit line 2" &&
     -+		git push origin edits-shared &&
     -+		git checkout --detach
     -+	) &&
     -+	(
     -+		cd upstream &&
     -+		git checkout next &&
     -+		test_write_lines 1 2 3 4 five 6 7 8 9 10 >shared &&
     -+		git commit -a -m "edit line 5" &&
     -+		git checkout main
     -+	) &&
     -+	squash_merge_upstream edits-shared &&
     -+	squash=$(git -C upstream rev-parse --short next) &&
     -+	(
     -+		cd repo &&
     -+		git fetch origin &&
     -+		sha=$(git rev-parse --short edits-shared) &&
     -+
     -+		git branch --delete-merged origin/next >actual 2>&1 &&
     -+		echo "Deleted branch edits-shared (was $sha, landed as $squash)." >expect &&
     -+		test_cmp expect actual &&
     -+
     -+		check_branches <<-\EOF
     -+		main
     -+		EOF
     -+	)
     -+'
     -+
      +test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' '
      +	setup_repo_for_delete_merged &&
      +	push_topic a-unmerged unmerged-work &&
 2:  ec88bde252 ! 3:  2befb182dc branch: add --no-squashed to skip the squash search
     @@ Metadata
      Author: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Commit message ##
     -    branch: add --no-squashed to skip the squash search
     +    branch: add --squashed to find squash merged branches
      
     -    Finding branches that were squash or rebase merged makes "git branch
     -    --delete-merged" slower, and in a partial clone it may have to
     -    download blobs. Users who do not need it have no way to skip that
     -    search.
     +    A branch that upstream integrated with
      
     -    Add --no-squashed, which only deletes branches whose tip is reachable
     -    from their upstream.
     +        git merge --squash topic
     +        git commit
     +
     +    after upstream had also changed the files the branch touches is still
     +    kept by "git branch --delete-merged". The squash commit has neither the
     +    branch's version of those files nor the fork point's, so the previous
     +    check cannot see that the work landed. This happens all the time on
     +    hosting services, where the "Squash and merge" button on GitHub lands
     +    pull requests this way while the main branch keeps moving. A rebase
     +    merge onto such changes looks the same.
     +
     +    Add --squashed, which for each such file merges the branch's version
     +    into the upstream one and treats the change as landed when that leaves
     +    the upstream version unchanged. This has to read the files, so it is
     +    slower, and in a partial clone it may download blobs. It is therefore
     +    only done when asked for.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
       git branch (-d|-D) [-r] <branch-name>...
       git branch --edit-description [<branch-name>]
      -git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]
     -+git branch [--dry-run] [--no-squashed] (--delete-merged <pattern>)... [<branch-pattern>...]
     ++git branch [--dry-run] [--squashed] (--delete-merged <pattern>)... [<branch-pattern>...]
       
       DESCRIPTION
       -----------
     @@ Documentation/git-branch.adoc: remove it anyway.
       	sanity-checking a wide pattern like `'origin/*'` before
       	committing to the deletion.
       
     -+`--no-squashed`::
     -+	With `--delete-merged`, only delete branches whose tip is
     -+	reachable from their upstream. This skips the search for
     -+	branches that were squash or rebase merged, which takes longer
     -+	and may have to download blobs in a partial clone.
     ++`--squashed`::
     ++	With `--delete-merged`, also delete a branch that was squash or
     ++	rebase merged after upstream changed the same files. Upstream
     ++	then has neither the branch's version of those files nor the fork
     ++	point's, so the branch's version is merged into upstream's, and
     ++	the file counts as landed when that leaves upstream's version
     ++	unchanged. This reads those files, so it takes longer and may
     ++	download blobs in a partial clone.
      +
       `-v`::
       `-vv`::
       `--verbose`::
      
       ## builtin/branch.c ##
     +@@
     + #include "diff.h"
     + #include "diffcore.h"
     + #include "hex.h"
     ++#include "merge-ll.h"
     + #include "revision.h"
     + #include "tree-walk.h"
     ++#include "xdiff-interface.h"
     + 
     + static const char * const builtin_branch_usage[] = {
     + 	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
      @@ builtin/branch.c: enum delete_branch_flags {
       	DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
       	DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
       	DELETE_BRANCH_DRY_RUN = (1 << 4),
     -+	DELETE_BRANCH_NO_SQUASHED = (1 << 5),
     ++	DELETE_BRANCH_SQUASHED = (1 << 5),
       };
       
       static int check_branch_commit(const char *branchname, const char *refname,
     +@@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
     + 	return ret;
     + }
     + 
     ++enum change_state {
     ++	CHANGE_MISSING,
     ++	CHANGE_LANDED,
     ++	CHANGE_NEEDS_MERGE,
     ++};
     ++
     + struct branch_change {
     + 	char *path;
     +-	struct object_id branch_oid;
     ++	struct object_id base_oid, branch_oid, upstream_oid;
     + 	unsigned short branch_mode;
     ++	enum change_state state;
     + };
     + 
     + struct landing_search {
     +@@ builtin/branch.c: static void collect_branch_changes(struct landing_search *search,
     + 			   search->changes_alloc);
     + 		change = &search->changes[search->changes_nr++];
     + 		change->path = xstrdup(p->two->path);
     ++		oidcpy(&change->base_oid, DIFF_FILE_VALID(p->one) ?
     ++		       &p->one->oid : null_oid(the_hash_algo));
     + 		oidcpy(&change->branch_oid, DIFF_FILE_VALID(p->two) ?
     + 		       &p->two->oid : null_oid(the_hash_algo));
     + 		change->branch_mode = p->two->mode;
     +@@ builtin/branch.c: static void collect_branch_changes(struct landing_search *search,
     + 	diff_flush(&opt);
     + }
     + 
     +-static int change_landed(const struct branch_change *change,
     +-			 struct commit *commit)
     ++static int merge_keeps_upstream(const struct branch_change *change)
     ++{
     ++	mmfile_t base, upstream, branch;
     ++	mmfile_t result = { 0 };
     ++	int ret;
     ++
     ++	read_mmblob(&base, the_repository->objects, &change->base_oid);
     ++	read_mmblob(&upstream, the_repository->objects, &change->upstream_oid);
     ++	read_mmblob(&branch, the_repository->objects, &change->branch_oid);
     ++	ret = ll_merge(&result, change->path, &base, "base",
     ++		       &upstream, "upstream", &branch, "branch",
     ++		       the_repository->index, NULL) == LL_MERGE_OK &&
     ++	      result.size == upstream.size &&
     ++	      !memcmp(result.ptr, upstream.ptr, upstream.size);
     ++
     ++	free(base.ptr);
     ++	free(upstream.ptr);
     ++	free(branch.ptr);
     ++	free(result.ptr);
     ++	return ret;
     ++}
     ++
     ++static enum change_state change_state_at(struct branch_change *change,
     ++					 struct commit *commit)
     + {
     +-	struct object_id oid;
     + 	unsigned short mode;
     + 
     + 	if (get_tree_entry(the_repository, get_commit_tree_oid(commit),
     +-			   change->path, &oid, &mode))
     +-		return is_null_oid(&change->branch_oid);
     +-	return oideq(&oid, &change->branch_oid) &&
     +-	       mode == change->branch_mode;
     ++			   change->path, &change->upstream_oid, &mode))
     ++		return is_null_oid(&change->branch_oid) ?
     ++			CHANGE_LANDED : CHANGE_MISSING;
     ++	if (oideq(&change->upstream_oid, &change->branch_oid))
     ++		return mode == change->branch_mode ?
     ++			CHANGE_LANDED : CHANGE_MISSING;
     ++	if (is_null_oid(&change->base_oid) ||
     ++	    is_null_oid(&change->branch_oid) ||
     ++	    oideq(&change->upstream_oid, &change->base_oid) ||
     ++	    mode != change->branch_mode || !S_ISREG(mode))
     ++		return CHANGE_MISSING;
     ++	return CHANGE_NEEDS_MERGE;
     + }
     + 
     + static int changes_landed(struct landing_search *search,
     +-			  struct commit *commit)
     ++			  struct commit *commit, unsigned int flags)
     + {
     +-	for (size_t i = 0; i < search->changes_nr; i++)
     +-		if (!change_landed(&search->changes[i], commit))
     ++	for (size_t i = 0; i < search->changes_nr; i++) {
     ++		struct branch_change *change = &search->changes[i];
     ++
     ++		change->state = change_state_at(change, commit);
     ++		if (change->state == CHANGE_MISSING ||
     ++		    (change->state == CHANGE_NEEDS_MERGE &&
     ++		     !(flags & DELETE_BRANCH_SQUASHED)))
     + 			return 0;
     ++	}
     ++	for (size_t i = 0; i < search->changes_nr; i++) {
     ++		struct branch_change *change = &search->changes[i];
     ++
     ++		if (change->state == CHANGE_NEEDS_MERGE &&
     ++		    !merge_keeps_upstream(change))
     ++			return 0;
     ++	}
     + 	return 1;
     + }
     + 
     +@@ builtin/branch.c: static int touches_changes(struct string_list *paths,
     + 
     + static struct commit *find_landed_commit(struct landing_search *search,
     + 					 struct changed_paths *cache,
     +-					 struct diff_options *opt)
     ++					 struct diff_options *opt,
     ++					 unsigned int flags)
     + {
     + 	struct commit *commit, *landed = NULL;
     + 	struct strvec args = STRVEC_INIT;
     +@@ builtin/branch.c: static struct commit *find_landed_commit(struct landing_search *search,
     + 	while (!landed && (commit = get_revision(&revs)))
     + 		if (touches_changes(changed_paths_of(cache, opt, commit),
     + 				    search) &&
     +-		    changes_landed(search, commit))
     ++		    changes_landed(search, commit, flags))
     + 			landed = commit;
     + 	release_revisions(&revs);
     + 	clear_commit_marks(search->upstream, ALL_REV_FLAGS);
     +@@ builtin/branch.c: static struct commit *find_landed_commit(struct landing_search *search,
     + 
     + static void find_landed_commits(struct landing_search *searches,
     + 				size_t searches_nr,
     +-				struct strmap *landed_commits)
     ++				struct strmap *landed_commits,
     ++				unsigned int flags)
     + {
     + 	struct changed_paths cache;
     + 	struct diff_options opt;
     +@@ builtin/branch.c: static void find_landed_commits(struct landing_search *searches,
     + 
     + 		if (!searches[i].changes_nr)
     + 			continue;
     +-		landed = find_landed_commit(&searches[i], &cache, &opt);
     ++		landed = find_landed_commit(&searches[i], &cache, &opt, flags);
     + 		if (landed)
     + 			strmap_put(landed_commits, searches[i].branch_name,
     + 				   landed);
      @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
     - 			struct commit *upstream = lookup_commit_reference_by_name(
     - 				upstream_refname);
     - 
     --			if (!rev || !upstream)
     -+			if ((flags & DELETE_BRANCH_NO_SQUASHED) ||
     -+			    !rev || !upstream)
     - 				continue;
     - 			ALLOC_GROW(searches, searches_nr + 1, searches_alloc);
     - 			searches[searches_nr++] = (struct landing_search) {
     + 			strset_add(&deletable_branch_names, branch_name);
     + 	}
     + 
     +-	find_landed_commits(searches, searches_nr, &landed_commits);
     ++	find_landed_commits(searches, searches_nr, &landed_commits, flags);
     + 	for (size_t i = 0; i < searches_nr; i++) {
     + 		const char *branch_name = searches[i].branch_name;
     + 
      @@ builtin/branch.c: int cmd_branch(int argc,
       	    unset_upstream = 0, show_current = 0, edit_description = 0;
       	struct strvec delete_merged = STRVEC_INIT;
       	int dry_run = 0;
     -+	int no_squashed = 0;
     ++	int squashed = 0;
       	const char *new_upstream = NULL;
       	int noncreate_actions = 0;
       	/* possible options */
     @@ builtin/branch.c: int cmd_branch(int argc,
       			PARSE_OPT_NONEG, parse_opt_strvec),
       		OPT_BOOL(0, "dry-run", &dry_run,
       			N_("with --delete-merged, only print which branches would be deleted")),
     -+		OPT_BOOL(0, "no-squashed", &no_squashed,
     -+			N_("with --delete-merged, skip the search for squash merged branches")),
     ++		OPT_BOOL(0, "squashed", &squashed,
     ++			N_("with --delete-merged, also find branches merged after upstream changed their files")),
       		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
       		OPT_MERGED(&filter, N_("print only branches that are merged")),
       		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
     @@ builtin/branch.c: int cmd_branch(int argc,
       
       	if (dry_run && !delete_merged.nr)
       		die(_("--dry-run requires --delete-merged"));
     -+	if (no_squashed && !delete_merged.nr)
     -+		die(_("--no-squashed requires --delete-merged"));
     ++	if (squashed && !delete_merged.nr)
     ++		die(_("--squashed requires --delete-merged"));
       
       	if (recurse_submodules_explicit) {
       		if (!submodule_propagate_branches)
     @@ builtin/branch.c: int cmd_branch(int argc,
       					     (quiet ? DELETE_BRANCH_QUIET : 0) |
      -					     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
      +					     (dry_run ? DELETE_BRANCH_DRY_RUN : 0) |
     -+					     (no_squashed ? DELETE_BRANCH_NO_SQUASHED : 0));
     ++					     (squashed ? DELETE_BRANCH_SQUASHED : 0));
       		goto out;
       	} else if (show_current) {
       		print_current_branch_name();
      
       ## t/t3200-branch.sh ##
     -@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes a squash merged branch after upstre
     +@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes a squash merged branch that was rev
       	)
       '
       
     -+test_expect_success '--delete-merged --no-squashed deletes only reachable branches' '
     ++test_expect_success '--delete-merged --squashed deletes a squash merged branch after upstream changed the same file' '
     ++	(
     ++		cd upstream &&
     ++		git checkout next &&
     ++		test_write_lines 1 2 3 4 5 6 7 8 9 10 >shared &&
     ++		git add shared &&
     ++		git commit -m "add shared" &&
     ++		git checkout main
     ++	) &&
      +	setup_repo_for_delete_merged &&
     -+	create_merged_branch merged &&
     -+	push_topic squash-merged squash-merged-work &&
     -+	squash_merge_upstream squash-merged &&
     ++	(
     ++		cd repo &&
     ++		git checkout -b edits-shared --track origin/next &&
     ++		test_write_lines 1 two 3 4 5 6 7 8 9 10 >shared &&
     ++		git commit -a -m "edit line 2" &&
     ++		git push origin edits-shared &&
     ++		git checkout --detach
     ++	) &&
     ++	(
     ++		cd upstream &&
     ++		git checkout next &&
     ++		test_write_lines 1 2 3 4 five 6 7 8 9 10 >shared &&
     ++		git commit -a -m "edit line 5" &&
     ++		git checkout main
     ++	) &&
     ++	squash_merge_upstream edits-shared &&
     ++	squash=$(git -C upstream rev-parse --short next) &&
      +	(
      +		cd repo &&
      +		git fetch origin &&
     -+		sha=$(git rev-parse --short merged) &&
     ++		sha=$(git rev-parse --short edits-shared) &&
     ++
     ++		git branch --delete-merged origin/next >actual 2>&1 &&
     ++		test_must_be_empty actual &&
     ++		check_branches <<-\EOF &&
     ++		edits-shared
     ++		main
     ++		EOF
      +
     -+		git branch --delete-merged origin/next --no-squashed \
     ++		git branch --delete-merged origin/next --squashed \
      +			>actual 2>&1 &&
     -+		echo "Deleted branch merged (was $sha)." >expect &&
     ++		echo "Deleted branch edits-shared (was $sha, landed as $squash)." >expect &&
      +		test_cmp expect actual &&
      +
      +		check_branches <<-\EOF
      +		main
     -+		squash-merged
      +		EOF
      +	)
      +'
     @@ t/t3200-branch.sh: test_expect_success "branch -d still deletes a deleteMerged=f
       	)
       '
       
     -+test_expect_success '--no-squashed without --delete-merged is rejected' '
     -+	test_must_fail git -C forked branch --no-squashed 2>err &&
     ++test_expect_success '--squashed without --delete-merged is rejected' '
     ++	test_must_fail git -C forked branch --squashed 2>err &&
      +	test_grep "requires --delete-merged" err
      +'
      +

-- 
gitgitgadget

^ permalink raw reply	[flat|nested] 60+ messages in thread

* [PATCH v3 1/3] branch: move the deleteMerged opt-out check into a helper
  2026-10-21 12:28 ` [PATCH v3 0/3] " Harald Nordgren via GitGitGadget
@ 2026-10-21 12:28   ` Harald Nordgren via GitGitGadget
  2026-10-21 12:28   ` [PATCH v3 2/3] branch: let --delete-merged find rebase merged branches Harald Nordgren via GitGitGadget
                     ` (4 subsequent siblings)
  5 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-10-21 12:28 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood, Jasper Mayone,
	Harald Nordgren, Harald Nordgren

From: Harald Nordgren <haraldnordgren@gmail.com>

The next commit lets "git branch --delete-merged" delete branches whose
tips are not reachable from their upstream, and those must honor
branch.<name>.deleteMerged=false as well. Move the check into a helper
so it can be used in both places.

No change in behavior.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 builtin/branch.c | 32 +++++++++++++++++++-------------
 1 file changed, 19 insertions(+), 13 deletions(-)

diff --git a/builtin/branch.c b/builtin/branch.c
index a613148fc7..171b13b081 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -824,6 +824,23 @@ static int branch_pushes_to_upstream(struct branch *branch,
 	return ret;
 }
 
+static bool branch_opted_out(struct strbuf *key, const char *branch_name,
+			     unsigned int flags)
+{
+	int opt_out;
+
+	strbuf_reset(key);
+	strbuf_addf(key, "branch.%s.deletemerged", branch_name);
+	if (repo_config_get_bool(the_repository, key->buf, &opt_out) ||
+	    opt_out)
+		return false;
+	if (!(flags & DELETE_BRANCH_QUIET))
+		fprintf(stderr,
+			_("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
+			branch_name, branch_name);
+	return true;
+}
+
 static int delete_merged_branches(const struct strvec *upstreams,
 				 const char **argv, unsigned int flags)
 {
@@ -852,7 +869,6 @@ static int delete_merged_branches(const struct strvec *upstreams,
 		const char *branch_name;
 		struct branch *branch;
 		const char *upstream_refname;
-		int opt_out;
 
 		if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
 			BUG("filter returned non-branch ref '%s'", branch_refname);
@@ -870,18 +886,8 @@ static int delete_merged_branches(const struct strvec *upstreams,
 					FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
 			continue;
 
-		strbuf_reset(&key);
-		strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
-		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
-		    !opt_out) {
-			if (!(flags & DELETE_BRANCH_QUIET))
-				fprintf(stderr,
-					_("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
-					branch_name, branch_name);
-			continue;
-		}
-
-		strset_add(&deletable_branch_names, branch_name);
+		if (!branch_opted_out(&key, branch_name, flags))
+			strset_add(&deletable_branch_names, branch_name);
 	}
 
 	protect_stacked_branch_bases(refs, &deletable_branch_names,
-- 
gitgitgadget


^ permalink raw reply related	[flat|nested] 60+ messages in thread

* [PATCH v3 2/3] branch: let --delete-merged find rebase merged branches
  2026-10-21 12:28 ` [PATCH v3 0/3] " Harald Nordgren via GitGitGadget
  2026-10-21 12:28   ` [PATCH v3 1/3] branch: move the deleteMerged opt-out check into a helper Harald Nordgren via GitGitGadget
@ 2026-10-21 12:28   ` Harald Nordgren via GitGitGadget
  2026-10-21 12:28   ` [PATCH v3 3/3] branch: add --squashed to find squash " Harald Nordgren via GitGitGadget
                     ` (3 subsequent siblings)
  5 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-10-21 12:28 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood, Jasper Mayone,
	Harald Nordgren, Harald Nordgren

From: Harald Nordgren <haraldnordgren@gmail.com>

A branch that upstream integrated with

    git rebase main topic
    git checkout main
    git merge --ff-only topic

is never deleted by "git branch --delete-merged". This is what the
"Rebase and merge" button on GitHub does. The upstream holds rewritten
copies of the branch's commits, so its tip is not reachable from it and
it looks unmerged forever.

Treat such a branch as merged when some upstream commit since the fork
point has the branch's version of every file the branch changed. Name
that commit in the output so the user can see where the work went:

    Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).

The first upstream commit that has them is used, so the branch is
deleted even if upstream later reverted or reworked them. Nothing is
lost, since that commit keeps them in the upstream history. A branch
whose changes only partly landed is kept.

The same check finds a squash merge when upstream did not change the
same files between the fork point and the squash. It never reads file
contents, so it stays cheap in a partial clone.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc |  15 +-
 builtin/branch.c              | 251 +++++++++++++++++++++++++++++++++-
 t/t3200-branch.sh             | 137 +++++++++++++++++++
 3 files changed, 391 insertions(+), 12 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index bfdf459329..3d6549b977 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -204,12 +204,15 @@ This option is only applicable in non-verbose mode.
 
 `--delete-merged <pattern>`::
 	Delete local branches whose configured upstream matches
-	_<pattern>_, but only when their tip is reachable from that
-	upstream. In other words, the work on the branch has already
-	landed on the upstream it tracks, so the local copy is no longer
-	needed. _<pattern>_ may name a ref, a remote (using the branch its
-	`HEAD` points at), or a shell-style glob. The option can be
-	repeated to widen the upstream match.
+	_<pattern>_, but only when their work has already landed on that
+	upstream, so the local copy is no longer needed. This is the case
+	when their tip is reachable from the upstream, or when some
+	upstream commit has their version of every file they changed, as
+	happens after a rebase merge, even if those changes were later
+	reverted. The message for such a branch names the first upstream
+	commit that has them. _<pattern>_ may name a ref, a remote (using
+	the branch its `HEAD` points at), or a shell-style glob. The
+	option can be repeated to widen the upstream match.
 	Optional _<branch-pattern>_ arguments limit which local branches
 	are considered, e.g. `git branch --delete-merged 'origin/*'
 	'topic-*'`.
diff --git a/builtin/branch.c b/builtin/branch.c
index 171b13b081..2a2e370401 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -29,6 +29,12 @@
 #include "help.h"
 #include "advice.h"
 #include "commit-reach.h"
+#include "commit-slab.h"
+#include "diff.h"
+#include "diffcore.h"
+#include "hex.h"
+#include "revision.h"
+#include "tree-walk.h"
 
 static const char * const builtin_branch_usage[] = {
 	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
@@ -236,7 +242,7 @@ static void delete_branch_config(const char *branchname)
 }
 
 static int delete_branches(int argc, const char **argv, int kinds,
-			   unsigned int flags)
+			   unsigned int flags, struct strmap *landed_commits)
 {
 	struct commit *head_rev = NULL;
 	struct object_id oid;
@@ -334,6 +340,8 @@ static int delete_branches(int argc, const char **argv, int kinds,
 		}
 
 		if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
+		    !(landed_commits &&
+		      strmap_contains(landed_commits, bname.buf)) &&
 		    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
 					flags)) {
 			if (!(flags & DELETE_BRANCH_SKIP_UNMERGED))
@@ -357,15 +365,33 @@ static int delete_branches(int argc, const char **argv, int kinds,
 	for_each_string_list_item(item, &refs_to_delete) {
 		char *describe_ref = item->util;
 		char *name = item->string;
+		struct commit *landed = landed_commits ?
+			strmap_get(landed_commits, name + branch_name_pos) : NULL;
+		const char *landed_abbrev = landed ?
+			repo_find_unique_abbrev(the_repository,
+						&landed->object.oid,
+						DEFAULT_ABBREV) : NULL;
+
 		if (flags & DELETE_BRANCH_DRY_RUN) {
-			if (!(flags & DELETE_BRANCH_QUIET))
+			if (flags & DELETE_BRANCH_QUIET)
+				;
+			else if (landed)
+				printf(_("Would delete branch %s (was %s, landed as %s).\n"),
+				       name + branch_name_pos, describe_ref,
+				       landed_abbrev);
+			else
 				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)) {
 			char *refname = name + branch_name_pos;
-			if (!(flags & DELETE_BRANCH_QUIET))
+			if (flags & DELETE_BRANCH_QUIET)
+				;
+			else if (landed)
+				printf(_("Deleted branch %s (was %s, landed as %s).\n"),
+				       refname, describe_ref, landed_abbrev);
+			else
 				printf(remote_branch
 					? _("Deleted remote-tracking branch %s (was %s).\n")
 					: _("Deleted branch %s (was %s).\n"),
@@ -824,6 +850,185 @@ static int branch_pushes_to_upstream(struct branch *branch,
 	return ret;
 }
 
+struct branch_change {
+	char *path;
+	struct object_id branch_oid;
+	unsigned short branch_mode;
+};
+
+struct landing_search {
+	const char *branch_name;
+	struct commit *rev;
+	struct commit *upstream;
+	struct branch_change *changes;
+	size_t changes_nr, changes_alloc;
+};
+
+define_commit_slab(changed_paths, struct string_list *);
+
+static void collect_branch_changes(struct landing_search *search,
+				   struct commit *base)
+{
+	struct diff_options opt;
+
+	repo_diff_setup(the_repository, &opt);
+	opt.flags.recursive = 1;
+	opt.output_format = DIFF_FORMAT_NO_OUTPUT;
+	diff_setup_done(&opt);
+	diff_tree_oid(get_commit_tree_oid(base),
+		      get_commit_tree_oid(search->rev), "", &opt);
+	for (int i = 0; i < diff_queued_diff.nr; i++) {
+		struct diff_filepair *p = diff_queued_diff.queue[i];
+		struct branch_change *change;
+
+		ALLOC_GROW(search->changes, search->changes_nr + 1,
+			   search->changes_alloc);
+		change = &search->changes[search->changes_nr++];
+		change->path = xstrdup(p->two->path);
+		oidcpy(&change->branch_oid, DIFF_FILE_VALID(p->two) ?
+		       &p->two->oid : null_oid(the_hash_algo));
+		change->branch_mode = p->two->mode;
+	}
+	diff_flush(&opt);
+}
+
+static int change_landed(const struct branch_change *change,
+			 struct commit *commit)
+{
+	struct object_id oid;
+	unsigned short mode;
+
+	if (get_tree_entry(the_repository, get_commit_tree_oid(commit),
+			   change->path, &oid, &mode))
+		return is_null_oid(&change->branch_oid);
+	return oideq(&oid, &change->branch_oid) &&
+	       mode == change->branch_mode;
+}
+
+static int changes_landed(struct landing_search *search,
+			  struct commit *commit)
+{
+	for (size_t i = 0; i < search->changes_nr; i++)
+		if (!change_landed(&search->changes[i], commit))
+			return 0;
+	return 1;
+}
+
+static struct string_list *changed_paths_of(struct changed_paths *cache,
+					    struct diff_options *opt,
+					    struct commit *commit)
+{
+	struct string_list **paths = changed_paths_at(cache, commit);
+	struct commit *parent;
+
+	if (*paths)
+		return *paths;
+
+	CALLOC_ARRAY(*paths, 1);
+	string_list_init_dup(*paths);
+	parent = commit->parents ? commit->parents->item : NULL;
+	if (parent && repo_parse_commit(the_repository, parent))
+		die(_("could not parse commit %s"),
+		    oid_to_hex(&parent->object.oid));
+	diff_tree_oid(parent ? get_commit_tree_oid(parent) : NULL,
+		      get_commit_tree_oid(commit), "", opt);
+	for (int i = 0; i < diff_queued_diff.nr; i++) {
+		struct diff_filepair *p = diff_queued_diff.queue[i];
+
+		string_list_append(*paths, p->two->path);
+	}
+	diff_flush(opt);
+	string_list_sort(*paths);
+	return *paths;
+}
+
+static void free_changed_paths(struct string_list **paths)
+{
+	if (!*paths)
+		return;
+	string_list_clear(*paths, 0);
+	free(*paths);
+}
+
+static int touches_changes(struct string_list *paths,
+			   const struct landing_search *search)
+{
+	for (size_t i = 0; i < search->changes_nr; i++)
+		if (string_list_has_string(paths, search->changes[i].path))
+			return 1;
+	return 0;
+}
+
+static struct commit *find_landed_commit(struct landing_search *search,
+					 struct changed_paths *cache,
+					 struct diff_options *opt)
+{
+	struct commit *commit, *landed = NULL;
+	struct strvec args = STRVEC_INIT;
+	struct rev_info revs;
+
+	strvec_pushl(&args, "rev-list", "--reverse",
+		     oid_to_hex(&search->upstream->object.oid), NULL);
+	strvec_pushf(&args, "^%s", oid_to_hex(&search->rev->object.oid));
+
+	repo_init_revisions(the_repository, &revs, NULL);
+	setup_revisions_from_strvec(&args, &revs, NULL);
+	if (prepare_revision_walk(&revs))
+		die(_("revision walk setup failed"));
+	while (!landed && (commit = get_revision(&revs)))
+		if (touches_changes(changed_paths_of(cache, opt, commit),
+				    search) &&
+		    changes_landed(search, commit))
+			landed = commit;
+	release_revisions(&revs);
+	clear_commit_marks(search->upstream, ALL_REV_FLAGS);
+	clear_commit_marks(search->rev, ALL_REV_FLAGS);
+	strvec_clear(&args);
+	return landed;
+}
+
+static void find_landed_commits(struct landing_search *searches,
+				size_t searches_nr,
+				struct strmap *landed_commits)
+{
+	struct changed_paths cache;
+	struct diff_options opt;
+
+	for (size_t i = 0; i < searches_nr; i++) {
+		struct landing_search *search = &searches[i];
+		struct commit_list *merge_bases = NULL;
+
+		if (repo_get_merge_bases(the_repository, search->upstream,
+					 search->rev, &merge_bases) < 0)
+			exit(128);
+		if (merge_bases)
+			collect_branch_changes(search, merge_bases->item);
+		commit_list_free(merge_bases);
+	}
+
+	repo_diff_setup(the_repository, &opt);
+	opt.flags.recursive = 1;
+	opt.output_format = DIFF_FORMAT_NO_OUTPUT;
+	opt.no_free = 1;
+	diff_setup_done(&opt);
+	init_changed_paths(&cache);
+
+	for (size_t i = 0; i < searches_nr; i++) {
+		struct commit *landed;
+
+		if (!searches[i].changes_nr)
+			continue;
+		landed = find_landed_commit(&searches[i], &cache, &opt);
+		if (landed)
+			strmap_put(landed_commits, searches[i].branch_name,
+				   landed);
+	}
+
+	deep_clear_changed_paths(&cache, free_changed_paths);
+	opt.no_free = 0;
+	diff_free(&opt);
+}
+
 static bool branch_opted_out(struct strbuf *key, const char *branch_name,
 			     unsigned int flags)
 {
@@ -849,6 +1054,9 @@ static int delete_merged_branches(const struct strvec *upstreams,
 	struct ref_array candidates = { 0 };
 	struct strset deletable_branch_names = STRSET_INIT;
 	struct strset protected_branch_names = STRSET_INIT;
+	struct strmap landed_commits = STRMAP_INIT;
+	struct landing_search *searches = NULL;
+	size_t searches_nr = 0, searches_alloc = 0;
 	struct strvec branches_to_delete = STRVEC_INIT;
 	struct strbuf key = STRBUF_INIT;
 	struct hashmap_iter iter;
@@ -883,13 +1091,37 @@ static int delete_merged_branches(const struct strvec *upstreams,
 			continue;
 		if (check_branch_commit(branch_name, branch_name,
 					&candidates.items[i]->objectname, NULL,
-					FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
+					FILTER_REFS_BRANCHES,
+					DELETE_BRANCH_SKIP_UNMERGED)) {
+			struct commit *rev = lookup_commit_reference(
+				the_repository, &candidates.items[i]->objectname);
+			struct commit *upstream = lookup_commit_reference_by_name(
+				upstream_refname);
+
+			if (!rev || !upstream)
+				continue;
+			ALLOC_GROW(searches, searches_nr + 1, searches_alloc);
+			searches[searches_nr++] = (struct landing_search) {
+				.branch_name = branch_name,
+				.rev = rev,
+				.upstream = upstream,
+			};
 			continue;
+		}
 
 		if (!branch_opted_out(&key, branch_name, flags))
 			strset_add(&deletable_branch_names, branch_name);
 	}
 
+	find_landed_commits(searches, searches_nr, &landed_commits);
+	for (size_t i = 0; i < searches_nr; i++) {
+		const char *branch_name = searches[i].branch_name;
+
+		if (strmap_contains(&landed_commits, branch_name) &&
+		    !branch_opted_out(&key, branch_name, flags))
+			strset_add(&deletable_branch_names, branch_name);
+	}
+
 	protect_stacked_branch_bases(refs, &deletable_branch_names,
 				     &protected_branch_names);
 
@@ -901,7 +1133,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
 				      FILTER_REFS_BRANCHES,
 				      DELETE_BRANCH_SKIP_UNMERGED |
 				      DELETE_BRANCH_NO_HEAD_FALLBACK |
-				      flags);
+				      flags, &landed_commits);
 
 	if (!ret && !(flags & DELETE_BRANCH_DRY_RUN))
 		clear_deleted_upstreams(&protected_branch_names,
@@ -909,6 +1141,13 @@ static int delete_merged_branches(const struct strvec *upstreams,
 
 	strbuf_release(&key);
 	strvec_clear(&branches_to_delete);
+	for (size_t i = 0; i < searches_nr; i++) {
+		for (size_t j = 0; j < searches[i].changes_nr; j++)
+			free(searches[i].changes[j].path);
+		free(searches[i].changes);
+	}
+	free(searches);
+	strmap_clear(&landed_commits, 0);
 	strset_clear(&protected_branch_names);
 	strset_clear(&deletable_branch_names);
 	ref_array_clear(&candidates);
@@ -1141,7 +1380,7 @@ int cmd_branch(int argc,
 			die(_("branch name required"));
 		ret = delete_branches(argc, argv, filter.kind,
 				      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
-				      (quiet ? DELETE_BRANCH_QUIET : 0));
+				      (quiet ? DELETE_BRANCH_QUIET : 0), NULL);
 		goto out;
 	} else if (delete_merged.nr) {
 		ret = delete_merged_branches(&delete_merged, argv,
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index cdb6c6a634..5ba19a34e2 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1979,6 +1979,143 @@ test_expect_success '--delete-merged deletes only selected merged branches' '
 	)
 '
 
+push_topic () {
+	branch=$1 &&
+	shift &&
+	(
+		cd repo &&
+		git checkout -b "$branch" --track origin/next &&
+		for commit in "$@"
+		do
+			test_commit "$commit" || return 1
+		done &&
+		git push origin "$branch" &&
+		git checkout --detach
+	)
+}
+
+squash_merge_upstream () {
+	(
+		cd upstream &&
+		git checkout next &&
+		git merge --squash "$1" &&
+		git commit -m "Squash merge of $1" &&
+		git checkout main
+	)
+}
+
+rebase_merge_upstream () {
+	(
+		cd upstream &&
+		git rebase next "$1" &&
+		git checkout next &&
+		git merge --ff-only "$1" &&
+		git checkout main
+	)
+}
+
+test_expect_success '--delete-merged deletes a rebase merged branch' '
+	setup_repo_for_delete_merged &&
+	push_topic rebased rebased-one rebased-two &&
+	(
+		cd upstream &&
+		git checkout next &&
+		test_commit before-rebase &&
+		git checkout main
+	) &&
+	rebase_merge_upstream rebased &&
+	landed=$(git -C upstream rev-parse --short next) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short rebased) &&
+
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		echo "Deleted branch rebased (was $sha, landed as $landed)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		EOF
+	)
+'
+
+test_expect_success '--delete-merged deletes a squash merged branch' '
+	setup_repo_for_delete_merged &&
+	push_topic squashed squashed-one squashed-two &&
+	push_topic partial lands-upstream stays-local &&
+	squash_merge_upstream partial~1 &&
+	squash_merge_upstream squashed &&
+	squash=$(git -C upstream rev-parse --short next) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short squashed) &&
+
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		echo "Deleted branch squashed (was $sha, landed as $squash)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		partial
+		EOF
+	)
+'
+
+test_expect_success '--delete-merged deletes a squash merged branch that was reverted' '
+	setup_repo_for_delete_merged &&
+	push_topic reverted reverted-work &&
+	squash_merge_upstream reverted &&
+	squash=$(git -C upstream rev-parse --short next) &&
+	(
+		cd upstream &&
+		git checkout next &&
+		git revert --no-edit HEAD &&
+		git checkout main
+	) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short reverted) &&
+
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		echo "Deleted branch reverted (was $sha, landed as $squash)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		EOF
+	)
+'
+
+test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' '
+	setup_repo_for_delete_merged &&
+	push_topic a-unmerged unmerged-work &&
+	push_topic b-merged merged-work &&
+	(
+		cd upstream &&
+		git checkout next &&
+		test_commit upstream-work &&
+		git merge --no-ff b-merged &&
+		git checkout main
+	) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short b-merged) &&
+
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		echo "Deleted branch b-merged (was $sha)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		a-unmerged
+		main
+		EOF
+	)
+'
+
 test_expect_success '--delete-merged keeps main despite a different default push remote' '
 	setup_repo_for_delete_merged &&
 	create_merged_branch on-next &&
-- 
gitgitgadget


^ permalink raw reply related	[flat|nested] 60+ messages in thread

* [PATCH v3 3/3] branch: add --squashed to find squash merged branches
  2026-10-21 12:28 ` [PATCH v3 0/3] " Harald Nordgren via GitGitGadget
  2026-10-21 12:28   ` [PATCH v3 1/3] branch: move the deleteMerged opt-out check into a helper Harald Nordgren via GitGitGadget
  2026-10-21 12:28   ` [PATCH v3 2/3] branch: let --delete-merged find rebase merged branches Harald Nordgren via GitGitGadget
@ 2026-10-21 12:28   ` Harald Nordgren via GitGitGadget
  2026-10-21 17:35   ` [PATCH v3 0/3] branch: let --delete-merged " Jasper Mayone
                     ` (2 subsequent siblings)
  5 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-10-21 12:28 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood, Jasper Mayone,
	Harald Nordgren, Harald Nordgren

From: Harald Nordgren <haraldnordgren@gmail.com>

A branch that upstream integrated with

    git merge --squash topic
    git commit

after upstream had also changed the files the branch touches is still
kept by "git branch --delete-merged". The squash commit has neither the
branch's version of those files nor the fork point's, so the previous
check cannot see that the work landed. This happens all the time on
hosting services, where the "Squash and merge" button on GitHub lands
pull requests this way while the main branch keeps moving. A rebase
merge onto such changes looks the same.

Add --squashed, which for each such file merges the branch's version
into the upstream one and treats the change as landed when that leaves
the upstream version unchanged. This has to read the files, so it is
slower, and in a partial clone it may download blobs. It is therefore
only done when asked for.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc | 11 +++-
 builtin/branch.c              | 96 ++++++++++++++++++++++++++++-------
 t/t3200-branch.sh             | 55 ++++++++++++++++++++
 3 files changed, 144 insertions(+), 18 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 3d6549b977..3b414be171 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
 git branch (-c|-C) [<old-branch>] <new-branch>
 git branch (-d|-D) [-r] <branch-name>...
 git branch --edit-description [<branch-name>]
-git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]
+git branch [--dry-run] [--squashed] (--delete-merged <pattern>)... [<branch-pattern>...]
 
 DESCRIPTION
 -----------
@@ -244,6 +244,15 @@ remove it anyway.
 	sanity-checking a wide pattern like `'origin/*'` before
 	committing to the deletion.
 
+`--squashed`::
+	With `--delete-merged`, also delete a branch that was squash or
+	rebase merged after upstream changed the same files. Upstream
+	then has neither the branch's version of those files nor the fork
+	point's, so the branch's version is merged into upstream's, and
+	the file counts as landed when that leaves upstream's version
+	unchanged. This reads those files, so it takes longer and may
+	download blobs in a partial clone.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 2a2e370401..9ac4dcb162 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -33,8 +33,10 @@
 #include "diff.h"
 #include "diffcore.h"
 #include "hex.h"
+#include "merge-ll.h"
 #include "revision.h"
 #include "tree-walk.h"
+#include "xdiff-interface.h"
 
 static const char * const builtin_branch_usage[] = {
 	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
@@ -207,6 +209,7 @@ enum delete_branch_flags {
 	DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
 	DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
 	DELETE_BRANCH_DRY_RUN = (1 << 4),
+	DELETE_BRANCH_SQUASHED = (1 << 5),
 };
 
 static int check_branch_commit(const char *branchname, const char *refname,
@@ -850,10 +853,17 @@ static int branch_pushes_to_upstream(struct branch *branch,
 	return ret;
 }
 
+enum change_state {
+	CHANGE_MISSING,
+	CHANGE_LANDED,
+	CHANGE_NEEDS_MERGE,
+};
+
 struct branch_change {
 	char *path;
-	struct object_id branch_oid;
+	struct object_id base_oid, branch_oid, upstream_oid;
 	unsigned short branch_mode;
+	enum change_state state;
 };
 
 struct landing_search {
@@ -885,6 +895,8 @@ static void collect_branch_changes(struct landing_search *search,
 			   search->changes_alloc);
 		change = &search->changes[search->changes_nr++];
 		change->path = xstrdup(p->two->path);
+		oidcpy(&change->base_oid, DIFF_FILE_VALID(p->one) ?
+		       &p->one->oid : null_oid(the_hash_algo));
 		oidcpy(&change->branch_oid, DIFF_FILE_VALID(p->two) ?
 		       &p->two->oid : null_oid(the_hash_algo));
 		change->branch_mode = p->two->mode;
@@ -892,25 +904,67 @@ static void collect_branch_changes(struct landing_search *search,
 	diff_flush(&opt);
 }
 
-static int change_landed(const struct branch_change *change,
-			 struct commit *commit)
+static int merge_keeps_upstream(const struct branch_change *change)
+{
+	mmfile_t base, upstream, branch;
+	mmfile_t result = { 0 };
+	int ret;
+
+	read_mmblob(&base, the_repository->objects, &change->base_oid);
+	read_mmblob(&upstream, the_repository->objects, &change->upstream_oid);
+	read_mmblob(&branch, the_repository->objects, &change->branch_oid);
+	ret = ll_merge(&result, change->path, &base, "base",
+		       &upstream, "upstream", &branch, "branch",
+		       the_repository->index, NULL) == LL_MERGE_OK &&
+	      result.size == upstream.size &&
+	      !memcmp(result.ptr, upstream.ptr, upstream.size);
+
+	free(base.ptr);
+	free(upstream.ptr);
+	free(branch.ptr);
+	free(result.ptr);
+	return ret;
+}
+
+static enum change_state change_state_at(struct branch_change *change,
+					 struct commit *commit)
 {
-	struct object_id oid;
 	unsigned short mode;
 
 	if (get_tree_entry(the_repository, get_commit_tree_oid(commit),
-			   change->path, &oid, &mode))
-		return is_null_oid(&change->branch_oid);
-	return oideq(&oid, &change->branch_oid) &&
-	       mode == change->branch_mode;
+			   change->path, &change->upstream_oid, &mode))
+		return is_null_oid(&change->branch_oid) ?
+			CHANGE_LANDED : CHANGE_MISSING;
+	if (oideq(&change->upstream_oid, &change->branch_oid))
+		return mode == change->branch_mode ?
+			CHANGE_LANDED : CHANGE_MISSING;
+	if (is_null_oid(&change->base_oid) ||
+	    is_null_oid(&change->branch_oid) ||
+	    oideq(&change->upstream_oid, &change->base_oid) ||
+	    mode != change->branch_mode || !S_ISREG(mode))
+		return CHANGE_MISSING;
+	return CHANGE_NEEDS_MERGE;
 }
 
 static int changes_landed(struct landing_search *search,
-			  struct commit *commit)
+			  struct commit *commit, unsigned int flags)
 {
-	for (size_t i = 0; i < search->changes_nr; i++)
-		if (!change_landed(&search->changes[i], commit))
+	for (size_t i = 0; i < search->changes_nr; i++) {
+		struct branch_change *change = &search->changes[i];
+
+		change->state = change_state_at(change, commit);
+		if (change->state == CHANGE_MISSING ||
+		    (change->state == CHANGE_NEEDS_MERGE &&
+		     !(flags & DELETE_BRANCH_SQUASHED)))
 			return 0;
+	}
+	for (size_t i = 0; i < search->changes_nr; i++) {
+		struct branch_change *change = &search->changes[i];
+
+		if (change->state == CHANGE_NEEDS_MERGE &&
+		    !merge_keeps_upstream(change))
+			return 0;
+	}
 	return 1;
 }
 
@@ -961,7 +1015,8 @@ static int touches_changes(struct string_list *paths,
 
 static struct commit *find_landed_commit(struct landing_search *search,
 					 struct changed_paths *cache,
-					 struct diff_options *opt)
+					 struct diff_options *opt,
+					 unsigned int flags)
 {
 	struct commit *commit, *landed = NULL;
 	struct strvec args = STRVEC_INIT;
@@ -978,7 +1033,7 @@ static struct commit *find_landed_commit(struct landing_search *search,
 	while (!landed && (commit = get_revision(&revs)))
 		if (touches_changes(changed_paths_of(cache, opt, commit),
 				    search) &&
-		    changes_landed(search, commit))
+		    changes_landed(search, commit, flags))
 			landed = commit;
 	release_revisions(&revs);
 	clear_commit_marks(search->upstream, ALL_REV_FLAGS);
@@ -989,7 +1044,8 @@ static struct commit *find_landed_commit(struct landing_search *search,
 
 static void find_landed_commits(struct landing_search *searches,
 				size_t searches_nr,
-				struct strmap *landed_commits)
+				struct strmap *landed_commits,
+				unsigned int flags)
 {
 	struct changed_paths cache;
 	struct diff_options opt;
@@ -1018,7 +1074,7 @@ static void find_landed_commits(struct landing_search *searches,
 
 		if (!searches[i].changes_nr)
 			continue;
-		landed = find_landed_commit(&searches[i], &cache, &opt);
+		landed = find_landed_commit(&searches[i], &cache, &opt, flags);
 		if (landed)
 			strmap_put(landed_commits, searches[i].branch_name,
 				   landed);
@@ -1113,7 +1169,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
 			strset_add(&deletable_branch_names, branch_name);
 	}
 
-	find_landed_commits(searches, searches_nr, &landed_commits);
+	find_landed_commits(searches, searches_nr, &landed_commits, flags);
 	for (size_t i = 0; i < searches_nr; i++) {
 		const char *branch_name = searches[i].branch_name;
 
@@ -1221,6 +1277,7 @@ int cmd_branch(int argc,
 	    unset_upstream = 0, show_current = 0, edit_description = 0;
 	struct strvec delete_merged = STRVEC_INIT;
 	int dry_run = 0;
+	int squashed = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -1279,6 +1336,8 @@ int cmd_branch(int argc,
 			PARSE_OPT_NONEG, parse_opt_strvec),
 		OPT_BOOL(0, "dry-run", &dry_run,
 			N_("with --delete-merged, only print which branches would be deleted")),
+		OPT_BOOL(0, "squashed", &squashed,
+			N_("with --delete-merged, also find branches merged after upstream changed their files")),
 		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
 		OPT_MERGED(&filter, N_("print only branches that are merged")),
 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1343,6 +1402,8 @@ int cmd_branch(int argc,
 
 	if (dry_run && !delete_merged.nr)
 		die(_("--dry-run requires --delete-merged"));
+	if (squashed && !delete_merged.nr)
+		die(_("--squashed requires --delete-merged"));
 
 	if (recurse_submodules_explicit) {
 		if (!submodule_propagate_branches)
@@ -1385,7 +1446,8 @@ int cmd_branch(int argc,
 	} else if (delete_merged.nr) {
 		ret = delete_merged_branches(&delete_merged, argv,
 					     (quiet ? DELETE_BRANCH_QUIET : 0) |
-					     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
+					     (dry_run ? DELETE_BRANCH_DRY_RUN : 0) |
+					     (squashed ? DELETE_BRANCH_SQUASHED : 0));
 		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 5ba19a34e2..f3980106fa 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2089,6 +2089,56 @@ test_expect_success '--delete-merged deletes a squash merged branch that was rev
 	)
 '
 
+test_expect_success '--delete-merged --squashed deletes a squash merged branch after upstream changed the same file' '
+	(
+		cd upstream &&
+		git checkout next &&
+		test_write_lines 1 2 3 4 5 6 7 8 9 10 >shared &&
+		git add shared &&
+		git commit -m "add shared" &&
+		git checkout main
+	) &&
+	setup_repo_for_delete_merged &&
+	(
+		cd repo &&
+		git checkout -b edits-shared --track origin/next &&
+		test_write_lines 1 two 3 4 5 6 7 8 9 10 >shared &&
+		git commit -a -m "edit line 2" &&
+		git push origin edits-shared &&
+		git checkout --detach
+	) &&
+	(
+		cd upstream &&
+		git checkout next &&
+		test_write_lines 1 2 3 4 five 6 7 8 9 10 >shared &&
+		git commit -a -m "edit line 5" &&
+		git checkout main
+	) &&
+	squash_merge_upstream edits-shared &&
+	squash=$(git -C upstream rev-parse --short next) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short edits-shared) &&
+
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		test_must_be_empty actual &&
+		check_branches <<-\EOF &&
+		edits-shared
+		main
+		EOF
+
+		git branch --delete-merged origin/next --squashed \
+			>actual 2>&1 &&
+		echo "Deleted branch edits-shared (was $sha, landed as $squash)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		EOF
+	)
+'
+
 test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' '
 	setup_repo_for_delete_merged &&
 	push_topic a-unmerged unmerged-work &&
@@ -2310,6 +2360,11 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" '
 	)
 '
 
+test_expect_success '--squashed without --delete-merged is rejected' '
+	test_must_fail git -C forked branch --squashed 2>err &&
+	test_grep "requires --delete-merged" err
+'
+
 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
-- 
gitgitgadget

^ permalink raw reply related	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-21 12:28 ` [PATCH v3 0/3] " Harald Nordgren via GitGitGadget
                     ` (2 preceding siblings ...)
  2026-10-21 12:28   ` [PATCH v3 3/3] branch: add --squashed to find squash " Harald Nordgren via GitGitGadget
@ 2026-10-21 17:35   ` Jasper Mayone
  2026-10-21 18:51     ` Harald Nordgren
  2026-10-22  5:49   ` Junio C Hamano
  2026-10-22 12:59   ` D. Ben Knoble
  5 siblings, 1 reply; 60+ messages in thread
From: Jasper Mayone @ 2026-10-21 17:35 UTC (permalink / raw)
  To: haraldnordgren
  Cc: gitster, git, ben.knoble, kristofferhaugsbakk, phillip.wood123,
	Jasper Mayone

"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:

>  * By default a branch counts as merged when an upstream commit has its
>    exact version of every file it changed. This never reads file contents,
>    so it stays cheap in a partial clone. It finds rebase merges, and squash
>    merges where upstream left those files alone.
>  * Replace --no-squashed with an opt-in --squashed, which also finds
>    branches merged after upstream changed the same files by merging those
>    files.

Thanks for the new round. I ran v3 (2befb182dc) on the same generated
repositories as v2, and added one case for a squash that landed after
upstream changed another line of the same file. t3200 passes, and the
build is clean with DEVELOPER=1.

The results match the cover letter. Without --squashed, v3 deletes the
squash merges, the GitHub-style squash, the rebase merge, the rename,
and the squash that was later reverted. It keeps the new case.
--squashed also deletes the new case, and "landed as" names the correct
commit each time. A branch whose work only partly landed is kept, with
or without --squashed.

But the false positive from my v2 report is still there. A branch that
was never merged is deleted because another author made the same change
in an unrelated commit. In v2 I could turn that off with --no-squashed.
In v3 the cheap check finds it, so it happens with no option at all.

That is the case Junio called "irrelevant", and he suggested that
"--delete-merged" stay the strict "my commits are in upstream" check.
The file contents of such a branch are in upstream, but its own commits
and messages are not, and the user did not ask for the looser meaning.

So I would still prefer the whole looser check behind one opt-in, with
a config variable for the default. The cheap check and --squashed could
then be two levels of that one option, for example:

    git branch --delete-merged origin/main --landed=files
    git branch --delete-merged origin/main --landed=merge

Users who land everything through "Squash and merge" set it once in the
config, and plain --delete-merged keeps its current meaning.

A branch checked out in a worktree is still skipped with no message,
while a branch with deleteMerged=false prints "Skipping". I sent an RFC
for "git worktree list --merged" about that case:

  https://lore.kernel.org/git/20261021173350.67854-1-me@jaspermayone.com/

Thanks,
Jasper

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-21 17:35   ` [PATCH v3 0/3] branch: let --delete-merged " Jasper Mayone
@ 2026-10-21 18:51     ` Harald Nordgren
  2026-10-22 12:55       ` D. Ben Knoble
  0 siblings, 1 reply; 60+ messages in thread
From: Harald Nordgren @ 2026-10-21 18:51 UTC (permalink / raw)
  To: Jasper Mayone
  Cc: gitster, git, ben.knoble, kristofferhaugsbakk, phillip.wood123

> But the false positive from my v2 report is still there. A branch that
> was never merged is deleted because another author made the same change
> in an unrelated commit. In v2 I could turn that off with --no-squashed.
> In v3 the cheap check finds it, so it happens with no option at all.
>
> That is the case Junio called "irrelevant", and he suggested that
> "--delete-merged" stay the strict "my commits are in upstream" check.
> The file contents of such a branch are in upstream, but its own commits
> and messages are not, and the user did not ask for the looser meaning.

My sincere apology for skipping over your post before.

My branch being pruned because someone else did the same thing would
indeed feel weird. And it could be stopped by doing an author check.

But I question if it’s realistic at all. The code has to be exactly
the same, and I don’t think I have ever seen that happen, I would call
this an extremely rare edge case.

And if it did happen and it was *incorrectly* pruned, it is still not
completely lost, because by definition it is in the upstream already.
So why would you actually need that branch? (But it won’t happen)

> So I would still prefer the whole looser check behind one opt-in, with
> a config variable for the default. The cheap check and --squashed could
> then be two levels of that one option, for example:
>
>     git branch --delete-merged origin/main --landed=files
>     git branch --delete-merged origin/main --landed=merge
>
> Users who land everything through "Squash and merge" set it once in the
> config, and plain --delete-merged keeps its current meaning.

I think this is pushing it in terms of UX mental load. I will never
run this check without specifying the highest level of pruning. I also
have another topic open to be able to skip the '**' pattern.

It's about giving users the simplest-to-use tool to work with, not
endless configurability for a command that just deletes merged
branches.

> A branch checked out in a worktree is still skipped with no message,
> while a branch with deleteMerged=false prints "Skipping". I sent an RFC
> for "git worktree list --merged" about that case:
>
>   https://lore.kernel.org/git/20261021173350.67854-1-me@jaspermayone.com/

I'll take a look at that after sending this, I haven't yet. Thanks in advance!


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-21 12:28 ` [PATCH v3 0/3] " Harald Nordgren via GitGitGadget
                     ` (3 preceding siblings ...)
  2026-10-21 17:35   ` [PATCH v3 0/3] branch: let --delete-merged " Jasper Mayone
@ 2026-10-22  5:49   ` Junio C Hamano
  2026-10-22  7:18     ` Harald Nordgren
  2026-10-22 12:59   ` D. Ben Knoble
  5 siblings, 1 reply; 60+ messages in thread
From: Junio C Hamano @ 2026-10-22  5:49 UTC (permalink / raw)
  To: Harald Nordgren via GitGitGadget
  Cc: git, Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood,
	Jasper Mayone, Harald Nordgren

"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:

> This series finds the first upstream commit since the fork point that has
> the branch's version of every file the branch changed, comparing only object
> ids. With --squashed, a file that upstream also changed counts when merging
> the branch's version into upstream's leaves it unchanged. Timed on my clone
> with 65 branches, this takes 0.5s.

It would be very surprising for the unsuspecting users to loosen the
behaviour of "--delete-merged" like this series does.  As requested
elsewhere [*], do not change the behaviour of the "--delete-merged"
when no other "please make it looser" option requests otherwise.

 * https://lore.kernel.org/git/20261019044005.99247-1-me@jaspermayone.com/

The attached new test illustrates the extent of damage.  We add a
file on a private branch while the upstream adds two files, one of
which has an identical contents to our file, under author identity
different from ours.  If we run "--delete-merged" in our repository,
we should not lose our branch that we never pushed, or asked them to
pull, or anything.  But when applied on top of this series, we do.

With the following applied on top of the series, with all changes to
builtin/branch.c reverted, it passes.  Of course, later tests that
assumes the looser criteria is used without an explicit opt-in will
fail with your code changes reverted, but that is to be expected.

 t/t3200-branch.sh | 33 +++++++++++++++++++++++++++++++++
 1 file changed, 33 insertions(+)

diff --git c/t/t3200-branch.sh w/t/t3200-branch.sh
index f3980106fa..adbcf937b5 100755
--- c/t/t3200-branch.sh
+++ w/t/t3200-branch.sh
@@ -1925,6 +1925,39 @@ check_branches () {
 	test_cmp expect actual
 }
 
+test_expect_success '--delete-merged does not delete unrelated branches' '
+	setup_repo_for_delete_merged &&
+	(
+		cd repo &&
+		git checkout -b unrelated-topic --track origin/next &&
+		test_write_lines 1 2 3 4 >file &&
+		test_write_lines 5 6 7 >elif &&
+		git add file elif &&
+		git commit -m "add file and elif" &&
+		git push origin unrelated-topic &&
+		git checkout --detach
+	) &&
+	(
+		cd upstream &&
+		git checkout next &&
+		test_write_lines 1 2 3 4 >file &&
+		git add file &&
+		git commit --author="unrelated user <uu@example.com>" \
+			-m "unrelated commit adding an identical file" &&
+		git checkout main
+	) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		test_must_be_empty actual &&
+		check_branches <<-\EOF
+		main
+		unrelated-topic
+		EOF
+	)
+'
+
 test_expect_success '--delete-merged keeps cloned main without explicit push configuration' '
 	setup_repo_for_delete_merged &&
 	(

^ permalink raw reply related	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-22  5:49   ` Junio C Hamano
@ 2026-10-22  7:18     ` Harald Nordgren
  2026-10-22 16:33       ` Junio C Hamano
  0 siblings, 1 reply; 60+ messages in thread
From: Harald Nordgren @ 2026-10-22  7:18 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	D. Ben Knoble, Phillip Wood, Jasper Mayone

> It would be very surprising for the unsuspecting users to loosen the
> behaviour of "--delete-merged" like this series does.  As requested
> elsewhere [*], do not change the behaviour of the "--delete-merged"
> when no other "please make it looser" option requests otherwise.
>
>  * https://lore.kernel.org/git/20261019044005.99247-1-me@jaspermayone.com/
>
> The attached new test illustrates the extent of damage.  We add a
> file on a private branch while the upstream adds two files, one of
> which has an identical contents to our file, under author identity
> different from ours.  If we run "--delete-merged" in our repository,
> we should not lose our branch that we never pushed, or asked them to
> pull, or anything.  But when applied on top of this series, we do.

Ok.

So do we go with `--delete-irrelevant` or
`--delete-irrelevant=squashed,rebased` to give us two levels here? I'd
rather do the former.


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-21 18:51     ` Harald Nordgren
@ 2026-10-22 12:55       ` D. Ben Knoble
  2026-10-22 14:41         ` Harald Nordgren
  0 siblings, 1 reply; 60+ messages in thread
From: D. Ben Knoble @ 2026-10-22 12:55 UTC (permalink / raw)
  To: Harald Nordgren
  Cc: Jasper Mayone, gitster, git, kristofferhaugsbakk, phillip.wood123

On Wed, Oct 21, 2026 at 2:52 PM Harald Nordgren
<haraldnordgren@gmail.com> wrote:
> My branch being pruned because someone else did the same thing would
> indeed feel weird. And it could be stopped by doing an author check.
>
> But I question if it’s realistic at all. The code has to be exactly
> the same, and I don’t think I have ever seen that happen, I would call
> this an extremely rare edge case.

I have seen this happen when 2 PRs contain the same "npm update"
commits and are rebased onto main: whoever gets there first wins. If
the commits contained additional changes, then the later-rebased
commit loses some changes because they were handled prior, but (when
doing so via GitHub) the commit message is unchanged. I find this a
bit confusing, tbh!

But, I will say it is rare. Perhaps not "extremely" :)

-- 
D. Ben Knoble

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-21 12:28 ` [PATCH v3 0/3] " Harald Nordgren via GitGitGadget
                     ` (4 preceding siblings ...)
  2026-10-22  5:49   ` Junio C Hamano
@ 2026-10-22 12:59   ` D. Ben Knoble
  5 siblings, 0 replies; 60+ messages in thread
From: D. Ben Knoble @ 2026-10-22 12:59 UTC (permalink / raw)
  To: Harald Nordgren via GitGitGadget
  Cc: git, Kristoffer Haugsbakk, Phillip Wood, Jasper Mayone,
	Harald Nordgren

On Wed, Oct 21, 2026 at 8:28 AM Harald Nordgren via GitGitGadget
<gitgitgadget@gmail.com> wrote:
>
> Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are
> never deleted by git branch --delete-merged, because their tips are not
> reachable from the upstream. This treats such a branch as merged when some
> upstream commit contains all of its changes, and names that commit in the
> output:
>
> Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
>
>
> This series finds the first upstream commit since the fork point that has
> the branch's version of every file the branch changed, comparing only object
> ids. With --squashed, a file that upstream also changed counts when merging
> the branch's version into upstream's leaves it unchanged. Timed on my clone
> with 65 branches, this takes 0.5s.

> Harald Nordgren (3):
>   branch: move the deleteMerged opt-out check into a helper
>   branch: let --delete-merged find rebase merged branches
>   branch: add --squashed to find squash merged branches

I hope you'll have a moment to consider my reply in
<https://lore.kernel.org/git/CALnO6CAsCvJRywkw0WpO1nEhL+yU2-r_4Z2AHdJch1m=NK8=TA@mail.gmail.com/T/#m5244d4a2781578b3058c4cef272a2014291ab85e>
wrt the CLI. Perhaps a modifier in --delete-merged's value would be
better than another --option that modifies how it behaves?

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-22 12:55       ` D. Ben Knoble
@ 2026-10-22 14:41         ` Harald Nordgren
  0 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren @ 2026-10-22 14:41 UTC (permalink / raw)
  To: D. Ben Knoble
  Cc: Jasper Mayone, gitster, git, kristofferhaugsbakk, phillip.wood123

On Thu, Oct 22, 2026 at 2:56 PM D. Ben Knoble <ben.knoble@gmail.com> wrote:
>
> On Wed, Oct 21, 2026 at 2:52 PM Harald Nordgren
> <haraldnordgren@gmail.com> wrote:
> > My branch being pruned because someone else did the same thing would
> > indeed feel weird. And it could be stopped by doing an author check.
> >
> > But I question if it’s realistic at all. The code has to be exactly
> > the same, and I don’t think I have ever seen that happen, I would call
> > this an extremely rare edge case.
>
> I have seen this happen when 2 PRs contain the same "npm update"
> commits and are rebased onto main: whoever gets there first wins. If
> the commits contained additional changes, then the later-rebased
> commit loses some changes because they were handled prior, but (when
> doing so via GitHub) the commit message is unchanged. I find this a
> bit confusing, tbh!
>
> But, I will say it is rare. Perhaps not "extremely" :)
>
> --
> D. Ben Knoble

Good counter example!

But it would have to be something like that then: an automated
deterministic script.


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-22  7:18     ` Harald Nordgren
@ 2026-10-22 16:33       ` Junio C Hamano
  2026-10-22 16:57         ` Junio C Hamano
  2026-10-22 17:44         ` Harald Nordgren
  0 siblings, 2 replies; 60+ messages in thread
From: Junio C Hamano @ 2026-10-22 16:33 UTC (permalink / raw)
  To: Harald Nordgren
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	D. Ben Knoble, Phillip Wood, Jasper Mayone

Harald Nordgren <haraldnordgren@gmail.com> writes:

> So do we go with `--delete-irrelevant` or
> `--delete-irrelevant=squashed,rebased` to give us two levels here? I'd
> rather do the former.

Two comments.

* Naming

I would avoid a negative-sounding word like "irrelevant".  Your work
is almost always relevant, at least to you.

The purpose of the new feature is to find branches that the user no
longer needs to keep because they have been practically merged.
Perhaps --delete-integrated?  I do not know.  The naming matters,
and I hope somebody watching from the sidelines can suggest a better
alternative.

For lack of a better alternative that I can suggest, I will use
"--delete-foo" for the remainder of this message.

* Option value

Another point is that different kinds of detection should probably
form subset-superset relationships, rather than a Venn diagram, to
make them easy to understand and useful.

When you specify --delete-foo=squashed,rebased, do you expect that a
branch that would be removed by --delete-merged (i.e., merged as
identical commits) would be kept because it was not squash-merged
or rebased onto the upstream?  I doubt users would want to make such
a distinction.

I do not personally care about the distinction between rebasing and
squash-merging, but users probably do.  I suspect they expect that a
mode catching squash-merged branches would also recognize a branch
that was straight cherry-picked (or rebased) upstream.

Perhaps the options could be structured like this to be as easy to
understand as possible:

    --delete-foo=merged      Same as --delete-merged.
    --delete-foo=rebased     Above, plus rebased.
    --delete-foo=squashed    Above, plus squash-merged.

Would this be a clearer way to present the feature to users?

Thanks.

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-22 16:33       ` Junio C Hamano
@ 2026-10-22 16:57         ` Junio C Hamano
  2026-10-22 17:31           ` Harald Nordgren
  2026-10-22 17:44         ` Harald Nordgren
  1 sibling, 1 reply; 60+ messages in thread
From: Junio C Hamano @ 2026-10-22 16:57 UTC (permalink / raw)
  To: Harald Nordgren
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	D. Ben Knoble, Phillip Wood, Jasper Mayone

Junio C Hamano <gitster@pobox.com> writes:

> I would avoid a negative-sounding word like "irrelevant".  Your work
> is almost always relevant, at least to you.
>
> The purpose of the new feature is to find branches that the user no
> longer needs to keep because they have been practically merged.
> Perhaps --delete-integrated?  I do not know.  The naming matters,
> and I hope somebody watching from the sidelines can suggest a better
> alternative.
>
> For lack of a better alternative that I can suggest, I will use
> "--delete-foo" for the remainder of this message.

Here is a short list of candidates for "foo" I came up with.

    merged
    integrated
    subsumed
    absorbed
    included
    done

Reusing the existing --delete-merged option by extending it as
--delete-merged[=<level>] is somewhat tempting.  However, we frown
upon options with optional values because our options by default
allow option-arguments to be given as separate arguments.  If we
can, I would prefer to avoid doing so.

Our documentation uses "integrated" to describe practically merging
your work upstream, whether by merging, rebasing, or squash-merging.
However, that word is a tad too long.  "subsumed", "absorbed", and
"included" are shorter alternatives, but none of them is familiar
in our parlance.

The word "done" may be a direct match to the mindset of our users.
Once your branch is integrated upstream and you no longer need to
keep pushing for its inclusion, you are "done" with it.

I dunno.  I hope others will suggest something much better than any
of the above ;-)

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-22 16:57         ` Junio C Hamano
@ 2026-10-22 17:31           ` Harald Nordgren
  2026-10-22 17:44             ` Jasper Mayone
  0 siblings, 1 reply; 60+ messages in thread
From: Harald Nordgren @ 2026-10-22 17:31 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	D. Ben Knoble, Phillip Wood, Jasper Mayone

On Thu, Oct 22, 2026 at 6:57 PM Junio C Hamano <gitster@pobox.com> wrote:
>
> Junio C Hamano <gitster@pobox.com> writes:
>
> > I would avoid a negative-sounding word like "irrelevant".  Your work
> > is almost always relevant, at least to you.
> >
> > The purpose of the new feature is to find branches that the user no
> > longer needs to keep because they have been practically merged.
> > Perhaps --delete-integrated?  I do not know.  The naming matters,
> > and I hope somebody watching from the sidelines can suggest a better
> > alternative.
> >
> > For lack of a better alternative that I can suggest, I will use
> > "--delete-foo" for the remainder of this message.
>
> Here is a short list of candidates for "foo" I came up with.
>
>     merged
>     integrated
>     subsumed
>     absorbed
>     included
>     done


> Reusing the existing --delete-merged option by extending it as
> --delete-merged[=<level>] is somewhat tempting.  However, we frown
> upon options with optional values because our options by default
> allow option-arguments to be given as separate arguments.  If we
> can, I would prefer to avoid doing so.

I'm leaning toward `--delete-merged[=<level>]`. But it's good that you
are pointing this out. It's tempting to unify it like that.

> Our documentation uses "integrated" to describe practically merging
> your work upstream, whether by merging, rebasing, or squash-merging.
> However, that word is a tad too long.  "subsumed", "absorbed", and
> "included" are shorter alternatives, but none of them is familiar
> in our parlance.

I like `integrated` a lot.

> The word "done" may be a direct match to the mindset of our users.
> Once your branch is integrated upstream and you no longer need to
> keep pushing for its inclusion, you are "done" with it.

`done` sounds like something else, like some script that finished. Not
clear to me that it will be removed.

Whereas `integrated` is non-ambiguous, and it's a beautiful word.
Getting your work integrated is the goal.




Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-22 16:33       ` Junio C Hamano
  2026-10-22 16:57         ` Junio C Hamano
@ 2026-10-22 17:44         ` Harald Nordgren
  1 sibling, 0 replies; 60+ messages in thread
From: Harald Nordgren @ 2026-10-22 17:44 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	D. Ben Knoble, Phillip Wood, Jasper Mayone

> I would avoid a negative-sounding word like "irrelevant".  Your work
> is almost always relevant, at least to you.

Yes :-D

> Another point is that different kinds of detection should probably
> form subset-superset relationships, rather than a Venn diagram, to
> make them easy to understand and useful.

> When you specify --delete-foo=squashed,rebased, do you expect that a
> branch that would be removed by --delete-merged (i.e., merged as
> identical commits) would be kept because it was not squash-merged
> or rebased onto the upstream?  I doubt users would want to make such
> a distinction.

Very important point regarding the design.

> I do not personally care about the distinction between rebasing and
> squash-merging, but users probably do.  I suspect they expect that a
> mode catching squash-merged branches would also recognize a branch
> that was straight cherry-picked (or rebased) upstream.
>
> Perhaps the options could be structured like this to be as easy to
> understand as possible:
>
>     --delete-foo=merged      Same as --delete-merged.
>     --delete-foo=rebased     Above, plus rebased.
>     --delete-foo=squashed    Above, plus squash-merged.
>
> Would this be a clearer way to present the feature to users?

Yes, but we can do better on naming, something in this direction?

     --delete-foo=match-hash     #Same as --delete-merged.
     --delete-foo=match-commits  #Above, plus rebased.
     --delete-foo=match-content  #Above, plus squash-merged.

This comes to mind
(https://git-scm.com/docs/git-push#Documentation/git-push.txt-pushdefault),
different `push.default` options can overlap or subsume each other,
and it's also clear that only one will be used at a time.


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-22 17:31           ` Harald Nordgren
@ 2026-10-22 17:44             ` Jasper Mayone
  2026-10-22 17:56               ` Junio C Hamano
  2026-10-23  9:07               ` Harald Nordgren
  0 siblings, 2 replies; 60+ messages in thread
From: Jasper Mayone @ 2026-10-22 17:44 UTC (permalink / raw)
  To: haraldnordgren
  Cc: gitster, gitgitgadget, git, kristofferhaugsbakk, ben.knoble,
	phillip.wood123, Jasper Mayone

Harald Nordgren <haraldnordgren@gmail.com> writes:

> I'm leaning toward `--delete-merged[=<level>]`. But it's good that you
> are pointing this out. It's tempting to unify it like that.

The value of --delete-merged is already taken by the upstream
<pattern>, and it is required. So "--delete-merged=squashed" already
means "branches whose upstream is a ref called squashed" today, and
it cannot also carry a level without breaking that.

> I like `integrated` a lot.

+1 from a user who lands most work with "Squash and merge". It is the
word our documentation already uses, and it does not say how the work
got there, which fits every level of the ladder.

Since the value slot holds the pattern, the level needs its own place.
One shape that keeps each command line simple:

    git branch --delete-merged <pattern>       # strict, as today
    git branch --delete-integrated <pattern>   # rebased and squashed too

with a config variable if someone wants the middle step of Junio's
ladder (merged, then rebased, then squashed) as the meaning of
--delete-integrated.

On the false positive: Ben's "npm update" case, Junio's new test and
my case are the same thing. The upstream commit has the right contents
but was written by someone else. The squash and rebase buttons keep
the author of the pull request on the upstream commit, so requiring
that the landing commit's author also authored a commit on the branch
would reject all three. Work that a maintainer applied with "git am"
would still count, because "git am" keeps the author too. I have not
measured this, so take it as an idea to try.

Thanks,
Jasper

^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-22 17:44             ` Jasper Mayone
@ 2026-10-22 17:56               ` Junio C Hamano
  2026-10-23  9:07               ` Harald Nordgren
  1 sibling, 0 replies; 60+ messages in thread
From: Junio C Hamano @ 2026-10-22 17:56 UTC (permalink / raw)
  To: Jasper Mayone
  Cc: haraldnordgren, gitgitgadget, git, kristofferhaugsbakk,
	ben.knoble, phillip.wood123

Jasper Mayone <me@jaspermayone.com> writes:

> Since the value slot holds the pattern, the level needs its own place.
> One shape that keeps each command line simple:
>
>     git branch --delete-merged <pattern>       # strict, as today
>     git branch --delete-integrated <pattern>   # rebased and squashed too
>
> with a config variable if someone wants the middle step of Junio's
> ladder (merged, then rebased, then squashed) as the meaning of
> --delete-integrated.

I like that without configuration.  Simplicity is the ultimate
sophistication ;-).


^ permalink raw reply	[flat|nested] 60+ messages in thread

* Re: [PATCH v3 0/3] branch: let --delete-merged find squash merged branches
  2026-10-22 17:44             ` Jasper Mayone
  2026-10-22 17:56               ` Junio C Hamano
@ 2026-10-23  9:07               ` Harald Nordgren
  1 sibling, 0 replies; 60+ messages in thread
From: Harald Nordgren @ 2026-10-23  9:07 UTC (permalink / raw)
  To: Jasper Mayone
  Cc: gitster, gitgitgadget, git, kristofferhaugsbakk, ben.knoble,
	phillip.wood123

> > I'm leaning toward `--delete-merged[=<level>]`. But it's good that you
> > are pointing this out. It's tempting to unify it like that.
>
> The value of --delete-merged is already taken by the upstream
> <pattern>, and it is required. So "--delete-merged=squashed" already
> means "branches whose upstream is a ref called squashed" today, and
> it cannot also carry a level without breaking that.

This is a good point and stops the `--delete-merged=squashed`, and
likely also `--delete-integrated=<level>` to keep it uniform.


Harald

^ permalink raw reply	[flat|nested] 60+ messages in thread

* [PATCH v4 0/4] branch: let --delete-merged find squash merged branches
  2026-09-29  7:30 [PATCH] branch: let --delete-merged find squash merged branches Harald Nordgren via GitGitGadget
                   ` (3 preceding siblings ...)
  2026-10-21 12:28 ` [PATCH v3 0/3] " Harald Nordgren via GitGitGadget
@ 2026-10-26  8:33 ` Harald Nordgren via GitGitGadget
  2026-10-26  8:33   ` [PATCH v4 1/4] branch: move the deleteMerged opt-out check into a helper Harald Nordgren via GitGitGadget
                     ` (3 more replies)
  4 siblings, 4 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-10-26  8:33 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood, Jasper Mayone,
	Harald Nordgren

Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are
never deleted by git branch --delete-merged, because their tips are not
reachable from the upstream. This treats such a branch as merged when some
upstream commit contains all of its changes, and names that commit in the
output:

Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).


This series finds the first upstream commit since the fork point that has
the branch's version of every file the branch changed, comparing only object
ids. With --squashed, a file that upstream also changed counts when merging
the branch's version into upstream's leaves it unchanged.

Changes in v4:

 * --delete-merged works as it does on master again: it only deletes
   branches whose tip is reachable from their upstream
 * New --delete-irrelevant <pattern> also deletes branches that were rebase
   or squash merged, including those squashed after upstream changed the
   same files. It replaces --squashed
 * New prep commit lets delete_branches() take a map of where each branch
   landed, so "landed as" is printed there
 * Added Junio's test that --delete-merged keeps a branch when an unrelated
   upstream commit adds an identical file
 * --dry-run works with either option, and the two options can't be combined

Changes in v3:

 * Split into multiple commits, one for rebase merges and one for squash
   merges.
 * By default a branch counts as merged when an upstream commit has its
   exact version of every file it changed. This never reads file contents,
   so it stays cheap in a partial clone. It finds rebase merges, and squash
   merges where upstream left those files alone.
 * Replace --no-squashed with an opt-in --squashed, which also finds
   branches merged after upstream changed the same files by merging those
   files.
 * Add a test for a rebase merge, and check that a squash onto changed files
   is kept without --squashed and deleted with it.

Changes in v2:

 * Diff each upstream commit once and share the result between all branches,
   faster than v1.
 * Fix a branch merged with a merge commit being reported as "landed as" the
   merge when another branch was searched first, and add a test for it. Show
   the topic commit itself in "landed as" for work that arrived through a
   merge.
 * Use git merge --squash as the example in the commit message and mention
   GitHub's squash and rebase buttons.
 * Add --no-squashed to skip the squash search.

Harald Nordgren (4):
  branch: move the deleteMerged opt-out check into a helper
  branch: let delete_branches() name where a branch landed
  branch: add --delete-integrated to delete rebase and squash merged
    branches
  branch: let --delete-integrated find branches merged onto changed
    files

 Documentation/config/branch.adoc |   8 +-
 Documentation/git-branch.adoc    |  22 +-
 builtin/branch.c                 | 355 +++++++++++++++++++++++++++++--
 t/t3200-branch.sh                | 217 +++++++++++++++++++
 4 files changed, 572 insertions(+), 30 deletions(-)


base-commit: 7e34d8174dcda3435d1404c9a30bef46afe6c13e
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2425%2FHaraldNordgren%2Fbranch-delete-squashed-v4
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2425/HaraldNordgren/branch-delete-squashed-v4
Pull-Request: https://github.com/git/git/pull/2425

Range-diff vs v3:

 1:  82413e5cd7 ! 1:  1d01085b75 branch: move the deleteMerged opt-out check into a helper
     @@ Metadata
       ## Commit message ##
          branch: move the deleteMerged opt-out check into a helper
      
     -    The next commit lets "git branch --delete-merged" delete branches whose
     -    tips are not reachable from their upstream, and those must honor
     -    branch.<name>.deleteMerged=false as well. Move the check into a helper
     -    so it can be used in both places.
     +    A later commit adds "git branch --delete-integrated", which deletes
     +    branches whose tips are not reachable from their upstream. Those must
     +    honor branch.<name>.deleteMerged=false as well. Move the check into a
     +    helper so it can be used in both places.
      
          No change in behavior.
      
 -:  ---------- > 2:  592562d7e7 branch: let delete_branches() name where a branch landed
 2:  9a7e88ced6 ! 3:  deb1e649cf branch: let --delete-merged find rebase merged branches
     @@ Metadata
      Author: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Commit message ##
     -    branch: let --delete-merged find rebase merged branches
     +    branch: add --delete-integrated to delete rebase and squash merged branches
      
          A branch that upstream integrated with
      
     @@ Commit message
              git checkout main
              git merge --ff-only topic
      
     +    or with
     +
     +        git merge --squash topic
     +        git commit
     +
          is never deleted by "git branch --delete-merged". This is what the
     -    "Rebase and merge" button on GitHub does. The upstream holds rewritten
     -    copies of the branch's commits, so its tip is not reachable from it and
     -    it looks unmerged forever.
     +    "Rebase and merge" and "Squash and merge" buttons on GitHub do. The
     +    upstream holds rewritten copies of the branch's work, so its tip is not
     +    reachable from it and it looks unmerged forever.
      
     -    Treat such a branch as merged when some upstream commit since the fork
     -    point has the branch's version of every file the branch changed. Name
     -    that commit in the output so the user can see where the work went:
     +    Add --delete-integrated, which does what --delete-merged does and also
     +    deletes a branch when some upstream commit since the fork point has the
     +    branch's version of every file the branch changed. That commit is named
     +    in the output so the user can see where the work went:
      
              Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).
      
     -    The first upstream commit that has them is used, so the branch is
     -    deleted even if upstream later reverted or reworked them. Nothing is
     -    lost, since that commit keeps them in the upstream history. A branch
     -    whose changes only partly landed is kept.
     +    The first such commit is used, so the branch is deleted even if
     +    upstream later reverted or reworked the changes. Nothing is lost, since
     +    that commit keeps them in the upstream history. Who made that commit
     +    does not matter, since the local branch is no longer needed either way.
     +    A branch whose changes only partly landed is kept.
      
     -    The same check finds a squash merge when upstream did not change the
     -    same files between the fork point and the squash. It never reads file
     -    contents, so it stays cheap in a partial clone.
     +    --delete-merged keeps deleting only branches whose own commits are in
     +    the upstream, so it never removes a branch because somebody else made
     +    the same change.
     +
     +    The check never reads file contents, so it stays cheap in a partial
     +    clone.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     + ## Documentation/config/branch.adoc ##
     +@@ Documentation/config/branch.adoc: for details).
     + 
     + `branch.<name>.deleteMerged`::
     + 	If set to `false`, branch _<name>_ is exempt from
     +-	`git branch --delete-merged`.  Useful for a topic branch you
     +-	intend to develop further after an initial round has been
     +-	merged upstream.  Defaults to true.  Explicit deletion via
     +-	`git branch -d` is unaffected.
     ++	`git branch --delete-merged` and `--delete-integrated`.  Useful
     ++	for a topic branch you intend to develop further after an
     ++	initial round has been merged upstream.  Defaults to true.
     ++	Explicit deletion via `git branch -d` is unaffected.
     +
       ## Documentation/git-branch.adoc ##
     -@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.
     +@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
     + git branch (-d|-D) [-r] <branch-name>...
     + git branch --edit-description [<branch-name>]
     + git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]
     ++git branch [--dry-run] (--delete-integrated <pattern>)... [<branch-pattern>...]
     + 
     + DESCRIPTION
     + -----------
     +@@ Documentation/git-branch.adoc: A branch whose work has not yet been merged into its upstream is
     + silently skipped. Delete it with `git branch -D` if you want to
     + remove it anyway.
       
     - `--delete-merged <pattern>`::
     - 	Delete local branches whose configured upstream matches
     --	_<pattern>_, but only when their tip is reachable from that
     --	upstream. In other words, the work on the branch has already
     --	landed on the upstream it tracks, so the local copy is no longer
     --	needed. _<pattern>_ may name a ref, a remote (using the branch its
     --	`HEAD` points at), or a shell-style glob. The option can be
     --	repeated to widen the upstream match.
     -+	_<pattern>_, but only when their work has already landed on that
     -+	upstream, so the local copy is no longer needed. This is the case
     -+	when their tip is reachable from the upstream, or when some
     -+	upstream commit has their version of every file they changed, as
     -+	happens after a rebase merge, even if those changes were later
     -+	reverted. The message for such a branch names the first upstream
     -+	commit that has them. _<pattern>_ may name a ref, a remote (using
     -+	the branch its `HEAD` points at), or a shell-style glob. The
     -+	option can be repeated to widen the upstream match.
     - 	Optional _<branch-pattern>_ arguments limit which local branches
     - 	are considered, e.g. `git branch --delete-merged 'origin/*'
     - 	'topic-*'`.
     ++`--delete-integrated <pattern>`::
     ++	Like `--delete-merged`, but also delete a branch whose changes
     ++	the upstream already has even though its commits are not there,
     ++	as happens after a squash or rebase merge. Such a branch is
     ++	deleted when some upstream commit since the fork point has its
     ++	version of every file it changed, even if those changes were
     ++	later reverted, and the message names the first such commit.
     ++	It does not matter who made that commit. The same rules for
     ++	which branches are kept apply as for `--delete-merged`.
     ++
     + `--dry-run`::
     +-	With `--delete-merged`, print which branches would be
     +-	deleted and exit without touching any ref.  Useful for
     +-	sanity-checking a wide pattern like `'origin/*'` before
     +-	committing to the deletion.
     ++	With `--delete-merged` or `--delete-integrated`, print which
     ++	branches would be deleted and exit without touching any ref.
     ++	Useful for sanity-checking a wide pattern like `'origin/*'`
     ++	before committing to the deletion.
     + 
     + `-v`::
     + `-vv`::
      
       ## builtin/branch.c ##
      @@
     @@ builtin/branch.c
       
       static const char * const builtin_branch_usage[] = {
       	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
     -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
     - }
     +@@ builtin/branch.c: enum delete_branch_flags {
     + 	DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
     + 	DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
     + 	DELETE_BRANCH_DRY_RUN = (1 << 4),
     ++	DELETE_BRANCH_INTEGRATED = (1 << 5),
     + };
       
     - static int delete_branches(int argc, const char **argv, int kinds,
     --			   unsigned int flags)
     -+			   unsigned int flags, struct strmap *landed_commits)
     - {
     - 	struct commit *head_rev = NULL;
     - 	struct object_id oid;
     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
     - 		}
     - 
     - 		if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
     -+		    !(landed_commits &&
     -+		      strmap_contains(landed_commits, bname.buf)) &&
     - 		    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
     - 					flags)) {
     - 			if (!(flags & DELETE_BRANCH_SKIP_UNMERGED))
     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
     - 	for_each_string_list_item(item, &refs_to_delete) {
     - 		char *describe_ref = item->util;
     - 		char *name = item->string;
     -+		struct commit *landed = landed_commits ?
     -+			strmap_get(landed_commits, name + branch_name_pos) : NULL;
     -+		const char *landed_abbrev = landed ?
     -+			repo_find_unique_abbrev(the_repository,
     -+						&landed->object.oid,
     -+						DEFAULT_ABBREV) : NULL;
     -+
     - 		if (flags & DELETE_BRANCH_DRY_RUN) {
     --			if (!(flags & DELETE_BRANCH_QUIET))
     -+			if (flags & DELETE_BRANCH_QUIET)
     -+				;
     -+			else if (landed)
     -+				printf(_("Would delete branch %s (was %s, landed as %s).\n"),
     -+				       name + branch_name_pos, describe_ref,
     -+				       landed_abbrev);
     -+			else
     - 				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)) {
     - 			char *refname = name + branch_name_pos;
     --			if (!(flags & DELETE_BRANCH_QUIET))
     -+			if (flags & DELETE_BRANCH_QUIET)
     -+				;
     -+			else if (landed)
     -+				printf(_("Deleted branch %s (was %s, landed as %s).\n"),
     -+				       refname, describe_ref, landed_abbrev);
     -+			else
     - 				printf(remote_branch
     - 					? _("Deleted remote-tracking branch %s (was %s).\n")
     - 					: _("Deleted branch %s (was %s).\n"),
     + static int check_branch_commit(const char *branchname, const char *refname,
      @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
       	return ret;
       }
     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
      -					FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
      +					FILTER_REFS_BRANCHES,
      +					DELETE_BRANCH_SKIP_UNMERGED)) {
     -+			struct commit *rev = lookup_commit_reference(
     -+				the_repository, &candidates.items[i]->objectname);
     -+			struct commit *upstream = lookup_commit_reference_by_name(
     -+				upstream_refname);
     ++			struct commit *rev, *upstream;
      +
     ++			if (!(flags & DELETE_BRANCH_INTEGRATED))
     ++				continue;
     ++			rev = lookup_commit_reference(
     ++				the_repository, &candidates.items[i]->objectname);
     ++			upstream = lookup_commit_reference_by_name(upstream_refname);
      +			if (!rev || !upstream)
      +				continue;
      +			ALLOC_GROW(searches, searches_nr + 1, searches_alloc);
     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
       				      FILTER_REFS_BRANCHES,
       				      DELETE_BRANCH_SKIP_UNMERGED |
       				      DELETE_BRANCH_NO_HEAD_FALLBACK |
     --				      flags);
     +-				      flags, NULL);
      +				      flags, &landed_commits);
       
       	if (!ret && !(flags & DELETE_BRANCH_DRY_RUN))
     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
       	strset_clear(&deletable_branch_names);
       	ref_array_clear(&candidates);
      @@ builtin/branch.c: int cmd_branch(int argc,
     - 			die(_("branch name required"));
     - 		ret = delete_branches(argc, argv, filter.kind,
     - 				      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
     --				      (quiet ? DELETE_BRANCH_QUIET : 0));
     -+				      (quiet ? DELETE_BRANCH_QUIET : 0), NULL);
     + 	int delete = 0, rename = 0, copy = 0, list = 0,
     + 	    unset_upstream = 0, show_current = 0, edit_description = 0;
     + 	struct strvec delete_merged = STRVEC_INIT;
     ++	struct strvec delete_integrated = STRVEC_INIT;
     + 	int dry_run = 0;
     + 	const char *new_upstream = NULL;
     + 	int noncreate_actions = 0;
     +@@ builtin/branch.c: int cmd_branch(int argc,
     + 		OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("pattern"),
     + 			N_("delete merged branches whose upstream matches <pattern> (repeatable)"),
     + 			PARSE_OPT_NONEG, parse_opt_strvec),
     ++		OPT_CALLBACK_F(0, "delete-integrated", &delete_integrated, N_("pattern"),
     ++			N_("like --delete-merged, but also delete branches whose changes landed upstream (repeatable)"),
     ++			PARSE_OPT_NONEG, parse_opt_strvec),
     + 		OPT_BOOL(0, "dry-run", &dry_run,
     +-			N_("with --delete-merged, only print which branches would be deleted")),
     ++			N_("with --delete-merged or --delete-integrated, only print which branches would be deleted")),
     + 		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
     + 		OPT_MERGED(&filter, N_("print only branches that are merged")),
     + 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
     +@@ builtin/branch.c: int cmd_branch(int argc,
     + 
     + 	if (!delete && !rename && !copy && !edit_description && !new_upstream &&
     + 	    !show_current && !unset_upstream && !delete_merged.nr &&
     +-	    argc == 0)
     ++	    !delete_integrated.nr && argc == 0)
     + 		list = 1;
     + 
     + 	if (filter.with_commit || filter.no_commit ||
     +@@ builtin/branch.c: int cmd_branch(int argc,
     + 
     + 	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
     + 			    !!show_current + !!list + !!edit_description +
     +-			    !!unset_upstream + !!delete_merged.nr;
     ++			    !!unset_upstream + !!delete_merged.nr +
     ++			    !!delete_integrated.nr;
     + 	if (noncreate_actions > 1)
     + 		usage_with_options(builtin_branch_usage, options);
     + 
     +-	if (dry_run && !delete_merged.nr)
     +-		die(_("--dry-run requires --delete-merged"));
     ++	if (dry_run && !delete_merged.nr && !delete_integrated.nr)
     ++		die(_("--dry-run requires --delete-merged or --delete-integrated"));
     + 
     + 	if (recurse_submodules_explicit) {
     + 		if (!submodule_propagate_branches)
     +@@ builtin/branch.c: int cmd_branch(int argc,
     + 					     (quiet ? DELETE_BRANCH_QUIET : 0) |
     + 					     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
       		goto out;
     - 	} else if (delete_merged.nr) {
     - 		ret = delete_merged_branches(&delete_merged, argv,
     ++	} else if (delete_integrated.nr) {
     ++		ret = delete_merged_branches(&delete_integrated, argv,
     ++					     (quiet ? DELETE_BRANCH_QUIET : 0) |
     ++					     (dry_run ? DELETE_BRANCH_DRY_RUN : 0) |
     ++					     DELETE_BRANCH_INTEGRATED);
     ++		goto out;
     + 	} else if (show_current) {
     + 		print_current_branch_name();
     + 		ret = 0;
     +@@ builtin/branch.c: int cmd_branch(int argc,
     + 
     + out:
     + 	strvec_clear(&delete_merged);
     ++	strvec_clear(&delete_integrated);
     + 	string_list_clear(&sorting_options, 0);
     + 	return ret;
     + }
      
       ## t/t3200-branch.sh ##
     +@@ t/t3200-branch.sh: check_branches () {
     + 	test_cmp expect actual
     + }
     + 
     ++test_expect_success '--delete-merged does not delete unrelated branches' '
     ++	setup_repo_for_delete_merged &&
     ++	(
     ++		cd repo &&
     ++		git checkout -b unrelated-topic --track origin/next &&
     ++		test_write_lines 1 2 3 4 >file &&
     ++		git add file &&
     ++		git commit -m "add file" &&
     ++		git push origin unrelated-topic &&
     ++		git checkout --detach
     ++	) &&
     ++	(
     ++		cd upstream &&
     ++		git checkout next &&
     ++		test_write_lines 1 2 3 4 >file &&
     ++		test_write_lines 5 6 7 >elif &&
     ++		git add file elif &&
     ++		git commit --author="unrelated user <uu@example.com>" \
     ++			-m "unrelated commit adding an identical file" &&
     ++		git checkout main
     ++	) &&
     ++	(
     ++		cd repo &&
     ++		git fetch origin &&
     ++		git branch --delete-merged origin/next >actual 2>&1 &&
     ++		test_must_be_empty actual &&
     ++		check_branches <<-\EOF
     ++		main
     ++		unrelated-topic
     ++		EOF
     ++	)
     ++'
     ++
     + test_expect_success '--delete-merged keeps cloned main without explicit push configuration' '
     + 	setup_repo_for_delete_merged &&
     + 	(
      @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected merged branches' '
       	)
       '
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
      +	)
      +}
      +
     -+test_expect_success '--delete-merged deletes a rebase merged branch' '
     ++test_expect_success '--delete-integrated deletes a rebase merged branch' '
      +	setup_repo_for_delete_merged &&
      +	push_topic rebased rebased-one rebased-two &&
      +	(
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
      +		git fetch origin &&
      +		sha=$(git rev-parse --short rebased) &&
      +
     -+		git branch --delete-merged origin/next >actual 2>&1 &&
     ++		git branch --delete-integrated origin/next >actual 2>&1 &&
      +		echo "Deleted branch rebased (was $sha, landed as $landed)." >expect &&
      +		test_cmp expect actual &&
      +
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
      +	)
      +'
      +
     -+test_expect_success '--delete-merged deletes a squash merged branch' '
     ++test_expect_success '--delete-integrated deletes a squash merged branch' '
      +	setup_repo_for_delete_merged &&
      +	push_topic squashed squashed-one squashed-two &&
      +	push_topic partial lands-upstream stays-local &&
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
      +		git fetch origin &&
      +		sha=$(git rev-parse --short squashed) &&
      +
     -+		git branch --delete-merged origin/next >actual 2>&1 &&
     ++		git branch --delete-integrated origin/next >actual 2>&1 &&
      +		echo "Deleted branch squashed (was $sha, landed as $squash)." >expect &&
      +		test_cmp expect actual &&
      +
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
      +	)
      +'
      +
     -+test_expect_success '--delete-merged deletes a squash merged branch that was reverted' '
     ++test_expect_success '--delete-integrated deletes a squash merged branch that was reverted' '
      +	setup_repo_for_delete_merged &&
      +	push_topic reverted reverted-work &&
      +	squash_merge_upstream reverted &&
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
      +		git fetch origin &&
      +		sha=$(git rev-parse --short reverted) &&
      +
     -+		git branch --delete-merged origin/next >actual 2>&1 &&
     ++		git branch --delete-integrated origin/next >actual 2>&1 &&
      +		echo "Deleted branch reverted (was $sha, landed as $squash)." >expect &&
      +		test_cmp expect actual &&
      +
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
      +	)
      +'
      +
     -+test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' '
     ++test_expect_success '--delete-integrated reports a merge plainly next to an unmerged branch' '
      +	setup_repo_for_delete_merged &&
      +	push_topic a-unmerged unmerged-work &&
      +	push_topic b-merged merged-work &&
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
      +		git fetch origin &&
      +		sha=$(git rev-parse --short b-merged) &&
      +
     -+		git branch --delete-merged origin/next >actual 2>&1 &&
     ++		git branch --delete-integrated origin/next >actual 2>&1 &&
      +		echo "Deleted branch b-merged (was $sha)." >expect &&
      +		test_cmp expect actual &&
      +
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
       test_expect_success '--delete-merged keeps main despite a different default push remote' '
       	setup_repo_for_delete_merged &&
       	create_merged_branch on-next &&
     +@@ t/t3200-branch.sh: test_expect_success '--dry-run without --delete-merged is rejected' '
     + 	test_grep "requires --delete-merged" err
     + '
     + 
     ++test_expect_success '--delete-integrated cannot be combined with --delete-merged' '
     ++	test_must_fail git -C forked branch --delete-merged origin/next \
     ++		--delete-integrated origin/next
     ++'
     ++
     + test_done
 3:  2befb182dc ! 4:  19a9630c13 branch: add --squashed to find squash merged branches
     @@ Metadata
      Author: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Commit message ##
     -    branch: add --squashed to find squash merged branches
     +    branch: let --delete-integrated find branches merged onto changed files
      
          A branch that upstream integrated with
      
     @@ Commit message
              git commit
      
          after upstream had also changed the files the branch touches is still
     -    kept by "git branch --delete-merged". The squash commit has neither the
     -    branch's version of those files nor the fork point's, so the previous
     -    check cannot see that the work landed. This happens all the time on
     -    hosting services, where the "Squash and merge" button on GitHub lands
     -    pull requests this way while the main branch keeps moving. A rebase
     -    merge onto such changes looks the same.
     +    kept by "git branch --delete-integrated". The squash commit has neither
     +    the branch's version of those files nor the fork point's, so the
     +    previous check cannot see that the work landed. This happens all the
     +    time on hosting services, where the "Squash and merge" button on GitHub
     +    lands pull requests this way while the main branch keeps moving. A
     +    rebase merge onto such changes looks the same.
      
     -    Add --squashed, which for each such file merges the branch's version
     -    into the upstream one and treats the change as landed when that leaves
     -    the upstream version unchanged. This has to read the files, so it is
     -    slower, and in a partial clone it may download blobs. It is therefore
     -    only done when asked for.
     +    For each such file, merge the branch's version into the upstream one
     +    and treat the change as landed when that leaves the upstream version
     +    unchanged. This has to read the files, so in a partial clone it may
     +    download blobs.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Documentation/git-branch.adoc ##
     -@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
     - git branch (-c|-C) [<old-branch>] <new-branch>
     - git branch (-d|-D) [-r] <branch-name>...
     - git branch --edit-description [<branch-name>]
     --git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]
     -+git branch [--dry-run] [--squashed] (--delete-merged <pattern>)... [<branch-pattern>...]
     - 
     - DESCRIPTION
     - -----------
      @@ Documentation/git-branch.adoc: remove it anyway.
     - 	sanity-checking a wide pattern like `'origin/*'` before
     - 	committing to the deletion.
     - 
     -+`--squashed`::
     -+	With `--delete-merged`, also delete a branch that was squash or
     -+	rebase merged after upstream changed the same files. Upstream
     -+	then has neither the branch's version of those files nor the fork
     -+	point's, so the branch's version is merged into upstream's, and
     -+	the file counts as landed when that leaves upstream's version
     -+	unchanged. This reads those files, so it takes longer and may
     -+	download blobs in a partial clone.
     -+
     - `-v`::
     - `-vv`::
     - `--verbose`::
     + 	Like `--delete-merged`, but also delete a branch whose changes
     + 	the upstream already has even though its commits are not there,
     + 	as happens after a squash or rebase merge. Such a branch is
     +-	deleted when some upstream commit since the fork point has its
     +-	version of every file it changed, even if those changes were
     +-	later reverted, and the message names the first such commit.
     +-	It does not matter who made that commit. The same rules for
     +-	which branches are kept apply as for `--delete-merged`.
     ++	deleted when some upstream commit since the fork point has all
     ++	of its changes, even if they were later reverted, and the
     ++	message names the first such commit. It does not matter who made
     ++	that commit. A file counts when the commit has the branch's
     ++	version of it, or when merging the branch's version into the
     ++	commit's leaves it unchanged, which reads the file and may
     ++	download blobs in a partial clone. The same rules for which
     ++	branches are kept apply as for `--delete-merged`.
     + 
     + `--dry-run`::
     + 	With `--delete-merged` or `--delete-integrated`, print which
      
       ## builtin/branch.c ##
      @@
     @@ builtin/branch.c
       
       static const char * const builtin_branch_usage[] = {
       	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
     -@@ builtin/branch.c: enum delete_branch_flags {
     - 	DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
     - 	DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
     - 	DELETE_BRANCH_DRY_RUN = (1 << 4),
     -+	DELETE_BRANCH_SQUASHED = (1 << 5),
     - };
     - 
     - static int check_branch_commit(const char *branchname, const char *refname,
      @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch,
       	return ret;
       }
     @@ builtin/branch.c: static void collect_branch_changes(struct landing_search *sear
       }
       
       static int changes_landed(struct landing_search *search,
     --			  struct commit *commit)
     -+			  struct commit *commit, unsigned int flags)
     + 			  struct commit *commit)
       {
      -	for (size_t i = 0; i < search->changes_nr; i++)
      -		if (!change_landed(&search->changes[i], commit))
     @@ builtin/branch.c: static void collect_branch_changes(struct landing_search *sear
      +		struct branch_change *change = &search->changes[i];
      +
      +		change->state = change_state_at(change, commit);
     -+		if (change->state == CHANGE_MISSING ||
     -+		    (change->state == CHANGE_NEEDS_MERGE &&
     -+		     !(flags & DELETE_BRANCH_SQUASHED)))
     ++		if (change->state == CHANGE_MISSING)
       			return 0;
      +	}
      +	for (size_t i = 0; i < search->changes_nr; i++) {
     @@ builtin/branch.c: static void collect_branch_changes(struct landing_search *sear
       	return 1;
       }
       
     -@@ builtin/branch.c: static int touches_changes(struct string_list *paths,
     - 
     - static struct commit *find_landed_commit(struct landing_search *search,
     - 					 struct changed_paths *cache,
     --					 struct diff_options *opt)
     -+					 struct diff_options *opt,
     -+					 unsigned int flags)
     - {
     - 	struct commit *commit, *landed = NULL;
     - 	struct strvec args = STRVEC_INIT;
     -@@ builtin/branch.c: static struct commit *find_landed_commit(struct landing_search *search,
     - 	while (!landed && (commit = get_revision(&revs)))
     - 		if (touches_changes(changed_paths_of(cache, opt, commit),
     - 				    search) &&
     --		    changes_landed(search, commit))
     -+		    changes_landed(search, commit, flags))
     - 			landed = commit;
     - 	release_revisions(&revs);
     - 	clear_commit_marks(search->upstream, ALL_REV_FLAGS);
     -@@ builtin/branch.c: static struct commit *find_landed_commit(struct landing_search *search,
     - 
     - static void find_landed_commits(struct landing_search *searches,
     - 				size_t searches_nr,
     --				struct strmap *landed_commits)
     -+				struct strmap *landed_commits,
     -+				unsigned int flags)
     - {
     - 	struct changed_paths cache;
     - 	struct diff_options opt;
     -@@ builtin/branch.c: static void find_landed_commits(struct landing_search *searches,
     - 
     - 		if (!searches[i].changes_nr)
     - 			continue;
     --		landed = find_landed_commit(&searches[i], &cache, &opt);
     -+		landed = find_landed_commit(&searches[i], &cache, &opt, flags);
     - 		if (landed)
     - 			strmap_put(landed_commits, searches[i].branch_name,
     - 				   landed);
     -@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
     - 			strset_add(&deletable_branch_names, branch_name);
     - 	}
     - 
     --	find_landed_commits(searches, searches_nr, &landed_commits);
     -+	find_landed_commits(searches, searches_nr, &landed_commits, flags);
     - 	for (size_t i = 0; i < searches_nr; i++) {
     - 		const char *branch_name = searches[i].branch_name;
     - 
     -@@ builtin/branch.c: int cmd_branch(int argc,
     - 	    unset_upstream = 0, show_current = 0, edit_description = 0;
     - 	struct strvec delete_merged = STRVEC_INIT;
     - 	int dry_run = 0;
     -+	int squashed = 0;
     - 	const char *new_upstream = NULL;
     - 	int noncreate_actions = 0;
     - 	/* possible options */
     -@@ builtin/branch.c: int cmd_branch(int argc,
     - 			PARSE_OPT_NONEG, parse_opt_strvec),
     - 		OPT_BOOL(0, "dry-run", &dry_run,
     - 			N_("with --delete-merged, only print which branches would be deleted")),
     -+		OPT_BOOL(0, "squashed", &squashed,
     -+			N_("with --delete-merged, also find branches merged after upstream changed their files")),
     - 		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
     - 		OPT_MERGED(&filter, N_("print only branches that are merged")),
     - 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
     -@@ builtin/branch.c: int cmd_branch(int argc,
     - 
     - 	if (dry_run && !delete_merged.nr)
     - 		die(_("--dry-run requires --delete-merged"));
     -+	if (squashed && !delete_merged.nr)
     -+		die(_("--squashed requires --delete-merged"));
     - 
     - 	if (recurse_submodules_explicit) {
     - 		if (!submodule_propagate_branches)
     -@@ builtin/branch.c: int cmd_branch(int argc,
     - 	} else if (delete_merged.nr) {
     - 		ret = delete_merged_branches(&delete_merged, argv,
     - 					     (quiet ? DELETE_BRANCH_QUIET : 0) |
     --					     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
     -+					     (dry_run ? DELETE_BRANCH_DRY_RUN : 0) |
     -+					     (squashed ? DELETE_BRANCH_SQUASHED : 0));
     - 		goto out;
     - 	} else if (show_current) {
     - 		print_current_branch_name();
      
       ## t/t3200-branch.sh ##
     -@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes a squash merged branch that was rev
     +@@ t/t3200-branch.sh: test_expect_success '--delete-integrated deletes a squash merged branch that was
       	)
       '
       
     -+test_expect_success '--delete-merged --squashed deletes a squash merged branch after upstream changed the same file' '
     ++test_expect_success '--delete-integrated deletes a squash merged branch after upstream changed the same file' '
      +	(
      +		cd upstream &&
      +		git checkout next &&
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes a squash merged
      +		git fetch origin &&
      +		sha=$(git rev-parse --short edits-shared) &&
      +
     -+		git branch --delete-merged origin/next >actual 2>&1 &&
     -+		test_must_be_empty actual &&
     -+		check_branches <<-\EOF &&
     -+		edits-shared
     -+		main
     -+		EOF
     -+
     -+		git branch --delete-merged origin/next --squashed \
     -+			>actual 2>&1 &&
     ++		git branch --delete-integrated origin/next >actual 2>&1 &&
      +		echo "Deleted branch edits-shared (was $sha, landed as $squash)." >expect &&
      +		test_cmp expect actual &&
      +
     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes a squash merged
      +	)
      +'
      +
     - test_expect_success '--delete-merged reports a merge plainly next to an unmerged branch' '
     + test_expect_success '--delete-integrated reports a merge plainly next to an unmerged branch' '
       	setup_repo_for_delete_merged &&
       	push_topic a-unmerged unmerged-work &&
     -@@ t/t3200-branch.sh: test_expect_success "branch -d still deletes a deleteMerged=false branch" '
     - 	)
     - '
     - 
     -+test_expect_success '--squashed without --delete-merged is rejected' '
     -+	test_must_fail git -C forked branch --squashed 2>err &&
     -+	test_grep "requires --delete-merged" err
     -+'
     -+
     - 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

-- 
gitgitgadget

^ permalink raw reply	[flat|nested] 60+ messages in thread

* [PATCH v4 1/4] branch: move the deleteMerged opt-out check into a helper
  2026-10-26  8:33 ` [PATCH v4 0/4] " Harald Nordgren via GitGitGadget
@ 2026-10-26  8:33   ` Harald Nordgren via GitGitGadget
  2026-10-26  8:33   ` [PATCH v4 2/4] branch: let delete_branches() name where a branch landed Harald Nordgren via GitGitGadget
                     ` (2 subsequent siblings)
  3 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-10-26  8:33 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood, Jasper Mayone,
	Harald Nordgren, Harald Nordgren

From: Harald Nordgren <haraldnordgren@gmail.com>

A later commit adds "git branch --delete-integrated", which deletes
branches whose tips are not reachable from their upstream. Those must
honor branch.<name>.deleteMerged=false as well. Move the check into a
helper so it can be used in both places.

No change in behavior.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 builtin/branch.c | 32 +++++++++++++++++++-------------
 1 file changed, 19 insertions(+), 13 deletions(-)

diff --git a/builtin/branch.c b/builtin/branch.c
index a613148fc7..171b13b081 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -824,6 +824,23 @@ static int branch_pushes_to_upstream(struct branch *branch,
 	return ret;
 }
 
+static bool branch_opted_out(struct strbuf *key, const char *branch_name,
+			     unsigned int flags)
+{
+	int opt_out;
+
+	strbuf_reset(key);
+	strbuf_addf(key, "branch.%s.deletemerged", branch_name);
+	if (repo_config_get_bool(the_repository, key->buf, &opt_out) ||
+	    opt_out)
+		return false;
+	if (!(flags & DELETE_BRANCH_QUIET))
+		fprintf(stderr,
+			_("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
+			branch_name, branch_name);
+	return true;
+}
+
 static int delete_merged_branches(const struct strvec *upstreams,
 				 const char **argv, unsigned int flags)
 {
@@ -852,7 +869,6 @@ static int delete_merged_branches(const struct strvec *upstreams,
 		const char *branch_name;
 		struct branch *branch;
 		const char *upstream_refname;
-		int opt_out;
 
 		if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
 			BUG("filter returned non-branch ref '%s'", branch_refname);
@@ -870,18 +886,8 @@ static int delete_merged_branches(const struct strvec *upstreams,
 					FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
 			continue;
 
-		strbuf_reset(&key);
-		strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
-		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
-		    !opt_out) {
-			if (!(flags & DELETE_BRANCH_QUIET))
-				fprintf(stderr,
-					_("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
-					branch_name, branch_name);
-			continue;
-		}
-
-		strset_add(&deletable_branch_names, branch_name);
+		if (!branch_opted_out(&key, branch_name, flags))
+			strset_add(&deletable_branch_names, branch_name);
 	}
 
 	protect_stacked_branch_bases(refs, &deletable_branch_names,
-- 
gitgitgadget


^ permalink raw reply related	[flat|nested] 60+ messages in thread

* [PATCH v4 2/4] branch: let delete_branches() name where a branch landed
  2026-10-26  8:33 ` [PATCH v4 0/4] " Harald Nordgren via GitGitGadget
  2026-10-26  8:33   ` [PATCH v4 1/4] branch: move the deleteMerged opt-out check into a helper Harald Nordgren via GitGitGadget
@ 2026-10-26  8:33   ` Harald Nordgren via GitGitGadget
  2026-10-26  8:33   ` [PATCH v4 3/4] branch: add --delete-integrated to delete rebase and squash merged branches Harald Nordgren via GitGitGadget
  2026-10-26  8:33   ` [PATCH v4 4/4] branch: let --delete-integrated find branches merged onto changed files Harald Nordgren via GitGitGadget
  3 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-10-26  8:33 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood, Jasper Mayone,
	Harald Nordgren, Harald Nordgren

From: Harald Nordgren <haraldnordgren@gmail.com>

A later commit lets "git branch" delete branches whose commits are not
in their upstream but whose changes are, such as after a squash merge.
Their tips are not reachable from the upstream, so the merge check in
delete_branches() would refuse to delete them, and the output should
say where their changes went.

Let delete_branches() take a map from branch names to the upstream
commit where each branch landed. A branch in the map skips the merge
check and is reported as

    Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).

No caller passes a map yet, so there is no change in behavior.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 builtin/branch.c | 30 +++++++++++++++++++++++++-----
 1 file changed, 25 insertions(+), 5 deletions(-)

diff --git a/builtin/branch.c b/builtin/branch.c
index 171b13b081..99b0dd45e9 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -236,7 +236,7 @@ static void delete_branch_config(const char *branchname)
 }
 
 static int delete_branches(int argc, const char **argv, int kinds,
-			   unsigned int flags)
+			   unsigned int flags, struct strmap *landed_commits)
 {
 	struct commit *head_rev = NULL;
 	struct object_id oid;
@@ -334,6 +334,8 @@ static int delete_branches(int argc, const char **argv, int kinds,
 		}
 
 		if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
+		    !(landed_commits &&
+		      strmap_contains(landed_commits, bname.buf)) &&
 		    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
 					flags)) {
 			if (!(flags & DELETE_BRANCH_SKIP_UNMERGED))
@@ -357,15 +359,33 @@ static int delete_branches(int argc, const char **argv, int kinds,
 	for_each_string_list_item(item, &refs_to_delete) {
 		char *describe_ref = item->util;
 		char *name = item->string;
+		struct commit *landed = landed_commits ?
+			strmap_get(landed_commits, name + branch_name_pos) : NULL;
+		const char *landed_abbrev = landed ?
+			repo_find_unique_abbrev(the_repository,
+						&landed->object.oid,
+						DEFAULT_ABBREV) : NULL;
+
 		if (flags & DELETE_BRANCH_DRY_RUN) {
-			if (!(flags & DELETE_BRANCH_QUIET))
+			if (flags & DELETE_BRANCH_QUIET)
+				;
+			else if (landed)
+				printf(_("Would delete branch %s (was %s, landed as %s).\n"),
+				       name + branch_name_pos, describe_ref,
+				       landed_abbrev);
+			else
 				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)) {
 			char *refname = name + branch_name_pos;
-			if (!(flags & DELETE_BRANCH_QUIET))
+			if (flags & DELETE_BRANCH_QUIET)
+				;
+			else if (landed)
+				printf(_("Deleted branch %s (was %s, landed as %s).\n"),
+				       refname, describe_ref, landed_abbrev);
+			else
 				printf(remote_branch
 					? _("Deleted remote-tracking branch %s (was %s).\n")
 					: _("Deleted branch %s (was %s).\n"),
@@ -901,7 +921,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
 				      FILTER_REFS_BRANCHES,
 				      DELETE_BRANCH_SKIP_UNMERGED |
 				      DELETE_BRANCH_NO_HEAD_FALLBACK |
-				      flags);
+				      flags, NULL);
 
 	if (!ret && !(flags & DELETE_BRANCH_DRY_RUN))
 		clear_deleted_upstreams(&protected_branch_names,
@@ -1141,7 +1161,7 @@ int cmd_branch(int argc,
 			die(_("branch name required"));
 		ret = delete_branches(argc, argv, filter.kind,
 				      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
-				      (quiet ? DELETE_BRANCH_QUIET : 0));
+				      (quiet ? DELETE_BRANCH_QUIET : 0), NULL);
 		goto out;
 	} else if (delete_merged.nr) {
 		ret = delete_merged_branches(&delete_merged, argv,
-- 
gitgitgadget


^ permalink raw reply related	[flat|nested] 60+ messages in thread

* [PATCH v4 3/4] branch: add --delete-integrated to delete rebase and squash merged branches
  2026-10-26  8:33 ` [PATCH v4 0/4] " Harald Nordgren via GitGitGadget
  2026-10-26  8:33   ` [PATCH v4 1/4] branch: move the deleteMerged opt-out check into a helper Harald Nordgren via GitGitGadget
  2026-10-26  8:33   ` [PATCH v4 2/4] branch: let delete_branches() name where a branch landed Harald Nordgren via GitGitGadget
@ 2026-10-26  8:33   ` Harald Nordgren via GitGitGadget
  2026-10-26  8:33   ` [PATCH v4 4/4] branch: let --delete-integrated find branches merged onto changed files Harald Nordgren via GitGitGadget
  3 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-10-26  8:33 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood, Jasper Mayone,
	Harald Nordgren, Harald Nordgren

From: Harald Nordgren <haraldnordgren@gmail.com>

A branch that upstream integrated with

    git rebase main topic
    git checkout main
    git merge --ff-only topic

or with

    git merge --squash topic
    git commit

is never deleted by "git branch --delete-merged". This is what the
"Rebase and merge" and "Squash and merge" buttons on GitHub do. The
upstream holds rewritten copies of the branch's work, so its tip is not
reachable from it and it looks unmerged forever.

Add --delete-integrated, which does what --delete-merged does and also
deletes a branch when some upstream commit since the fork point has the
branch's version of every file the branch changed. That commit is named
in the output so the user can see where the work went:

    Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).

The first such commit is used, so the branch is deleted even if
upstream later reverted or reworked the changes. Nothing is lost, since
that commit keeps them in the upstream history. Who made that commit
does not matter, since the local branch is no longer needed either way.
A branch whose changes only partly landed is kept.

--delete-merged keeps deleting only branches whose own commits are in
the upstream, so it never removes a branch because somebody else made
the same change.

The check never reads file contents, so it stays cheap in a partial
clone.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/config/branch.adoc |   8 +-
 Documentation/git-branch.adoc    |  19 ++-
 builtin/branch.c                 | 248 ++++++++++++++++++++++++++++++-
 t/t3200-branch.sh                | 175 ++++++++++++++++++++++
 4 files changed, 435 insertions(+), 15 deletions(-)

diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index c2acd89c99..be9b296bda 100644
--- a/Documentation/config/branch.adoc
+++ b/Documentation/config/branch.adoc
@@ -106,7 +106,7 @@ for details).
 
 `branch.<name>.deleteMerged`::
 	If set to `false`, branch _<name>_ is exempt from
-	`git branch --delete-merged`.  Useful for a topic branch you
-	intend to develop further after an initial round has been
-	merged upstream.  Defaults to true.  Explicit deletion via
-	`git branch -d` is unaffected.
+	`git branch --delete-merged` and `--delete-integrated`.  Useful
+	for a topic branch you intend to develop further after an
+	initial round has been merged upstream.  Defaults to true.
+	Explicit deletion via `git branch -d` is unaffected.
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index bfdf459329..6be28c8a4e 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -26,6 +26,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>
 git branch (-d|-D) [-r] <branch-name>...
 git branch --edit-description [<branch-name>]
 git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]
+git branch [--dry-run] (--delete-integrated <pattern>)... [<branch-pattern>...]
 
 DESCRIPTION
 -----------
@@ -235,11 +236,21 @@ A branch whose work has not yet been merged into its upstream is
 silently skipped. Delete it with `git branch -D` if you want to
 remove it anyway.
 
+`--delete-integrated <pattern>`::
+	Like `--delete-merged`, but also delete a branch whose changes
+	the upstream already has even though its commits are not there,
+	as happens after a squash or rebase merge. Such a branch is
+	deleted when some upstream commit since the fork point has its
+	version of every file it changed, even if those changes were
+	later reverted, and the message names the first such commit.
+	It does not matter who made that commit. The same rules for
+	which branches are kept apply as for `--delete-merged`.
+
 `--dry-run`::
-	With `--delete-merged`, print which branches would be
-	deleted and exit without touching any ref.  Useful for
-	sanity-checking a wide pattern like `'origin/*'` before
-	committing to the deletion.
+	With `--delete-merged` or `--delete-integrated`, print which
+	branches would be deleted and exit without touching any ref.
+	Useful for sanity-checking a wide pattern like `'origin/*'`
+	before committing to the deletion.
 
 `-v`::
 `-vv`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 99b0dd45e9..ba04d8c486 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -29,6 +29,12 @@
 #include "help.h"
 #include "advice.h"
 #include "commit-reach.h"
+#include "commit-slab.h"
+#include "diff.h"
+#include "diffcore.h"
+#include "hex.h"
+#include "revision.h"
+#include "tree-walk.h"
 
 static const char * const builtin_branch_usage[] = {
 	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
@@ -201,6 +207,7 @@ enum delete_branch_flags {
 	DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
 	DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
 	DELETE_BRANCH_DRY_RUN = (1 << 4),
+	DELETE_BRANCH_INTEGRATED = (1 << 5),
 };
 
 static int check_branch_commit(const char *branchname, const char *refname,
@@ -844,6 +851,185 @@ static int branch_pushes_to_upstream(struct branch *branch,
 	return ret;
 }
 
+struct branch_change {
+	char *path;
+	struct object_id branch_oid;
+	unsigned short branch_mode;
+};
+
+struct landing_search {
+	const char *branch_name;
+	struct commit *rev;
+	struct commit *upstream;
+	struct branch_change *changes;
+	size_t changes_nr, changes_alloc;
+};
+
+define_commit_slab(changed_paths, struct string_list *);
+
+static void collect_branch_changes(struct landing_search *search,
+				   struct commit *base)
+{
+	struct diff_options opt;
+
+	repo_diff_setup(the_repository, &opt);
+	opt.flags.recursive = 1;
+	opt.output_format = DIFF_FORMAT_NO_OUTPUT;
+	diff_setup_done(&opt);
+	diff_tree_oid(get_commit_tree_oid(base),
+		      get_commit_tree_oid(search->rev), "", &opt);
+	for (int i = 0; i < diff_queued_diff.nr; i++) {
+		struct diff_filepair *p = diff_queued_diff.queue[i];
+		struct branch_change *change;
+
+		ALLOC_GROW(search->changes, search->changes_nr + 1,
+			   search->changes_alloc);
+		change = &search->changes[search->changes_nr++];
+		change->path = xstrdup(p->two->path);
+		oidcpy(&change->branch_oid, DIFF_FILE_VALID(p->two) ?
+		       &p->two->oid : null_oid(the_hash_algo));
+		change->branch_mode = p->two->mode;
+	}
+	diff_flush(&opt);
+}
+
+static int change_landed(const struct branch_change *change,
+			 struct commit *commit)
+{
+	struct object_id oid;
+	unsigned short mode;
+
+	if (get_tree_entry(the_repository, get_commit_tree_oid(commit),
+			   change->path, &oid, &mode))
+		return is_null_oid(&change->branch_oid);
+	return oideq(&oid, &change->branch_oid) &&
+	       mode == change->branch_mode;
+}
+
+static int changes_landed(struct landing_search *search,
+			  struct commit *commit)
+{
+	for (size_t i = 0; i < search->changes_nr; i++)
+		if (!change_landed(&search->changes[i], commit))
+			return 0;
+	return 1;
+}
+
+static struct string_list *changed_paths_of(struct changed_paths *cache,
+					    struct diff_options *opt,
+					    struct commit *commit)
+{
+	struct string_list **paths = changed_paths_at(cache, commit);
+	struct commit *parent;
+
+	if (*paths)
+		return *paths;
+
+	CALLOC_ARRAY(*paths, 1);
+	string_list_init_dup(*paths);
+	parent = commit->parents ? commit->parents->item : NULL;
+	if (parent && repo_parse_commit(the_repository, parent))
+		die(_("could not parse commit %s"),
+		    oid_to_hex(&parent->object.oid));
+	diff_tree_oid(parent ? get_commit_tree_oid(parent) : NULL,
+		      get_commit_tree_oid(commit), "", opt);
+	for (int i = 0; i < diff_queued_diff.nr; i++) {
+		struct diff_filepair *p = diff_queued_diff.queue[i];
+
+		string_list_append(*paths, p->two->path);
+	}
+	diff_flush(opt);
+	string_list_sort(*paths);
+	return *paths;
+}
+
+static void free_changed_paths(struct string_list **paths)
+{
+	if (!*paths)
+		return;
+	string_list_clear(*paths, 0);
+	free(*paths);
+}
+
+static int touches_changes(struct string_list *paths,
+			   const struct landing_search *search)
+{
+	for (size_t i = 0; i < search->changes_nr; i++)
+		if (string_list_has_string(paths, search->changes[i].path))
+			return 1;
+	return 0;
+}
+
+static struct commit *find_landed_commit(struct landing_search *search,
+					 struct changed_paths *cache,
+					 struct diff_options *opt)
+{
+	struct commit *commit, *landed = NULL;
+	struct strvec args = STRVEC_INIT;
+	struct rev_info revs;
+
+	strvec_pushl(&args, "rev-list", "--reverse",
+		     oid_to_hex(&search->upstream->object.oid), NULL);
+	strvec_pushf(&args, "^%s", oid_to_hex(&search->rev->object.oid));
+
+	repo_init_revisions(the_repository, &revs, NULL);
+	setup_revisions_from_strvec(&args, &revs, NULL);
+	if (prepare_revision_walk(&revs))
+		die(_("revision walk setup failed"));
+	while (!landed && (commit = get_revision(&revs)))
+		if (touches_changes(changed_paths_of(cache, opt, commit),
+				    search) &&
+		    changes_landed(search, commit))
+			landed = commit;
+	release_revisions(&revs);
+	clear_commit_marks(search->upstream, ALL_REV_FLAGS);
+	clear_commit_marks(search->rev, ALL_REV_FLAGS);
+	strvec_clear(&args);
+	return landed;
+}
+
+static void find_landed_commits(struct landing_search *searches,
+				size_t searches_nr,
+				struct strmap *landed_commits)
+{
+	struct changed_paths cache;
+	struct diff_options opt;
+
+	for (size_t i = 0; i < searches_nr; i++) {
+		struct landing_search *search = &searches[i];
+		struct commit_list *merge_bases = NULL;
+
+		if (repo_get_merge_bases(the_repository, search->upstream,
+					 search->rev, &merge_bases) < 0)
+			exit(128);
+		if (merge_bases)
+			collect_branch_changes(search, merge_bases->item);
+		commit_list_free(merge_bases);
+	}
+
+	repo_diff_setup(the_repository, &opt);
+	opt.flags.recursive = 1;
+	opt.output_format = DIFF_FORMAT_NO_OUTPUT;
+	opt.no_free = 1;
+	diff_setup_done(&opt);
+	init_changed_paths(&cache);
+
+	for (size_t i = 0; i < searches_nr; i++) {
+		struct commit *landed;
+
+		if (!searches[i].changes_nr)
+			continue;
+		landed = find_landed_commit(&searches[i], &cache, &opt);
+		if (landed)
+			strmap_put(landed_commits, searches[i].branch_name,
+				   landed);
+	}
+
+	deep_clear_changed_paths(&cache, free_changed_paths);
+	opt.no_free = 0;
+	diff_free(&opt);
+}
+
 static bool branch_opted_out(struct strbuf *key, const char *branch_name,
 			     unsigned int flags)
 {
@@ -869,6 +1055,9 @@ static int delete_merged_branches(const struct strvec *upstreams,
 	struct ref_array candidates = { 0 };
 	struct strset deletable_branch_names = STRSET_INIT;
 	struct strset protected_branch_names = STRSET_INIT;
+	struct strmap landed_commits = STRMAP_INIT;
+	struct landing_search *searches = NULL;
+	size_t searches_nr = 0, searches_alloc = 0;
 	struct strvec branches_to_delete = STRVEC_INIT;
 	struct strbuf key = STRBUF_INIT;
 	struct hashmap_iter iter;
@@ -903,13 +1092,39 @@ static int delete_merged_branches(const struct strvec *upstreams,
 			continue;
 		if (check_branch_commit(branch_name, branch_name,
 					&candidates.items[i]->objectname, NULL,
-					FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
+					FILTER_REFS_BRANCHES,
+					DELETE_BRANCH_SKIP_UNMERGED)) {
+			struct commit *rev, *upstream;
+
+			if (!(flags & DELETE_BRANCH_INTEGRATED))
+				continue;
+			rev = lookup_commit_reference(
+				the_repository, &candidates.items[i]->objectname);
+			upstream = lookup_commit_reference_by_name(upstream_refname);
+			if (!rev || !upstream)
+				continue;
+			ALLOC_GROW(searches, searches_nr + 1, searches_alloc);
+			searches[searches_nr++] = (struct landing_search) {
+				.branch_name = branch_name,
+				.rev = rev,
+				.upstream = upstream,
+			};
 			continue;
+		}
 
 		if (!branch_opted_out(&key, branch_name, flags))
 			strset_add(&deletable_branch_names, branch_name);
 	}
 
+	find_landed_commits(searches, searches_nr, &landed_commits);
+	for (size_t i = 0; i < searches_nr; i++) {
+		const char *branch_name = searches[i].branch_name;
+
+		if (strmap_contains(&landed_commits, branch_name) &&
+		    !branch_opted_out(&key, branch_name, flags))
+			strset_add(&deletable_branch_names, branch_name);
+	}
+
 	protect_stacked_branch_bases(refs, &deletable_branch_names,
 				     &protected_branch_names);
 
@@ -921,7 +1136,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
 				      FILTER_REFS_BRANCHES,
 				      DELETE_BRANCH_SKIP_UNMERGED |
 				      DELETE_BRANCH_NO_HEAD_FALLBACK |
-				      flags, NULL);
+				      flags, &landed_commits);
 
 	if (!ret && !(flags & DELETE_BRANCH_DRY_RUN))
 		clear_deleted_upstreams(&protected_branch_names,
@@ -929,6 +1144,13 @@ static int delete_merged_branches(const struct strvec *upstreams,
 
 	strbuf_release(&key);
 	strvec_clear(&branches_to_delete);
+	for (size_t i = 0; i < searches_nr; i++) {
+		for (size_t j = 0; j < searches[i].changes_nr; j++)
+			free(searches[i].changes[j].path);
+		free(searches[i].changes);
+	}
+	free(searches);
+	strmap_clear(&landed_commits, 0);
 	strset_clear(&protected_branch_names);
 	strset_clear(&deletable_branch_names);
 	ref_array_clear(&candidates);
@@ -1001,6 +1223,7 @@ int cmd_branch(int argc,
 	int delete = 0, rename = 0, copy = 0, list = 0,
 	    unset_upstream = 0, show_current = 0, edit_description = 0;
 	struct strvec delete_merged = STRVEC_INIT;
+	struct strvec delete_integrated = STRVEC_INIT;
 	int dry_run = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
@@ -1058,8 +1281,11 @@ int cmd_branch(int argc,
 		OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("pattern"),
 			N_("delete merged branches whose upstream matches <pattern> (repeatable)"),
 			PARSE_OPT_NONEG, parse_opt_strvec),
+		OPT_CALLBACK_F(0, "delete-integrated", &delete_integrated, N_("pattern"),
+			N_("like --delete-merged, but also delete branches whose changes landed upstream (repeatable)"),
+			PARSE_OPT_NONEG, parse_opt_strvec),
 		OPT_BOOL(0, "dry-run", &dry_run,
-			N_("with --delete-merged, only print which branches would be deleted")),
+			N_("with --delete-merged or --delete-integrated, only print which branches would be deleted")),
 		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
 		OPT_MERGED(&filter, N_("print only branches that are merged")),
 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1108,7 +1334,7 @@ int cmd_branch(int argc,
 
 	if (!delete && !rename && !copy && !edit_description && !new_upstream &&
 	    !show_current && !unset_upstream && !delete_merged.nr &&
-	    argc == 0)
+	    !delete_integrated.nr && argc == 0)
 		list = 1;
 
 	if (filter.with_commit || filter.no_commit ||
@@ -1118,12 +1344,13 @@ int cmd_branch(int argc,
 
 	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
 			    !!show_current + !!list + !!edit_description +
-			    !!unset_upstream + !!delete_merged.nr;
+			    !!unset_upstream + !!delete_merged.nr +
+			    !!delete_integrated.nr;
 	if (noncreate_actions > 1)
 		usage_with_options(builtin_branch_usage, options);
 
-	if (dry_run && !delete_merged.nr)
-		die(_("--dry-run requires --delete-merged"));
+	if (dry_run && !delete_merged.nr && !delete_integrated.nr)
+		die(_("--dry-run requires --delete-merged or --delete-integrated"));
 
 	if (recurse_submodules_explicit) {
 		if (!submodule_propagate_branches)
@@ -1168,6 +1395,12 @@ int cmd_branch(int argc,
 					     (quiet ? DELETE_BRANCH_QUIET : 0) |
 					     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
 		goto out;
+	} else if (delete_integrated.nr) {
+		ret = delete_merged_branches(&delete_integrated, argv,
+					     (quiet ? DELETE_BRANCH_QUIET : 0) |
+					     (dry_run ? DELETE_BRANCH_DRY_RUN : 0) |
+					     DELETE_BRANCH_INTEGRATED);
+		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
 		ret = 0;
@@ -1340,6 +1573,7 @@ int cmd_branch(int argc,
 
 out:
 	strvec_clear(&delete_merged);
+	strvec_clear(&delete_integrated);
 	string_list_clear(&sorting_options, 0);
 	return ret;
 }
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index cdb6c6a634..64dc4c7561 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1925,6 +1925,39 @@ check_branches () {
 	test_cmp expect actual
 }
 
+test_expect_success '--delete-merged does not delete unrelated branches' '
+	setup_repo_for_delete_merged &&
+	(
+		cd repo &&
+		git checkout -b unrelated-topic --track origin/next &&
+		test_write_lines 1 2 3 4 >file &&
+		git add file &&
+		git commit -m "add file" &&
+		git push origin unrelated-topic &&
+		git checkout --detach
+	) &&
+	(
+		cd upstream &&
+		git checkout next &&
+		test_write_lines 1 2 3 4 >file &&
+		test_write_lines 5 6 7 >elif &&
+		git add file elif &&
+		git commit --author="unrelated user <uu@example.com>" \
+			-m "unrelated commit adding an identical file" &&
+		git checkout main
+	) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		git branch --delete-merged origin/next >actual 2>&1 &&
+		test_must_be_empty actual &&
+		check_branches <<-\EOF
+		main
+		unrelated-topic
+		EOF
+	)
+'
+
 test_expect_success '--delete-merged keeps cloned main without explicit push configuration' '
 	setup_repo_for_delete_merged &&
 	(
@@ -1979,6 +2012,143 @@ test_expect_success '--delete-merged deletes only selected merged branches' '
 	)
 '
 
+push_topic () {
+	branch=$1 &&
+	shift &&
+	(
+		cd repo &&
+		git checkout -b "$branch" --track origin/next &&
+		for commit in "$@"
+		do
+			test_commit "$commit" || return 1
+		done &&
+		git push origin "$branch" &&
+		git checkout --detach
+	)
+}
+
+squash_merge_upstream () {
+	(
+		cd upstream &&
+		git checkout next &&
+		git merge --squash "$1" &&
+		git commit -m "Squash merge of $1" &&
+		git checkout main
+	)
+}
+
+rebase_merge_upstream () {
+	(
+		cd upstream &&
+		git rebase next "$1" &&
+		git checkout next &&
+		git merge --ff-only "$1" &&
+		git checkout main
+	)
+}
+
+test_expect_success '--delete-integrated deletes a rebase merged branch' '
+	setup_repo_for_delete_merged &&
+	push_topic rebased rebased-one rebased-two &&
+	(
+		cd upstream &&
+		git checkout next &&
+		test_commit before-rebase &&
+		git checkout main
+	) &&
+	rebase_merge_upstream rebased &&
+	landed=$(git -C upstream rev-parse --short next) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short rebased) &&
+
+		git branch --delete-integrated origin/next >actual 2>&1 &&
+		echo "Deleted branch rebased (was $sha, landed as $landed)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		EOF
+	)
+'
+
+test_expect_success '--delete-integrated deletes a squash merged branch' '
+	setup_repo_for_delete_merged &&
+	push_topic squashed squashed-one squashed-two &&
+	push_topic partial lands-upstream stays-local &&
+	squash_merge_upstream partial~1 &&
+	squash_merge_upstream squashed &&
+	squash=$(git -C upstream rev-parse --short next) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short squashed) &&
+
+		git branch --delete-integrated origin/next >actual 2>&1 &&
+		echo "Deleted branch squashed (was $sha, landed as $squash)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		partial
+		EOF
+	)
+'
+
+test_expect_success '--delete-integrated deletes a squash merged branch that was reverted' '
+	setup_repo_for_delete_merged &&
+	push_topic reverted reverted-work &&
+	squash_merge_upstream reverted &&
+	squash=$(git -C upstream rev-parse --short next) &&
+	(
+		cd upstream &&
+		git checkout next &&
+		git revert --no-edit HEAD &&
+		git checkout main
+	) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short reverted) &&
+
+		git branch --delete-integrated origin/next >actual 2>&1 &&
+		echo "Deleted branch reverted (was $sha, landed as $squash)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		EOF
+	)
+'
+
+test_expect_success '--delete-integrated reports a merge plainly next to an unmerged branch' '
+	setup_repo_for_delete_merged &&
+	push_topic a-unmerged unmerged-work &&
+	push_topic b-merged merged-work &&
+	(
+		cd upstream &&
+		git checkout next &&
+		test_commit upstream-work &&
+		git merge --no-ff b-merged &&
+		git checkout main
+	) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short b-merged) &&
+
+		git branch --delete-integrated origin/next >actual 2>&1 &&
+		echo "Deleted branch b-merged (was $sha)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		a-unmerged
+		main
+		EOF
+	)
+'
+
 test_expect_success '--delete-merged keeps main despite a different default push remote' '
 	setup_repo_for_delete_merged &&
 	create_merged_branch on-next &&
@@ -2178,4 +2348,9 @@ test_expect_success '--dry-run without --delete-merged is rejected' '
 	test_grep "requires --delete-merged" err
 '
 
+test_expect_success '--delete-integrated cannot be combined with --delete-merged' '
+	test_must_fail git -C forked branch --delete-merged origin/next \
+		--delete-integrated origin/next
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related	[flat|nested] 60+ messages in thread

* [PATCH v4 4/4] branch: let --delete-integrated find branches merged onto changed files
  2026-10-26  8:33 ` [PATCH v4 0/4] " Harald Nordgren via GitGitGadget
                     ` (2 preceding siblings ...)
  2026-10-26  8:33   ` [PATCH v4 3/4] branch: add --delete-integrated to delete rebase and squash merged branches Harald Nordgren via GitGitGadget
@ 2026-10-26  8:33   ` Harald Nordgren via GitGitGadget
  3 siblings, 0 replies; 60+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-10-26  8:33 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, D. Ben Knoble, Phillip Wood, Jasper Mayone,
	Harald Nordgren, Harald Nordgren

From: Harald Nordgren <haraldnordgren@gmail.com>

A branch that upstream integrated with

    git merge --squash topic
    git commit

after upstream had also changed the files the branch touches is still
kept by "git branch --delete-integrated". The squash commit has neither
the branch's version of those files nor the fork point's, so the
previous check cannot see that the work landed. This happens all the
time on hosting services, where the "Squash and merge" button on GitHub
lands pull requests this way while the main branch keeps moving. A
rebase merge onto such changes looks the same.

For each such file, merge the branch's version into the upstream one
and treat the change as landed when that leaves the upstream version
unchanged. This has to read the files, so in a partial clone it may
download blobs.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc | 13 ++++---
 builtin/branch.c              | 71 ++++++++++++++++++++++++++++++-----
 t/t3200-branch.sh             | 42 +++++++++++++++++++++
 3 files changed, 111 insertions(+), 15 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 6be28c8a4e..489a9d5fd0 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -240,11 +240,14 @@ remove it anyway.
 	Like `--delete-merged`, but also delete a branch whose changes
 	the upstream already has even though its commits are not there,
 	as happens after a squash or rebase merge. Such a branch is
-	deleted when some upstream commit since the fork point has its
-	version of every file it changed, even if those changes were
-	later reverted, and the message names the first such commit.
-	It does not matter who made that commit. The same rules for
-	which branches are kept apply as for `--delete-merged`.
+	deleted when some upstream commit since the fork point has all
+	of its changes, even if they were later reverted, and the
+	message names the first such commit. It does not matter who made
+	that commit. A file counts when the commit has the branch's
+	version of it, or when merging the branch's version into the
+	commit's leaves it unchanged, which reads the file and may
+	download blobs in a partial clone. The same rules for which
+	branches are kept apply as for `--delete-merged`.
 
 `--dry-run`::
 	With `--delete-merged` or `--delete-integrated`, print which
diff --git a/builtin/branch.c b/builtin/branch.c
index ba04d8c486..c2343f3164 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -33,8 +33,10 @@
 #include "diff.h"
 #include "diffcore.h"
 #include "hex.h"
+#include "merge-ll.h"
 #include "revision.h"
 #include "tree-walk.h"
+#include "xdiff-interface.h"
 
 static const char * const builtin_branch_usage[] = {
 	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
@@ -851,10 +853,17 @@ static int branch_pushes_to_upstream(struct branch *branch,
 	return ret;
 }
 
+enum change_state {
+	CHANGE_MISSING,
+	CHANGE_LANDED,
+	CHANGE_NEEDS_MERGE,
+};
+
 struct branch_change {
 	char *path;
-	struct object_id branch_oid;
+	struct object_id base_oid, branch_oid, upstream_oid;
 	unsigned short branch_mode;
+	enum change_state state;
 };
 
 struct landing_search {
@@ -886,6 +895,8 @@ static void collect_branch_changes(struct landing_search *search,
 			   search->changes_alloc);
 		change = &search->changes[search->changes_nr++];
 		change->path = xstrdup(p->two->path);
+		oidcpy(&change->base_oid, DIFF_FILE_VALID(p->one) ?
+		       &p->one->oid : null_oid(the_hash_algo));
 		oidcpy(&change->branch_oid, DIFF_FILE_VALID(p->two) ?
 		       &p->two->oid : null_oid(the_hash_algo));
 		change->branch_mode = p->two->mode;
@@ -893,25 +904,65 @@ static void collect_branch_changes(struct landing_search *search,
 	diff_flush(&opt);
 }
 
-static int change_landed(const struct branch_change *change,
-			 struct commit *commit)
+static int merge_keeps_upstream(const struct branch_change *change)
+{
+	mmfile_t base, upstream, branch;
+	mmfile_t result = { 0 };
+	int ret;
+
+	read_mmblob(&base, the_repository->objects, &change->base_oid);
+	read_mmblob(&upstream, the_repository->objects, &change->upstream_oid);
+	read_mmblob(&branch, the_repository->objects, &change->branch_oid);
+	ret = ll_merge(&result, change->path, &base, "base",
+		       &upstream, "upstream", &branch, "branch",
+		       the_repository->index, NULL) == LL_MERGE_OK &&
+	      result.size == upstream.size &&
+	      !memcmp(result.ptr, upstream.ptr, upstream.size);
+
+	free(base.ptr);
+	free(upstream.ptr);
+	free(branch.ptr);
+	free(result.ptr);
+	return ret;
+}
+
+static enum change_state change_state_at(struct branch_change *change,
+					 struct commit *commit)
 {
-	struct object_id oid;
 	unsigned short mode;
 
 	if (get_tree_entry(the_repository, get_commit_tree_oid(commit),
-			   change->path, &oid, &mode))
-		return is_null_oid(&change->branch_oid);
-	return oideq(&oid, &change->branch_oid) &&
-	       mode == change->branch_mode;
+			   change->path, &change->upstream_oid, &mode))
+		return is_null_oid(&change->branch_oid) ?
+			CHANGE_LANDED : CHANGE_MISSING;
+	if (oideq(&change->upstream_oid, &change->branch_oid))
+		return mode == change->branch_mode ?
+			CHANGE_LANDED : CHANGE_MISSING;
+	if (is_null_oid(&change->base_oid) ||
+	    is_null_oid(&change->branch_oid) ||
+	    oideq(&change->upstream_oid, &change->base_oid) ||
+	    mode != change->branch_mode || !S_ISREG(mode))
+		return CHANGE_MISSING;
+	return CHANGE_NEEDS_MERGE;
 }
 
 static int changes_landed(struct landing_search *search,
 			  struct commit *commit)
 {
-	for (size_t i = 0; i < search->changes_nr; i++)
-		if (!change_landed(&search->changes[i], commit))
+	for (size_t i = 0; i < search->changes_nr; i++) {
+		struct branch_change *change = &search->changes[i];
+
+		change->state = change_state_at(change, commit);
+		if (change->state == CHANGE_MISSING)
 			return 0;
+	}
+	for (size_t i = 0; i < search->changes_nr; i++) {
+		struct branch_change *change = &search->changes[i];
+
+		if (change->state == CHANGE_NEEDS_MERGE &&
+		    !merge_keeps_upstream(change))
+			return 0;
+	}
 	return 1;
 }
 
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 64dc4c7561..ecded6085b 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2122,6 +2122,48 @@ test_expect_success '--delete-integrated deletes a squash merged branch that was
 	)
 '
 
+test_expect_success '--delete-integrated deletes a squash merged branch after upstream changed the same file' '
+	(
+		cd upstream &&
+		git checkout next &&
+		test_write_lines 1 2 3 4 5 6 7 8 9 10 >shared &&
+		git add shared &&
+		git commit -m "add shared" &&
+		git checkout main
+	) &&
+	setup_repo_for_delete_merged &&
+	(
+		cd repo &&
+		git checkout -b edits-shared --track origin/next &&
+		test_write_lines 1 two 3 4 5 6 7 8 9 10 >shared &&
+		git commit -a -m "edit line 2" &&
+		git push origin edits-shared &&
+		git checkout --detach
+	) &&
+	(
+		cd upstream &&
+		git checkout next &&
+		test_write_lines 1 2 3 4 five 6 7 8 9 10 >shared &&
+		git commit -a -m "edit line 5" &&
+		git checkout main
+	) &&
+	squash_merge_upstream edits-shared &&
+	squash=$(git -C upstream rev-parse --short next) &&
+	(
+		cd repo &&
+		git fetch origin &&
+		sha=$(git rev-parse --short edits-shared) &&
+
+		git branch --delete-integrated origin/next >actual 2>&1 &&
+		echo "Deleted branch edits-shared (was $sha, landed as $squash)." >expect &&
+		test_cmp expect actual &&
+
+		check_branches <<-\EOF
+		main
+		EOF
+	)
+'
+
 test_expect_success '--delete-integrated reports a merge plainly next to an unmerged branch' '
 	setup_repo_for_delete_merged &&
 	push_topic a-unmerged unmerged-work &&
-- 
gitgitgadget

^ permalink raw reply related	[flat|nested] 60+ messages in thread

end of thread, other threads:[~2026-10-26  8:33 UTC | newest]

Thread overview: 60+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-29  7:30 [PATCH] branch: let --delete-merged find squash merged branches Harald Nordgren via GitGitGadget
2026-09-29  7:46 ` Kristoffer Haugsbakk
2026-09-29  7:52   ` Harald Nordgren
2026-09-29  8:08     ` Kristoffer Haugsbakk
2026-09-29 11:18       ` D. Ben Knoble
2026-09-29 13:31         ` Kristoffer Haugsbakk
2026-09-29 11:26 ` D. Ben Knoble
2026-09-29 13:44   ` Harald Nordgren
2026-09-29 13:52   ` Harald Nordgren
2026-09-29 15:28     ` D. Ben Knoble
2026-09-29 18:34       ` Harald Nordgren
2026-10-04  9:54   ` Phillip Wood
2026-10-04 22:28     ` Harald Nordgren
2026-10-08 15:25       ` Phillip Wood
2026-10-08 18:42         ` Harald Nordgren
2026-10-09  8:16           ` Kristoffer Haugsbakk
2026-10-09 11:11             ` Harald Nordgren
2026-10-09 11:18           ` Harald Nordgren
2026-10-09 13:40             ` Phillip Wood
2026-10-09 13:37           ` Phillip Wood
2026-10-09 15:27             ` Harald Nordgren
2026-10-08 17:51     ` D. Ben Knoble
2026-10-09 13:34       ` Phillip Wood
2026-10-11  9:41 ` [PATCH v2 0/2] " Harald Nordgren via GitGitGadget
2026-10-11  9:41   ` [PATCH v2 1/2] " Harald Nordgren via GitGitGadget
2026-10-11  9:41   ` [PATCH v2 2/2] branch: add --no-squashed to skip the squash search Harald Nordgren via GitGitGadget
2026-10-12  0:30   ` [PATCH v2 0/2] branch: let --delete-merged find squash merged branches Junio C Hamano
2026-10-12  9:50     ` Phillip Wood
2026-10-12 14:31       ` Harald Nordgren
2026-10-12 16:50       ` Junio C Hamano
2026-10-12 17:44         ` Harald Nordgren
2026-10-13  2:23           ` Junio C Hamano
2026-10-17 22:23             ` Harald Nordgren
2026-10-19  4:40             ` Jasper Mayone
2026-10-18 15:09         ` Harald Nordgren
2026-10-12 12:41     ` Harald Nordgren
2026-10-12 15:49       ` D. Ben Knoble
2026-10-21 12:28 ` [PATCH v3 0/3] " Harald Nordgren via GitGitGadget
2026-10-21 12:28   ` [PATCH v3 1/3] branch: move the deleteMerged opt-out check into a helper Harald Nordgren via GitGitGadget
2026-10-21 12:28   ` [PATCH v3 2/3] branch: let --delete-merged find rebase merged branches Harald Nordgren via GitGitGadget
2026-10-21 12:28   ` [PATCH v3 3/3] branch: add --squashed to find squash " Harald Nordgren via GitGitGadget
2026-10-21 17:35   ` [PATCH v3 0/3] branch: let --delete-merged " Jasper Mayone
2026-10-21 18:51     ` Harald Nordgren
2026-10-22 12:55       ` D. Ben Knoble
2026-10-22 14:41         ` Harald Nordgren
2026-10-22  5:49   ` Junio C Hamano
2026-10-22  7:18     ` Harald Nordgren
2026-10-22 16:33       ` Junio C Hamano
2026-10-22 16:57         ` Junio C Hamano
2026-10-22 17:31           ` Harald Nordgren
2026-10-22 17:44             ` Jasper Mayone
2026-10-22 17:56               ` Junio C Hamano
2026-10-23  9:07               ` Harald Nordgren
2026-10-22 17:44         ` Harald Nordgren
2026-10-22 12:59   ` D. Ben Knoble
2026-10-26  8:33 ` [PATCH v4 0/4] " Harald Nordgren via GitGitGadget
2026-10-26  8:33   ` [PATCH v4 1/4] branch: move the deleteMerged opt-out check into a helper Harald Nordgren via GitGitGadget
2026-10-26  8:33   ` [PATCH v4 2/4] branch: let delete_branches() name where a branch landed Harald Nordgren via GitGitGadget
2026-10-26  8:33   ` [PATCH v4 3/4] branch: add --delete-integrated to delete rebase and squash merged branches Harald Nordgren via GitGitGadget
2026-10-26  8:33   ` [PATCH v4 4/4] branch: let --delete-integrated find branches merged onto changed files Harald Nordgren via GitGitGadget

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox