Volume XXII, number 279Tuesday, October 6, 2026Latest message 22 minutes ago

The Git List

News and archive of git@vger.kernel.org, since April 2005

patchbranch: let --delete-merged find squash merged branches

13 messages between Sep 29, 2026 and Oct 4, 2026, from Harald Nordgren via GitGitGadget, Kristoffer Haugsbakk, Harald Nordgren, D. Ben Knoble, Phillip Wood.

Plain Markdown or JSON for tools and agents. Diffs are folded; open one to read it.

Harald Nordgren via GitGitGadgetSep 29, 2026, 07:30 UTC on lore
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(-)
Show changes to 3 files +259 −13

Documentation/git-branch.adoc, builtin/branch.c, t/t3200-branch.sh

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 HaugsbakkSep 29, 2026, 07:46 UTC in reply to Harald Nordgren via GitGitGadget on lore

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

On Tue, Sep 29, 2026, at 09:30, Harald Nordgren via GitGitGadget wrote:
Show 6 quoted lines
> 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 NordgrenSep 29, 2026, 07:52 UTC in reply to Kristoffer Haugsbakk on lore

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

On Tue, Sep 29, 2026 at 9:47 AM Kristoffer Haugsbakk <kristofferhaugsbakk@fastmail.com> wrote:

Show 13 quoted lines
>
> 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 HaugsbakkSep 29, 2026, 08:08 UTC in reply to Harald Nordgren on lore

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

On Tue, Sep 29, 2026, at 09:52, Harald Nordgren wrote:
Show 22 quoted lines
> 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 KnobleSep 29, 2026, 11:18 UTC in reply to Kristoffer Haugsbakk on lore

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

On Tue, Sep 29, 2026 at 4:17 AM Kristoffer Haugsbakk <kristofferhaugsbakk@fastmail.com> wrote:

Show 31 quoted lines
>
> 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 KnobleSep 29, 2026, 11:26 UTC in reply to Harald Nordgren via GitGitGadget on lore

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

Without looking too much further…

On Tue, Sep 29, 2026 at 3:33 AM Harald Nordgren via GitGitGadget <gitgitgadget@gmail.com> wrote:

Show 21 quoted lines
>
> 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 HaugsbakkSep 29, 2026, 13:31 UTC in reply to D. Ben Knoble on lore

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

On Tue, Sep 29, 2026, at 13:18, D. Ben Knoble wrote:
Show 17 quoted lines
> 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...

Show 6 quoted lines
>
> 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 NordgrenSep 29, 2026, 13:44 UTC in reply to D. Ben Knoble on lore

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

Show 7 quoted lines
> > 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 NordgrenSep 29, 2026, 13:52 UTC in reply to D. Ben Knoble on lore

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

Show 6 quoted lines
> 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 KnobleSep 29, 2026, 15:28 UTC in reply to Harald Nordgren on lore

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

On Tue, Sep 29, 2026 at 9:53 AM Harald Nordgren <haraldnordgren@gmail.com> wrote:

Show 13 quoted lines
>
> > 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 NordgrenSep 29, 2026, 18:34 UTC in reply to D. Ben Knoble on lore

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

Show 8 quoted lines
> > 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 WoodOct 4, 2026, 09:54 UTC in reply to D. Ben Knoble on lore

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

On 29/09/2026 12:26, D. Ben Knoble wrote:
Show 6 quoted lines
> 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.

Show 5 quoted lines
> 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
Show 20 quoted lines
> 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 NordgrenOct 4, 2026, 22:28 UTC in reply to Phillip Wood on lore

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

> 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

Back to recent threads