From: Harald Nordgren via GitGitGadget Date: Sun, 01 Nov 2026 08:22:07 GMT Subject: [PATCH v6 0/4] branch: let --delete-merged find squash merged branches Message-ID: In-Reply-To: Branches merged on GitHub with "Squash and merge" or "Rebase and merge" are never deleted by git branch --delete-merged, because their tips are not reachable from the upstream. This treats such a branch as merged when some upstream commit contains all of its changes, and names that commit in the output: Deleted branch topic (was 1a2b3c4, landed as 9f8e7d6). Changes in v6: * Read the index before the file merge, so .gitattributes that are only in the index are honored too. * Renamed the diff_options variables from opt to diffopt. Changes in v5: * git branch -h lists --delete-integrated in the usage lines * Giving both --delete-merged and --delete-integrated dies with a short "cannot be used together" message instead of the full usage text Changes in v4: * --delete-merged works as it does on master again: it only deletes branches whose tip is reachable from their upstream * New --delete-integrated also deletes branches that were rebase or squash merged, including those squashed after upstream changed the same files. It replaces --squashed * New prep commit lets delete_branches() take a map of where each branch landed, so "landed as" is printed there * Added Junio's test that --delete-merged keeps a branch when an unrelated upstream commit adds an identical file * --dry-run works with either option, and the two options can't be combined Changes in v3: * Split into multiple commits, one for rebase merges and one for squash merges. * By default a branch counts as merged when an upstream commit has its exact version of every file it changed. This never reads file contents, so it stays cheap in a partial clone. It finds rebase merges, and squash merges where upstream left those files alone. * Replace --no-squashed with an opt-in --squashed, which also finds branches merged after upstream changed the same files by merging those files. * Add a test for a rebase merge, and check that a squash onto changed files is kept without --squashed and deleted with it. Changes in v2: * Diff each upstream commit once and share the result between all branches, faster than v1. * Fix a branch merged with a merge commit being reported as "landed as" the merge when another branch was searched first, and add a test for it. Show the topic commit itself in "landed as" for work that arrived through a merge. * Use git merge --squash as the example in the commit message and mention GitHub's squash and rebase buttons. * Add --no-squashed to skip the squash search. Harald Nordgren (4): branch: move the deleteMerged opt-out check into a helper branch: let delete_branches() name where a branch landed branch: add --delete-integrated to delete rebase and squash merged branches branch: let --delete-integrated find branches merged onto changed files Documentation/config/branch.adoc | 8 +- Documentation/git-branch.adoc | 22 +- builtin/branch.c | 361 +++++++++++++++++++++++++++++-- t/t3200-branch.sh | 218 +++++++++++++++++++ 4 files changed, 579 insertions(+), 30 deletions(-) base-commit: 50fe5cc63f9cd6e89b87a2c9ccf96a444f4cfdb7 Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2425%2FHaraldNordgren%2Fbranch-delete-squashed-v6 Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2425/HaraldNordgren/branch-delete-squashed-v6 Pull-Request: https://github.com/git/git/pull/2425 Range-diff vs v5: 1: 090aec5725 = 1: e728b4ed0d branch: move the deleteMerged opt-out check into a helper 2: 24180bf4a2 = 2: 9858c8c81e branch: let delete_branches() name where a branch landed 3: bf30f57ceb ! 3: 92bd9afda4 branch: add --delete-integrated to delete rebase and squash merged branches @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch, +static void collect_branch_changes(struct landing_search *search, + struct commit *base) +{ -+ struct diff_options opt; ++ struct diff_options diffopt; + -+ repo_diff_setup(the_repository, &opt); -+ opt.flags.recursive = 1; -+ opt.output_format = DIFF_FORMAT_NO_OUTPUT; -+ diff_setup_done(&opt); ++ repo_diff_setup(the_repository, &diffopt); ++ diffopt.flags.recursive = 1; ++ diffopt.output_format = DIFF_FORMAT_NO_OUTPUT; ++ diff_setup_done(&diffopt); + diff_tree_oid(get_commit_tree_oid(base), -+ get_commit_tree_oid(search->rev), "", &opt); ++ get_commit_tree_oid(search->rev), "", &diffopt); + for (int i = 0; i < diff_queued_diff.nr; i++) { + struct diff_filepair *p = diff_queued_diff.queue[i]; + struct branch_change *change; @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch, + &p->two->oid : null_oid(the_hash_algo)); + change->branch_mode = p->two->mode; + } -+ diff_flush(&opt); ++ diff_flush(&diffopt); +} + +static int change_landed(const struct branch_change *change, @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch, +} + +static struct string_list *changed_paths_of(struct changed_paths *cache, -+ struct diff_options *opt, ++ struct diff_options *diffopt, + struct commit *commit) +{ + struct string_list **paths = changed_paths_at(cache, commit); @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch, + die(_("could not parse commit %s"), + oid_to_hex(&parent->object.oid)); + diff_tree_oid(parent ? get_commit_tree_oid(parent) : NULL, -+ get_commit_tree_oid(commit), "", opt); ++ get_commit_tree_oid(commit), "", diffopt); + for (int i = 0; i < diff_queued_diff.nr; i++) { + struct diff_filepair *p = diff_queued_diff.queue[i]; + + string_list_append(*paths, p->two->path); + } -+ diff_flush(opt); ++ diff_flush(diffopt); + string_list_sort(*paths); + return *paths; +} @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch, + +static struct commit *find_landed_commit(struct landing_search *search, + struct changed_paths *cache, -+ struct diff_options *opt) ++ struct diff_options *diffopt) +{ + struct commit *commit, *landed = NULL; + struct strvec args = STRVEC_INIT; @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch, + if (prepare_revision_walk(&revs)) + die(_("revision walk setup failed")); + while (!landed && (commit = get_revision(&revs))) -+ if (touches_changes(changed_paths_of(cache, opt, commit), ++ if (touches_changes(changed_paths_of(cache, diffopt, commit), + search) && + changes_landed(search, commit)) + landed = commit; @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch, + struct strmap *landed_commits) +{ + struct changed_paths cache; -+ struct diff_options opt; ++ struct diff_options diffopt; + + for (size_t i = 0; i < searches_nr; i++) { + struct landing_search *search = &searches[i]; @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch, + commit_list_free(merge_bases); + } + -+ repo_diff_setup(the_repository, &opt); -+ opt.flags.recursive = 1; -+ opt.output_format = DIFF_FORMAT_NO_OUTPUT; -+ opt.no_free = 1; -+ diff_setup_done(&opt); ++ repo_diff_setup(the_repository, &diffopt); ++ diffopt.flags.recursive = 1; ++ diffopt.output_format = DIFF_FORMAT_NO_OUTPUT; ++ diffopt.no_free = 1; ++ diff_setup_done(&diffopt); + init_changed_paths(&cache); + + for (size_t i = 0; i < searches_nr; i++) { @@ builtin/branch.c: static int branch_pushes_to_upstream(struct branch *branch, + + if (!searches[i].changes_nr) + continue; -+ landed = find_landed_commit(&searches[i], &cache, &opt); ++ landed = find_landed_commit(&searches[i], &cache, &diffopt); + if (landed) + strmap_put(landed_commits, searches[i].branch_name, + landed); + } + + deep_clear_changed_paths(&cache, free_changed_paths); -+ opt.no_free = 0; -+ diff_free(&opt); ++ diffopt.no_free = 0; ++ diff_free(&diffopt); +} + static bool branch_opted_out(struct strbuf *key, const char *branch_name, 4: 9443055242 ! 4: 006a57d7d1 branch: let --delete-integrated find branches merged onto changed files @@ builtin/branch.c: static void collect_branch_changes(struct landing_search *sear &p->two->oid : null_oid(the_hash_algo)); change->branch_mode = p->two->mode; @@ builtin/branch.c: static void collect_branch_changes(struct landing_search *search, - diff_flush(&opt); + diff_flush(&diffopt); } -static int change_landed(const struct branch_change *change, @@ builtin/branch.c: static void collect_branch_changes(struct landing_search *sear + mmfile_t result = { 0 }; + int ret; + ++ repo_read_index(the_repository); + read_mmblob(&base, the_repository->objects, &change->base_oid); + read_mmblob(&upstream, the_repository->objects, &change->upstream_oid); + read_mmblob(&branch, the_repository->objects, &change->branch_oid); -- gitgitgadget