{"thread":{"id":"66417","subject":"[PATCH] branch: let --delete-merged find squash merged branches","startedAt":"2026-09-29T07:30:33Z","lastAt":"2026-10-04T22:29:15Z","messageCount":13,"participants":["Harald Nordgren via GitGitGadget","Kristoffer Haugsbakk","Harald Nordgren","D. Ben Knoble","Phillip Wood"],"isPatch":true,"patchVersion":1,"patchTotal":null},"messages":[{"id":"553554","messageId":"pull.2425.git.git.1790667030497.gitgitgadget@gmail.com","threadId":"66417","inReplyTo":null,"subject":"[PATCH] branch: let --delete-merged find squash merged branches","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-29T07:30:30Z","receivedAt":"2026-09-29T07:30:33Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nBranches merged on GitHub with \"Squash and merge\" or \"Rebase and\nmerge\" are never deleted by \"git branch --delete-merged\". The upstream\nholds a rewritten copy of their work, so their tips are not reachable\nfrom it and they look unmerged forever.\n\nTreat such a branch as merged when some upstream commit since the fork\npoint contains all of its changes, so that merging the branch into\nthat commit would change nothing. Name that commit in the output so\nthe user can see where the work went:\n\n    Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).\n\nThe first upstream commit that contains the changes is used, so the\nbranch is deleted even if upstream later reverted or reworked them.\nNothing is lost, since that commit keeps them in the upstream history.\nA branch whose changes only partly landed is kept.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n    branch: let --delete-merged find squash merged branches\n    \n    Branches merged on GitHub with \"Squash and merge\" or \"Rebase and merge\"\n    are never deleted by git branch --delete-merged, because their tips are\n    not reachable from the upstream. This treats such a branch as merged\n    when some upstream commit contains all of its changes, and names that\n    commit in the output:\n    \n    Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).\n    \n    \n    After the release of 2.56, I saw people liking the --delete-merged\n    feature, but asking for this. A lot of people, me included prefer\n    squash-merge and it currently doesn't work with --delete-merged.\n\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2425%2FHaraldNordgren%2Fbranch-delete-squashed-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2425/HaraldNordgren/branch-delete-squashed-v1\nPull-Request: https://github.com/git/git/pull/2425\n\n Documentation/git-branch.adoc |  15 +--\n builtin/branch.c              | 183 ++++++++++++++++++++++++++++++++--\n t/t3200-branch.sh             |  74 ++++++++++++++\n 3 files changed, 259 insertions(+), 13 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex bfdf459329..0427324de1 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -204,12 +204,15 @@ This option is only applicable in non-verbose mode.\n \n `--delete-merged <pattern>`::\n \tDelete local branches whose configured upstream matches\n-\t_<pattern>_, but only when their tip is reachable from that\n-\tupstream. In other words, the work on the branch has already\n-\tlanded on the upstream it tracks, so the local copy is no longer\n-\tneeded. _<pattern>_ may name a ref, a remote (using the branch its\n-\t`HEAD` points at), or a shell-style glob. The option can be\n-\trepeated to widen the upstream match.\n+\t_<pattern>_, but only when their work has already landed on that\n+\tupstream, so the local copy is no longer needed. This is the case\n+\twhen their tip is reachable from the upstream, or when some\n+\tupstream commit contains all of their changes, as happens after\n+\ta squash or rebase merge, even if those changes were later\n+\treverted. The message for such a branch names the first upstream\n+\tcommit that contains its changes. _<pattern>_ may name a ref, a\n+\tremote (using the branch its `HEAD` points at), or a shell-style\n+\tglob. The option can be repeated to widen the upstream match.\n \tOptional _<branch-pattern>_ arguments limit which local branches\n \tare considered, e.g. `git branch --delete-merged 'origin/*'\n \t'topic-*'`.\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex a613148fc7..982a8abe24 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -29,6 +29,13 @@\n #include \"help.h\"\n #include \"advice.h\"\n #include \"commit-reach.h\"\n+#include \"diff.h\"\n+#include \"diffcore.h\"\n+#include \"hex.h\"\n+#include \"merge-ll.h\"\n+#include \"revision.h\"\n+#include \"tree-walk.h\"\n+#include \"xdiff-interface.h\"\n \n static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n@@ -236,7 +243,7 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int kinds,\n-\t\t\t   unsigned int flags)\n+\t\t\t   unsigned int flags, struct strmap *landed_commits)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -334,6 +341,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\t}\n \n \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\t    !(landed_commits &&\n+\t\t      strmap_contains(landed_commits, bname.buf)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n \t\t\t\t\tflags)) {\n \t\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED))\n@@ -357,15 +366,33 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n+\t\tstruct commit *landed = landed_commits ?\n+\t\t\tstrmap_get(landed_commits, name + branch_name_pos) : NULL;\n+\t\tconst char *landed_abbrev = landed ?\n+\t\t\trepo_find_unique_abbrev(the_repository,\n+\t\t\t\t\t\t&landed->object.oid,\n+\t\t\t\t\t\tDEFAULT_ABBREV) : NULL;\n+\n \t\tif (flags & DELETE_BRANCH_DRY_RUN) {\n-\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\tif (flags & DELETE_BRANCH_QUIET)\n+\t\t\t\t;\n+\t\t\telse if (landed)\n+\t\t\t\tprintf(_(\"Would delete branch %s (was %s, landed as %s).\\n\"),\n+\t\t\t\t       name + branch_name_pos, describe_ref,\n+\t\t\t\t       landed_abbrev);\n+\t\t\telse\n \t\t\t\tprintf(remote_branch\n \t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n \t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n \t\t\t\t\tname + branch_name_pos, describe_ref);\n \t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n-\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\tif (flags & DELETE_BRANCH_QUIET)\n+\t\t\t\t;\n+\t\t\telse if (landed)\n+\t\t\t\tprintf(_(\"Deleted branch %s (was %s, landed as %s).\\n\"),\n+\t\t\t\t       refname, describe_ref, landed_abbrev);\n+\t\t\telse\n \t\t\t\tprintf(remote_branch\n \t\t\t\t\t? _(\"Deleted remote-tracking branch %s (was %s).\\n\")\n \t\t\t\t\t: _(\"Deleted branch %s (was %s).\\n\"),\n@@ -824,6 +851,134 @@ static int branch_pushes_to_upstream(struct branch *branch,\n \treturn ret;\n }\n \n+struct branch_change {\n+\tchar *path;\n+\tstruct object_id base_oid, branch_oid;\n+\tunsigned short branch_mode;\n+};\n+\n+static void collect_branch_changes(struct commit *base, struct commit *rev,\n+\t\t\t\t   struct branch_change **changes,\n+\t\t\t\t   size_t *nr, size_t *alloc)\n+{\n+\tstruct diff_options opt;\n+\n+\trepo_diff_setup(the_repository, &opt);\n+\topt.flags.recursive = 1;\n+\topt.output_format = DIFF_FORMAT_NO_OUTPUT;\n+\tdiff_setup_done(&opt);\n+\tdiff_tree_oid(get_commit_tree_oid(base), get_commit_tree_oid(rev),\n+\t\t      \"\", &opt);\n+\tfor (int i = 0; i < diff_queued_diff.nr; i++) {\n+\t\tstruct diff_filepair *p = diff_queued_diff.queue[i];\n+\t\tstruct branch_change *change;\n+\n+\t\tALLOC_GROW(*changes, *nr + 1, *alloc);\n+\t\tchange = &(*changes)[(*nr)++];\n+\t\tchange->path = xstrdup(p->two->path);\n+\t\toidcpy(&change->base_oid, DIFF_FILE_VALID(p->one) ?\n+\t\t       &p->one->oid : null_oid(the_hash_algo));\n+\t\toidcpy(&change->branch_oid, DIFF_FILE_VALID(p->two) ?\n+\t\t       &p->two->oid : null_oid(the_hash_algo));\n+\t\tchange->branch_mode = p->two->mode;\n+\t}\n+\tdiff_flush(&opt);\n+}\n+\n+static int merge_keeps_upstream(const struct branch_change *change,\n+\t\t\t\tconst struct object_id *upstream_oid)\n+{\n+\tmmfile_t base, upstream, branch;\n+\tmmbuffer_t result = { 0 };\n+\tint ret;\n+\n+\tread_mmblob(&base, the_repository->objects, &change->base_oid);\n+\tread_mmblob(&upstream, the_repository->objects, upstream_oid);\n+\tread_mmblob(&branch, the_repository->objects, &change->branch_oid);\n+\tret = ll_merge(&result, change->path, &base, \"base\",\n+\t\t       &upstream, \"upstream\", &branch, \"branch\",\n+\t\t       the_repository->index, NULL) == LL_MERGE_OK &&\n+\t      result.size == upstream.size &&\n+\t      !memcmp(result.ptr, upstream.ptr, upstream.size);\n+\n+\tfree(base.ptr);\n+\tfree(upstream.ptr);\n+\tfree(branch.ptr);\n+\tfree(result.ptr);\n+\treturn ret;\n+}\n+\n+static int change_landed(const struct branch_change *change,\n+\t\t\t struct commit *commit)\n+{\n+\tstruct object_id oid;\n+\tunsigned short mode;\n+\n+\tif (get_tree_entry(the_repository, get_commit_tree_oid(commit),\n+\t\t\t   change->path, &oid, &mode))\n+\t\treturn is_null_oid(&change->branch_oid);\n+\tif (oideq(&oid, &change->branch_oid))\n+\t\treturn mode == change->branch_mode;\n+\tif (is_null_oid(&change->base_oid) ||\n+\t    is_null_oid(&change->branch_oid) ||\n+\t    oideq(&oid, &change->base_oid) ||\n+\t    mode != change->branch_mode || !S_ISREG(mode))\n+\t\treturn 0;\n+\treturn merge_keeps_upstream(change, &oid);\n+}\n+\n+static struct commit *find_landed_commit(struct commit *rev,\n+\t\t\t\t\t struct commit *upstream)\n+{\n+\tstruct commit_list *merge_bases = NULL;\n+\tstruct branch_change *changes = NULL;\n+\tsize_t changes_nr = 0, changes_alloc = 0;\n+\tstruct commit *commit, *landed = NULL;\n+\tstruct strvec args = STRVEC_INIT;\n+\tstruct rev_info revs;\n+\n+\tif (repo_get_merge_bases(the_repository, upstream, rev,\n+\t\t\t\t &merge_bases) < 0)\n+\t\texit(128);\n+\tif (!merge_bases)\n+\t\treturn NULL;\n+\tcollect_branch_changes(merge_bases->item, rev, &changes,\n+\t\t\t       &changes_nr, &changes_alloc);\n+\tcommit_list_free(merge_bases);\n+\tif (!changes_nr)\n+\t\treturn NULL;\n+\n+\tstrvec_pushl(&args, \"rev-list\", \"--reverse\",\n+\t\t     oid_to_hex(&upstream->object.oid), NULL);\n+\tstrvec_pushf(&args, \"^%s\", oid_to_hex(&rev->object.oid));\n+\tstrvec_push(&args, \"--\");\n+\tfor (size_t i = 0; i < changes_nr; i++)\n+\t\tstrvec_pushf(&args, \":(literal)%s\", changes[i].path);\n+\n+\trepo_init_revisions(the_repository, &revs, NULL);\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\tif (prepare_revision_walk(&revs))\n+\t\tdie(_(\"revision walk setup failed\"));\n+\twhile (!landed && (commit = get_revision(&revs))) {\n+\t\tsize_t i;\n+\n+\t\tfor (i = 0; i < changes_nr; i++)\n+\t\t\tif (!change_landed(&changes[i], commit))\n+\t\t\t\tbreak;\n+\t\tif (i == changes_nr)\n+\t\t\tlanded = commit;\n+\t}\n+\trelease_revisions(&revs);\n+\tclear_commit_marks(upstream, ALL_REV_FLAGS);\n+\tclear_commit_marks(rev, ALL_REV_FLAGS);\n+\tstrvec_clear(&args);\n+\n+\tfor (size_t i = 0; i < changes_nr; i++)\n+\t\tfree(changes[i].path);\n+\tfree(changes);\n+\treturn landed;\n+}\n+\n static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t const char **argv, unsigned int flags)\n {\n@@ -832,6 +987,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \tstruct ref_array candidates = { 0 };\n \tstruct strset deletable_branch_names = STRSET_INIT;\n \tstruct strset protected_branch_names = STRSET_INIT;\n+\tstruct strmap landed_commits = STRMAP_INIT;\n \tstruct strvec branches_to_delete = STRVEC_INIT;\n \tstruct strbuf key = STRBUF_INIT;\n \tstruct hashmap_iter iter;\n@@ -852,6 +1008,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\tconst char *branch_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream_refname;\n+\t\tstruct commit *landed = NULL;\n \t\tint opt_out;\n \n \t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n@@ -867,8 +1024,17 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\tcontinue;\n \t\tif (check_branch_commit(branch_name, branch_name,\n \t\t\t\t\t&candidates.items[i]->objectname, NULL,\n-\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n-\t\t\tcontinue;\n+\t\t\t\t\tFILTER_REFS_BRANCHES,\n+\t\t\t\t\tDELETE_BRANCH_SKIP_UNMERGED)) {\n+\t\t\tstruct commit *rev = lookup_commit_reference(\n+\t\t\t\tthe_repository, &candidates.items[i]->objectname);\n+\t\t\tstruct commit *upstream = lookup_commit_reference_by_name(\n+\t\t\t\tupstream_refname);\n+\n+\t\t\tif (!rev || !upstream ||\n+\t\t\t    !(landed = find_landed_commit(rev, upstream)))\n+\t\t\t\tcontinue;\n+\t\t}\n \n \t\tstrbuf_reset(&key);\n \t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", branch_name);\n@@ -882,6 +1048,8 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t}\n \n \t\tstrset_add(&deletable_branch_names, branch_name);\n+\t\tif (landed)\n+\t\t\tstrmap_put(&landed_commits, branch_name, landed);\n \t}\n \n \tprotect_stacked_branch_bases(refs, &deletable_branch_names,\n@@ -895,7 +1063,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t      FILTER_REFS_BRANCHES,\n \t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n-\t\t\t\t      flags);\n+\t\t\t\t      flags, &landed_commits);\n \n \tif (!ret && !(flags & DELETE_BRANCH_DRY_RUN))\n \t\tclear_deleted_upstreams(&protected_branch_names,\n@@ -903,6 +1071,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \n \tstrbuf_release(&key);\n \tstrvec_clear(&branches_to_delete);\n+\tstrmap_clear(&landed_commits, 0);\n \tstrset_clear(&protected_branch_names);\n \tstrset_clear(&deletable_branch_names);\n \tref_array_clear(&candidates);\n@@ -1135,7 +1304,7 @@ int cmd_branch(int argc,\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, filter.kind,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n-\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0), NULL);\n \t\tgoto out;\n \t} else if (delete_merged.nr) {\n \t\tret = delete_merged_branches(&delete_merged, argv,\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex cdb6c6a634..3b05718bab 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1979,6 +1979,80 @@ test_expect_success '--delete-merged deletes only selected merged branches' '\n \t)\n '\n \n+push_topic () {\n+\tbranch=$1 &&\n+\tshift &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b \"$branch\" --track origin/next &&\n+\t\tfor commit in \"$@\"\n+\t\tdo\n+\t\t\ttest_commit \"$commit\" || return 1\n+\t\tdone &&\n+\t\tgit push origin \"$branch\" &&\n+\t\tgit checkout --detach\n+\t)\n+}\n+\n+squash_merge_upstream () {\n+\t(\n+\t\tcd upstream &&\n+\t\tgit checkout next &&\n+\t\tgit merge --squash \"$1\" &&\n+\t\tgit commit -m \"Squash merge of $1\" &&\n+\t\tgit checkout main\n+\t)\n+}\n+\n+test_expect_success '--delete-merged deletes a squash merged branch' '\n+\tsetup_repo_for_delete_merged &&\n+\tpush_topic squashed squashed-one squashed-two &&\n+\tpush_topic partial partial-landed partial-pending &&\n+\tsquash_merge_upstream partial~1 &&\n+\tsquash_merge_upstream squashed &&\n+\tsquash=$(git -C upstream rev-parse --short next) &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit fetch origin &&\n+\t\tsha=$(git rev-parse --short squashed) &&\n+\n+\t\tgit branch --delete-merged origin/next >actual 2>&1 &&\n+\t\techo \"Deleted branch squashed (was $sha, landed as $squash).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tpartial\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged deletes a squash merged branch that was reverted' '\n+\tsetup_repo_for_delete_merged &&\n+\tpush_topic reverted reverted-work &&\n+\tsquash_merge_upstream reverted &&\n+\tsquash=$(git -C upstream rev-parse --short next) &&\n+\t(\n+\t\tcd upstream &&\n+\t\tgit checkout next &&\n+\t\tgit revert --no-edit HEAD &&\n+\t\tgit checkout main\n+\t) &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit fetch origin &&\n+\t\tsha=$(git rev-parse --short reverted) &&\n+\n+\t\tgit branch --delete-merged origin/next >actual 2>&1 &&\n+\t\techo \"Deleted branch reverted (was $sha, landed as $squash).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n test_expect_success '--delete-merged keeps main despite a different default push remote' '\n \tsetup_repo_for_delete_merged &&\n \tcreate_merged_branch on-next &&\n\nbase-commit: a018953688f1b10bddf91bff8747068f5f4746a4\n-- \ngitgitgadget\n"},{"id":"553557","messageId":"9a6bfc3c-8759-4fbe-9e90-5dec9d00e278@app.fastmail.com","threadId":"66417","inReplyTo":"pull.2425.git.git.1790667030497.gitgitgadget@gmail.com","subject":"Re: [PATCH] branch: let --delete-merged find squash merged branches","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2026-09-29T07:46:45Z","receivedAt":"2026-09-29T07:47:08Z","isPatch":true,"body":"On Tue, Sep 29, 2026, at 09:30, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n>\n> Branches merged on GitHub with \"Squash and merge\" or \"Rebase and\n> merge\" are never deleted by \"git branch --delete-merged\". The upstream\n> holds a rewritten copy of their work, so their tips are not reachable\n> from it and they look unmerged forever.\n\nAn example closer to git(1)’s home:\n\n    git merge --squash\n    git commit\n\n>[snip]\n"},{"id":"553560","messageId":"CAHwyqnVf_D3qV1OVYiCnLz2tVteRXdWYTGBaNTJpkVtDwCC1vg@mail.gmail.com","threadId":"66417","inReplyTo":"9a6bfc3c-8759-4fbe-9e90-5dec9d00e278@app.fastmail.com","subject":"Re: [PATCH] branch: let --delete-merged find squash merged branches","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-09-29T07:52:01Z","receivedAt":"2026-09-29T07:52:40Z","isPatch":true,"body":"On Tue, Sep 29, 2026 at 9:47 AM Kristoffer Haugsbakk\n<kristofferhaugsbakk@fastmail.com> wrote:\n>\n> On Tue, Sep 29, 2026, at 09:30, Harald Nordgren via GitGitGadget wrote:\n> > From: Harald Nordgren <haraldnordgren@gmail.com>\n> >\n> > Branches merged on GitHub with \"Squash and merge\" or \"Rebase and\n> > merge\" are never deleted by \"git branch --delete-merged\". The upstream\n> > holds a rewritten copy of their work, so their tips are not reachable\n> > from it and they look unmerged forever.\n>\n> An example closer to git(1)’s home:\n>\n>     git merge --squash\n>     git commit\n\nTrue. But likely it opens up the question of _why_ would anyone on\nupstream be doing such destructive actions? Well, then the answer is\nof course that millions of users (including) me do that via GitHub all\nthe time.\n\nMaybe I should include both examples in my text.\n\n\nHarald\n"},{"id":"553561","messageId":"d4fd92ea-b1c5-4528-9e9e-0b1ab600891e@app.fastmail.com","threadId":"66417","inReplyTo":"CAHwyqnVf_D3qV1OVYiCnLz2tVteRXdWYTGBaNTJpkVtDwCC1vg@mail.gmail.com","subject":"Re: [PATCH] branch: let --delete-merged find squash merged branches","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2026-09-29T08:08:54Z","receivedAt":"2026-09-29T08:09:20Z","isPatch":true,"body":"On Tue, Sep 29, 2026, at 09:52, Harald Nordgren wrote:\n> On Tue, Sep 29, 2026 at 9:47 AM Kristoffer Haugsbakk\n> <kristofferhaugsbakk@fastmail.com> wrote:\n>>\n>> On Tue, Sep 29, 2026, at 09:30, Harald Nordgren via GitGitGadget wrote:\n>> > From: Harald Nordgren <haraldnordgren@gmail.com>\n>> >\n>> > Branches merged on GitHub with \"Squash and merge\" or \"Rebase and\n>> > merge\" are never deleted by \"git branch --delete-merged\". The upstream\n>> > holds a rewritten copy of their work, so their tips are not reachable\n>> > from it and they look unmerged forever.\n>>\n>> An example closer to git(1)’s home:\n>>\n>>     git merge --squash\n>>     git commit\n>\n> True. But likely it opens up the question of _why_ would anyone on\n> upstream be doing such destructive actions? Well, then the answer is\n> of course that millions of users (including) me do that via GitHub all\n> the time.\n>\n> Maybe I should include both examples in my text.\n\nMy *guess* is that `git merge --squash` inspired the forge squashes.\nBut the forges popularized it.\n\nThe apparent `git merge --squash` approach of using `git log` for\nconcatenating the commit messages isn’t that nice in my opinion. So I\nwonder how much it is used.\n"},{"id":"553582","messageId":"CALnO6CBq5Udc1rbk6efRj1q5pJNUDt-uvmSGDDen=CMH0dFOHQ@mail.gmail.com","threadId":"66417","inReplyTo":"d4fd92ea-b1c5-4528-9e9e-0b1ab600891e@app.fastmail.com","subject":"Re: [PATCH] branch: let --delete-merged find squash merged branches","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-09-29T11:18:43Z","receivedAt":"2026-09-29T11:18:54Z","isPatch":true,"body":"On Tue, Sep 29, 2026 at 4:17 AM Kristoffer Haugsbakk\n<kristofferhaugsbakk@fastmail.com> wrote:\n>\n> On Tue, Sep 29, 2026, at 09:52, Harald Nordgren wrote:\n> > On Tue, Sep 29, 2026 at 9:47 AM Kristoffer Haugsbakk\n> > <kristofferhaugsbakk@fastmail.com> wrote:\n> >>\n> >> On Tue, Sep 29, 2026, at 09:30, Harald Nordgren via GitGitGadget wrote:\n> >> > From: Harald Nordgren <haraldnordgren@gmail.com>\n> >> >\n> >> > Branches merged on GitHub with \"Squash and merge\" or \"Rebase and\n> >> > merge\" are never deleted by \"git branch --delete-merged\". The upstream\n> >> > holds a rewritten copy of their work, so their tips are not reachable\n> >> > from it and they look unmerged forever.\n> >>\n> >> An example closer to git(1)’s home:\n> >>\n> >>     git merge --squash\n> >>     git commit\n> >\n> > True. But likely it opens up the question of _why_ would anyone on\n> > upstream be doing such destructive actions? Well, then the answer is\n> > of course that millions of users (including) me do that via GitHub all\n> > the time.\n> >\n> > Maybe I should include both examples in my text.\n>\n> My *guess* is that `git merge --squash` inspired the forge squashes.\n> But the forges popularized it.\n>\n> The apparent `git merge --squash` approach of using `git log` for\n> concatenating the commit messages isn’t that nice in my opinion. So I\n> wonder how much it is used.\n\nThe same behavior is present in GitHub's default squash merge message,\nand almost no one I work with bothers to edit it.\n\nIt's really sad to lose the opportunity to have good commit messages\nwhen using squash-and-merge on a forge---not because we *cannot*, but\nbecause the defaults do not *encourage* it (and we all know how\ndefaults affect user behavior!).\n\nAnyway, see https://benknoble.github.io/blog/2024/08/02/github-squash/\nfor a distillation of my thoughts from working around folks that\nsquash carelessly.\n\nSo, anecdotally: it is used widely due to defaults. Blech.\n\n-- \nD. Ben Knoble\n"},{"id":"553588","messageId":"CALnO6CBwWy3aafyDJPKFk5vuWy2EF1n1Oc=W7+RVAE3rxpXwiw@mail.gmail.com","threadId":"66417","inReplyTo":"pull.2425.git.git.1790667030497.gitgitgadget@gmail.com","subject":"Re: [PATCH] branch: let --delete-merged find squash merged branches","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-09-29T11:26:20Z","receivedAt":"2026-09-29T11:26:33Z","isPatch":true,"body":"Without looking too much further…\n\nOn Tue, Sep 29, 2026 at 3:33 AM Harald Nordgren via GitGitGadget\n<gitgitgadget@gmail.com> wrote:\n>\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n>\n> Branches merged on GitHub with \"Squash and merge\" or \"Rebase and\n> merge\" are never deleted by \"git branch --delete-merged\". The upstream\n> holds a rewritten copy of their work, so their tips are not reachable\n> from it and they look unmerged forever.\n>\n> Treat such a branch as merged when some upstream commit since the fork\n> point contains all of its changes, so that merging the branch into\n> that commit would change nothing. Name that commit in the output so\n> the user can see where the work went:\n>\n>     Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6).\n>\n> The first upstream commit that contains the changes is used, so the\n> branch is deleted even if upstream later reverted or reworked them.\n> Nothing is lost, since that commit keeps them in the upstream history.\n> A branch whose changes only partly landed is kept.\n>\n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n\n…in the rebase case, I would expect something like git-log's\n--cherry-mark option (or really the algorithm behind it, git-cherry,\nand git-range-diff) to be useful for identifying rebased branches. But\nof course even rebase-merged branches can end up with minor\ndifferences (say, a commit was made upstream before that branch was\nrebased with an identical change; no conflict occurs, but the new\ncommit differs from the old by not having that change).\n\nIn the squash case, I suppose the best we can do is check that all our\nchanges were applied at some point between the merge-base and the tip.\nThere probably won't be any tree-same commits, though maybe a\n(premature?) optimization can return early if the trees match exactly.\n\nIt looked like you don't distinguish the 2 cases in the code, and I\nthink that's reasonable: we wouldn't know a priori whether to check\nfor a rebased series or a squashed commit, so we'd have to run both\nchecks, and the latter presumably subsumes the former.\n\nAnyway, I can see how this would all be fairly expensive---on one repo\nI work in, git-range-diff can be somewhat slow depending on how many\ncommits are in the range, I think. I don't know if it's worth trying\nto state that for folks, though? If we ever make improvements to\nperformance, we'd have to remember to remove the \"this may be slow\"\ntext.\n\n> After the release of 2.56, I saw people liking the --delete-merged\n>    feature, but asking for this. A lot of people, me included prefer\n>    squash-merge and it currently doesn't work with --delete-merged.\n\nBtw, I wonder if you can share where you saw this? 2.56 was released\nso recently I'm (pleasantly) surprised there's already feedback on\nthis!\n\n-- \nD. Ben Knoble\n"},{"id":"553601","messageId":"4c4fba69-e474-4ee3-8e34-73e76d42d3d5@app.fastmail.com","threadId":"66417","inReplyTo":"CALnO6CBq5Udc1rbk6efRj1q5pJNUDt-uvmSGDDen=CMH0dFOHQ@mail.gmail.com","subject":"Re: [PATCH] branch: let --delete-merged find squash merged branches","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2026-09-29T13:31:38Z","receivedAt":"2026-09-29T13:32:03Z","isPatch":true,"body":"On Tue, Sep 29, 2026, at 13:18, D. Ben Knoble wrote:\n> On Tue, Sep 29, 2026 at 4:17 AM Kristoffer Haugsbakk\n> <kristofferhaugsbakk@fastmail.com> wrote:\n>>\n>> On Tue, Sep 29, 2026, at 09:52, Harald Nordgren wrote:\n>> > [snip]\n>> >\n>> > Maybe I should include both examples in my text.\n>>\n>> My *guess* is that `git merge --squash` inspired the forge squashes.\n>> But the forges popularized it.\n>>\n>> The apparent `git merge --squash` approach of using `git log` for\n>> concatenating the commit messages isn’t that nice in my opinion. So I\n>> wonder how much it is used.\n>\n> The same behavior is present in GitHub's default squash merge message,\n> and almost no one I work with bothers to edit it.\n\nThe asterisk bullet points on GitHub are better than `git merge\n--squash`:\n\n    Squashed commit of the following:\n\n    [just `git log` of the commits in the range]\n\n> It's really sad to lose the opportunity to have good commit messages\n> when using squash-and-merge on a forge---not because we *cannot*, but\n> because the defaults do not *encourage* it (and we all know how\n> defaults affect user behavior!).\n\nIn my opinion squash merges cannot be implemented in a good way, in a\nway that leads to good commits. Fundamentally not. It’s the button to\nboth squash “oops” and the incremental, valuable commits, resulting in a\nblob where even a manually written commit message cannot document all\nthe changes properly. And the reasons why are laid out in your article,\nI think...\n\n>\n> Anyway, see https://benknoble.github.io/blog/2024/08/02/github-squash/\n> for a distillation of my thoughts from working around folks that\n> squash carelessly.\n>\n> So, anecdotally: it is used widely due to defaults. Blech.\n\nWhich is excellent. Thanks for writing it.\n"},{"id":"553603","messageId":"CAHwyqnUkz+7FH4QY-EB__dOc2rWNNwy9EL_b7R7cA1oPvequ=A@mail.gmail.com","threadId":"66417","inReplyTo":"CALnO6CBwWy3aafyDJPKFk5vuWy2EF1n1Oc=W7+RVAE3rxpXwiw@mail.gmail.com","subject":"Re: [PATCH] branch: let --delete-merged find squash merged branches","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-09-29T13:44:07Z","receivedAt":"2026-09-29T13:44:47Z","isPatch":true,"body":"> > After the release of 2.56, I saw people liking the --delete-merged\n> >    feature, but asking for this. A lot of people, me included prefer\n> >    squash-merge and it currently doesn't work with --delete-merged.\n>\n> Btw, I wonder if you can share where you saw this? 2.56 was released\n> so recently I'm (pleasantly) surprised there's already feedback on\n> this!\n\nReddit thread: https://www.reddit.com/r/git/comments/1wsnrl9/comment/pcncf5t\n\nThere was only one person asking to clean up squashed branches. But I\nalso started thinking about it the other day, when 2.56 drew closer,\nand I realized that friends that work in companies using squash merge\nwon't get any benefit from this.\n\nBlog posts that is drawing attention to this new feature (not\nnecessary feedbacking on it):\n\n- https://github.blog/open-source/git/highlights-from-git-2-56/\n- https://about.gitlab.com/blog/whats-new-in-git-2-56-0/\n- https://9to5linux.com/git-2-56-adds-new-options-for-cleaning-up-branches-and-resolving-conflicts\n- https://linuxiac.com/git-2-56-released-with-safer-conflict-resolution-and-performance-gains/\n\nBtw, I love squash merge!\n\n\nHarald\n"},{"id":"553604","messageId":"CAHwyqnXLAQgTen2nu6xX63ch7Z9V8kZ-c3zbZgw_xwEVYaz=oA@mail.gmail.com","threadId":"66417","inReplyTo":"CALnO6CBwWy3aafyDJPKFk5vuWy2EF1n1Oc=W7+RVAE3rxpXwiw@mail.gmail.com","subject":"Re: [PATCH] branch: let --delete-merged find squash merged branches","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-09-29T13:52:53Z","receivedAt":"2026-09-29T13:53:31Z","isPatch":true,"body":"> Anyway, I can see how this would all be fairly expensive---on one repo\n> I work in, git-range-diff can be somewhat slow depending on how many\n> commits are in the range, I think. I don't know if it's worth trying\n> to state that for folks, though? If we ever make improvements to\n> performance, we'd have to remember to remove the \"this may be slow\"\n> text.\n\nIt was much slower in my first iterations, so running this on my local\nGit repo now does not feel painfully slow. Although slower than\nwithout this feature.\n\nWe might hide it behind a feature flag?\n\n\nHarald\n"},{"id":"553607","messageId":"CALnO6CC4fS0LWe6ta7B-C3dQXSjfYhab-e=EHdneRRqfa9mAZw@mail.gmail.com","threadId":"66417","inReplyTo":"CAHwyqnXLAQgTen2nu6xX63ch7Z9V8kZ-c3zbZgw_xwEVYaz=oA@mail.gmail.com","subject":"Re: [PATCH] branch: let --delete-merged find squash merged branches","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-09-29T15:28:08Z","receivedAt":"2026-09-29T15:28:23Z","isPatch":true,"body":"On Tue, Sep 29, 2026 at 9:53 AM Harald Nordgren\n<haraldnordgren@gmail.com> wrote:\n>\n> > Anyway, I can see how this would all be fairly expensive---on one repo\n> > I work in, git-range-diff can be somewhat slow depending on how many\n> > commits are in the range, I think. I don't know if it's worth trying\n> > to state that for folks, though? If we ever make improvements to\n> > performance, we'd have to remember to remove the \"this may be slow\"\n> > text.\n>\n> It was much slower in my first iterations, so running this on my local\n> Git repo now does not feel painfully slow. Although slower than\n> without this feature.\n>\n> We might hide it behind a feature flag?\n\nI could imagine wanting a CSV-style value for the option, like\n\"--delete-merged=squashed,rebased\" vs. \"--delete-merged=merged\"\n(current default), or something. But I'd have to think about whether\nI'm suggesting that only to work around performance or to also support\nactual use cases. Mostly I think people just want to go \"gah, delete\nmerged branches\" and not think about it further… hm.\n\n-- \nD. Ben Knoble\n"},{"id":"553627","messageId":"CAHwyqnXA6kxktxNRPCbq0hTdH+G+3jSkCq5ghkS6QkJEgA1wkw@mail.gmail.com","threadId":"66417","inReplyTo":"CALnO6CC4fS0LWe6ta7B-C3dQXSjfYhab-e=EHdneRRqfa9mAZw@mail.gmail.com","subject":"Re: [PATCH] branch: let --delete-merged find squash merged branches","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-09-29T18:34:23Z","receivedAt":"2026-09-29T18:35:03Z","isPatch":true,"body":"> > We might hide it behind a feature flag?\n>\n> I could imagine wanting a CSV-style value for the option, like\n> \"--delete-merged=squashed,rebased\" vs. \"--delete-merged=merged\"\n> (current default), or something. But I'd have to think about whether\n> I'm suggesting that only to work around performance or to also support\n> actual use cases. Mostly I think people just want to go \"gah, delete\n> merged branches\" and not think about it further… hm.\n\nFor me, the only reason to hide this would be for performance.\n\nYes, I think most people (me included) would rather have the simplest\npossible command. I'd rather not have to specify the pattern so that\nthese are to the same:\n\n    git branch --delete-merged\n    git branch --delete-merged '*/*'\n\n\nHarald\n"},{"id":"554101","messageId":"39a28064-1698-4971-a80f-4a4c4dcdd8d9@gmail.com","threadId":"66417","inReplyTo":"CALnO6CBwWy3aafyDJPKFk5vuWy2EF1n1Oc=W7+RVAE3rxpXwiw@mail.gmail.com","subject":"Re: [PATCH] branch: let --delete-merged find squash merged branches","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-10-04T09:54:10Z","receivedAt":"2026-10-04T09:54:14Z","isPatch":true,"body":"On 29/09/2026 12:26, D. Ben Knoble wrote:\n> On Tue, Sep 29, 2026 at 3:33 AM Harald Nordgren via GitGitGadget\n> <gitgitgadget@gmail.com> wrote:\n> \n> …in the rebase case, I would expect something like git-log's\n> --cherry-mark option (or really the algorithm behind it, git-cherry,\n> and git-range-diff) \n\nThat's what I was expecting as well. It would be worth carefully \nstudying the implementation of git-cherry. \"git cherry A...B\" \nprecalculates the patch-ids from the side of the merge base that has the \nfewest commits and then walks the other side to compare them. While it \nis walking the other side I think it also looks at which paths were \nchanged to avoid calculating the patch-id for commits that cannot match. \nIt also batches fetches the blobs it needs in partial clones.\n\nAs far as I can see the implementation here makes a separate upstream \nrevision walk for each branch, and recalculates the upstream diffs each \ntime which seems less efficient than it could be.\n> to be useful for identifying rebased branches. But\n> of course even rebase-merged branches can end up with minor\n> differences (say, a commit was made upstream before that branch was\n> rebased with an identical change; no conflict occurs, but the new\n> commit differs from the old by not having that change).\n\nYes if a branch has been rebased before it is merged it may be altered \nsuch that we cannot detect it.\n> In the squash case, I suppose the best we can do is check that all our\n> changes were applied at some point between the merge-base and the tip.\n> There probably won't be any tree-same commits, though maybe a\n> (premature?) optimization can return early if the trees match exactly.\n\nIf we have\n\n(topic)  D - C - B - A\n                       \\\n  (main)    M - Q - P - O -\n             \\          /\n               - - S - -\n\nwhere M is a squashed merge of topic I think we have\n\n     M^2^{tree} == topic^{tree}\n     Merge-base(M^1, M^2) == Merge-base(topic, topic@{upstream})\n     $(git rev-list --count --right-only M^1...M^2) == 1\n\nIf you know your repository only has squash merges that were not rebased \nit would be a lot more efficient to just look at the trees and \nmerge-bases, especially in a blobless clone. Having an option to turn \noff the patch-id based detection would probably be useful in that case.\n\nI think detecting branches that have been squashed and/or rebased is a \nuseful improvement, but it needs careful implementation to be efficient \nenough that it is practical in large repositories and I'm unlikely to \nhave time to closely review it.\n\nThanks\n\nPhillip\n\n> It looked like you don't distinguish the 2 cases in the code, and I\n> think that's reasonable: we wouldn't know a priori whether to check\n> for a rebased series or a squashed commit, so we'd have to run both\n> checks, and the latter presumably subsumes the former.\n> \n> Anyway, I can see how this would all be fairly expensive---on one repo\n> I work in, git-range-diff can be somewhat slow depending on how many\n> commits are in the range, I think. I don't know if it's worth trying\n> to state that for folks, though? If we ever make improvements to\n> performance, we'd have to remember to remove the \"this may be slow\"\n> text.\n> \n>> After the release of 2.56, I saw people liking the --delete-merged\n>>     feature, but asking for this. A lot of people, me included prefer\n>>     squash-merge and it currently doesn't work with --delete-merged.\n> \n> Btw, I wonder if you can share where you saw this? 2.56 was released\n> so recently I'm (pleasantly) surprised there's already feedback on\n> this!\n> \n\n"},{"id":"554131","messageId":"CAHwyqnVoMnO_fYGJ0N29bQv=Lh5naZ0jc5uSpiS2urQMZVG5-Q@mail.gmail.com","threadId":"66417","inReplyTo":"39a28064-1698-4971-a80f-4a4c4dcdd8d9@gmail.com","subject":"Re: [PATCH] branch: let --delete-merged find squash merged branches","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-10-04T22:28:36Z","receivedAt":"2026-10-04T22:29:15Z","isPatch":true,"body":"> As far as I can see the implementation here makes a separate upstream\n> revision walk for each branch, and recalculates the upstream diffs each\n> time which seems less efficient than it could be.\n\nYes, that can be improved!\n\n> > In the squash case, I suppose the best we can do is check that all our\n> > changes were applied at some point between the merge-base and the tip.\n> > There probably won't be any tree-same commits, though maybe a\n> > (premature?) optimization can return early if the trees match exactly.\n...\n> If you know your repository only has squash merges that were not rebased\n> it would be a lot more efficient to just look at the trees and\n> merge-bases, especially in a blobless clone. Having an option to turn\n> off the patch-id based detection would probably be useful in that case.\n\nMaybe yes, but for users I imagine they want the interface to be as\nsimple as possible.\n\nI'm iterating on the code on my side (sharing logic between branches,\netc) and it became fast on my local Git repo. If there are no\nperformance concerns then would we still want the option to turn it\noff?\n\n\nHarald\n"}]}