# [PATCH] branch: let --delete-merged find squash merged branches

13 messages from 2026-09-29 to 2026-10-04. Participants: Harald Nordgren via GitGitGadget, Kristoffer Haugsbakk, Harald Nordgren, D. Ben Knoble, Phillip Wood.
Thread: https://gitlist.dev/t/66417

## Harald Nordgren via GitGitGadget, 2026-09-29 07:30

Subject: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <pull.2425.git.git.1790667030497.gitgitgadget@gmail.com>

```
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

```

## Kristoffer Haugsbakk, 2026-09-29 07:46

Subject: Re: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <9a6bfc3c-8759-4fbe-9e90-5dec9d00e278@app.fastmail.com>
In-Reply-To: <pull.2425.git.git.1790667030497.gitgitgadget@gmail.com>

```
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]

```

## Harald Nordgren, 2026-09-29 07:52

Subject: Re: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <CAHwyqnVf_D3qV1OVYiCnLz2tVteRXdWYTGBaNTJpkVtDwCC1vg@mail.gmail.com>
In-Reply-To: <9a6bfc3c-8759-4fbe-9e90-5dec9d00e278@app.fastmail.com>

```
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

```

## Kristoffer Haugsbakk, 2026-09-29 08:08

Subject: Re: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <d4fd92ea-b1c5-4528-9e9e-0b1ab600891e@app.fastmail.com>
In-Reply-To: <CAHwyqnVf_D3qV1OVYiCnLz2tVteRXdWYTGBaNTJpkVtDwCC1vg@mail.gmail.com>

```
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.

```

## D. Ben Knoble, 2026-09-29 11:18

Subject: Re: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <CALnO6CBq5Udc1rbk6efRj1q5pJNUDt-uvmSGDDen=CMH0dFOHQ@mail.gmail.com>
In-Reply-To: <d4fd92ea-b1c5-4528-9e9e-0b1ab600891e@app.fastmail.com>

```
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

```

## D. Ben Knoble, 2026-09-29 11:26

Subject: Re: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <CALnO6CBwWy3aafyDJPKFk5vuWy2EF1n1Oc=W7+RVAE3rxpXwiw@mail.gmail.com>
In-Reply-To: <pull.2425.git.git.1790667030497.gitgitgadget@gmail.com>

```
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

```

## Kristoffer Haugsbakk, 2026-09-29 13:31

Subject: Re: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <4c4fba69-e474-4ee3-8e34-73e76d42d3d5@app.fastmail.com>
In-Reply-To: <CALnO6CBq5Udc1rbk6efRj1q5pJNUDt-uvmSGDDen=CMH0dFOHQ@mail.gmail.com>

```
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.

```

## Harald Nordgren, 2026-09-29 13:44

Subject: Re: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <CAHwyqnUkz+7FH4QY-EB__dOc2rWNNwy9EL_b7R7cA1oPvequ=A@mail.gmail.com>
In-Reply-To: <CALnO6CBwWy3aafyDJPKFk5vuWy2EF1n1Oc=W7+RVAE3rxpXwiw@mail.gmail.com>

```
> > 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

```

## Harald Nordgren, 2026-09-29 13:52

Subject: Re: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <CAHwyqnXLAQgTen2nu6xX63ch7Z9V8kZ-c3zbZgw_xwEVYaz=oA@mail.gmail.com>
In-Reply-To: <CALnO6CBwWy3aafyDJPKFk5vuWy2EF1n1Oc=W7+RVAE3rxpXwiw@mail.gmail.com>

```
> 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

```

## D. Ben Knoble, 2026-09-29 15:28

Subject: Re: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <CALnO6CC4fS0LWe6ta7B-C3dQXSjfYhab-e=EHdneRRqfa9mAZw@mail.gmail.com>
In-Reply-To: <CAHwyqnXLAQgTen2nu6xX63ch7Z9V8kZ-c3zbZgw_xwEVYaz=oA@mail.gmail.com>

```
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

```

## Harald Nordgren, 2026-09-29 18:34

Subject: Re: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <CAHwyqnXA6kxktxNRPCbq0hTdH+G+3jSkCq5ghkS6QkJEgA1wkw@mail.gmail.com>
In-Reply-To: <CALnO6CC4fS0LWe6ta7B-C3dQXSjfYhab-e=EHdneRRqfa9mAZw@mail.gmail.com>

```
> > 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

```

## Phillip Wood, 2026-10-04 09:54

Subject: Re: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <39a28064-1698-4971-a80f-4a4c4dcdd8d9@gmail.com>
In-Reply-To: <CALnO6CBwWy3aafyDJPKFk5vuWy2EF1n1Oc=W7+RVAE3rxpXwiw@mail.gmail.com>

```
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!
> 


```

## Harald Nordgren, 2026-10-04 22:28

Subject: Re: [PATCH] branch: let --delete-merged find squash merged branches
Message-ID: <CAHwyqnVoMnO_fYGJ0N29bQv=Lh5naZ0jc5uSpiS2urQMZVG5-Q@mail.gmail.com>
In-Reply-To: <39a28064-1698-4971-a80f-4a4c4dcdd8d9@gmail.com>

```
> 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

```
