diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index b0d66a6deb..66b1c87c55 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -25,6 +25,7 @@ git branch (-m|-M) [] git branch (-c|-C) [] git branch (-d|-D) [-r] ... git branch --edit-description [] +git branch --delete-merged ... DESCRIPTION ----------- @@ -201,6 +202,34 @@ This option is only applicable in non-verbose mode. Print the name of the current branch. In detached `HEAD` state, nothing is printed. +`--delete-merged ...`:: + Delete the local branches that `--forked` would list for the + given __ arguments, but only those whose tip is + reachable from their configured 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. Several + __ patterns may be given, e.g. `git branch + --delete-merged origin/main 'feature*'`. ++ +A branch is not deleted when: ++ +-- +* its upstream remote-tracking branch no longer exists, +* it is checked out in any worktree, or +* its push destination (`@{push}`) equals its upstream + (`@{upstream}`), so it cannot be distinguished from a + branch that just looks "fully merged" right after a pull. +-- ++ +A branch whose work has not yet been merged into its upstream is +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. ++ +A branch that another, surviving branch tracks as its upstream is +kept, so a branch is never deleted out from under one stacked on top +of it. If that kept branch in turn tracks a branch that is being +deleted, its now-stale upstream configuration is cleared. + `-v`:: `-vv`:: `--verbose`:: diff --git a/builtin/branch.c b/builtin/branch.c index 01c1f64c73..d12a2f57ea 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -21,6 +21,7 @@ #include "branch.h" #include "path.h" #include "string-list.h" +#include "strmap.h" #include "column.h" #include "utf8.h" #include "ref-filter.h" @@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = { N_("git branch [] (-c | -C) [] "), N_("git branch [] [-r | -a] [--points-at]"), N_("git branch [] [-r | -a] [--format]"), + N_("git branch [] --delete-merged ..."), NULL }; @@ -705,6 +707,139 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset return 0; } +struct spare_data { + struct strset *deletable; + struct strset *spared; +}; + +/* + * A surviving branch stacked on a deletion candidate would lose its + * upstream, so drop that candidate from the delete set and remember it + * in "spared" so its own upstream can be tidied up afterwards. + */ +static int spare_stacked_base(const struct reference *ref, void *cb_data) +{ + struct spare_data *data = cb_data; + struct branch *branch; + const char *upstream, *up_short; + + if (strset_contains(data->deletable, ref->name)) + return 0; + branch = branch_get(ref->name); + upstream = branch_get_upstream(branch, NULL); + if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) || + !strset_contains(data->deletable, up_short)) + return 0; + + strset_remove(data->deletable, up_short); + strset_add(data->spared, up_short); + return 0; +} + +/* + * Keep any branch that a surviving branch tracks as its upstream, so we + * never delete a branch out from under one stacked on top of it. Such a + * base is itself merged, so when its own upstream is also going away + * (no surviving branch tracks it), clear the base's now-stale upstream. + */ +static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable) +{ + struct strset spared = STRSET_INIT; + struct spare_data data = { .deletable = deletable, .spared = &spared }; + struct strbuf key = STRBUF_INIT; + struct hashmap_iter iter; + struct strmap_entry *entry; + + refs_for_each_branch_ref(refs, spare_stacked_base, &data); + + strset_for_each_entry(&spared, &iter, entry) { + struct branch *branch = branch_get(entry->key); + const char *upstream = branch_get_upstream(branch, NULL); + const char *up_short; + + if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) || + !strset_contains(deletable, up_short)) + continue; + + strbuf_reset(&key); + strbuf_addf(&key, "branch.%s.merge", branch->name); + repo_config_set_gently(the_repository, key.buf, NULL); + strbuf_reset(&key); + strbuf_addf(&key, "branch.%s.remote", branch->name); + repo_config_set_gently(the_repository, key.buf, NULL); + } + + strbuf_release(&key); + strset_clear(&spared); +} + +static int delete_merged_branches(int argc, const char **argv, + unsigned int flags) +{ + struct ref_store *refs = get_main_ref_store(the_repository); + struct ref_filter filter = REF_FILTER_INIT; + struct ref_array candidates = { 0 }; + struct strset deletable = STRSET_INIT; + struct strvec to_delete = STRVEC_INIT; + struct hashmap_iter iter; + struct strmap_entry *entry; + int i, ret = 0; + + if (!argc) + die(_("--delete-merged requires at least one ")); + + for (i = 0; i < argc; i++) + if (ref_filter_forked_add(&filter, argv[i]) < 0) + die(_("'%s' is not a valid branch or pattern"), argv[i]); + + filter.kind = FILTER_REFS_BRANCHES; + filter_refs(&candidates, &filter, filter.kind); + + for (i = 0; i < candidates.nr; i++) { + const char *full_name = candidates.items[i]->refname; + const char *short_name; + struct branch *branch; + const char *upstream, *push; + + if (!skip_prefix(full_name, "refs/heads/", &short_name)) + BUG("filter returned non-branch ref '%s'", full_name); + if (branch_checked_out(full_name)) + continue; + + branch = branch_get(short_name); + upstream = branch_get_upstream(branch, NULL); + if (!upstream || !refs_ref_exists(refs, upstream)) + continue; + push = branch_get_push(branch, NULL); + if (!push || !strcmp(push, upstream)) + continue; + if (check_branch_commit(short_name, short_name, + &candidates.items[i]->objectname, NULL, + FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) + continue; + + strset_add(&deletable, short_name); + } + + spare_stacked_bases(refs, &deletable); + + strset_for_each_entry(&deletable, &iter, entry) + strvec_push(&to_delete, entry->key); + + if (to_delete.nr) + ret = delete_branches(to_delete.nr, to_delete.v, + FILTER_REFS_BRANCHES, + DELETE_BRANCH_SKIP_UNMERGED | + DELETE_BRANCH_NO_HEAD_FALLBACK | + flags); + + strvec_clear(&to_delete); + strset_clear(&deletable); + ref_array_clear(&candidates); + ref_filter_clear(&filter); + return ret; +} + static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION") static int edit_branch_description(const char *branch_name) @@ -746,6 +881,7 @@ int cmd_branch(int argc, /* possible actions */ int delete = 0, rename = 0, copy = 0, list = 0, unset_upstream = 0, show_current = 0, edit_description = 0; + int delete_merged = 0; const char *new_upstream = NULL; int noncreate_actions = 0; /* possible options */ @@ -799,6 +935,8 @@ int cmd_branch(int argc, OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")), OPT_BOOL(0, "edit-description", &edit_description, N_("edit the description for the branch")), + OPT_BOOL(0, "delete-merged", &delete_merged, + N_("delete local branches whose upstream matches and are merged")), OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE), OPT_MERGED(&filter, N_("print only branches that are merged")), OPT_NO_MERGED(&filter, N_("print only branches that are not merged")), @@ -846,7 +984,8 @@ int cmd_branch(int argc, 0); if (!delete && !rename && !copy && !edit_description && !new_upstream && - !show_current && !unset_upstream && argc == 0) + !show_current && !unset_upstream && !delete_merged && + argc == 0) list = 1; if (filter.with_commit || filter.no_commit || @@ -856,7 +995,7 @@ int cmd_branch(int argc, noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream + !!show_current + !!list + !!edit_description + - !!unset_upstream; + !!unset_upstream + !!delete_merged; if (noncreate_actions > 1) usage_with_options(builtin_branch_usage, options); @@ -898,6 +1037,10 @@ int cmd_branch(int argc, (delete > 1 ? DELETE_BRANCH_FORCE : 0) | (quiet ? DELETE_BRANCH_QUIET : 0)); goto out; + } else if (delete_merged) { + ret = delete_merged_branches(argc, argv, + quiet ? DELETE_BRANCH_QUIET : 0); + goto out; } else if (show_current) { print_current_branch_name(); ret = 0; diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index 3104c555f6..047ba54778 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1839,4 +1839,189 @@ test_expect_success '--forked narrows a argument' ' test_cmp expect actual ' +test_expect_success '--delete-merged: setup' ' + git init -b main upstream && + ( + cd upstream && + test_commit base && + git checkout -b next && + test_commit next-work && + git checkout main + ) && + git init -b main other && + test_commit -C other other-base && + git init -b main fork +' + +setup_repo_for_delete_merged () { + rm -rf repo && + git clone upstream repo && + ( + cd repo && + git remote add fork ../fork && + git remote add other ../other && + git config remote.pushDefault fork && + git config push.default current && + git fetch other + ) +} + +merged_branch () { + ( + cd repo && + git checkout -b "$1" "$2" && + git commit --allow-empty -m "$1 work" && + git push origin "$1:next" && + git fetch origin && + git branch --set-upstream-to="$2" "$1" + ) +} + +test_expect_success '--delete-merged deletes merged branches and spares the rest' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch merged origin/next && + ( + cd repo && + git checkout -b unmerged origin/next && + git commit --allow-empty -m "unmerged work" && + git branch --set-upstream-to=origin/next unmerged && + git checkout -b tracks-other other/main && + git branch --set-upstream-to=other/main tracks-other && + git checkout --detach + ) && + sha=$(git -C repo rev-parse --short merged) && + + git -C repo branch --delete-merged origin/next >actual 2>&1 && + + echo "Deleted branch merged (was $sha)." >expect && + test_cmp expect actual && + git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + main + tracks-other + unmerged + EOF + test_cmp expect actual +' + +test_expect_success '--delete-merged deletes merged branches and spares protected ones' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch on-next origin/next && + merged_branch checked-out origin/next && + merged_branch upstream-gone origin/next && + ( + cd repo && + git checkout -b mainline main && + git checkout -b on-local mainline && + git branch --set-upstream-to=mainline on-local && + git update-ref refs/remotes/origin/topic refs/remotes/origin/next && + git branch --set-upstream-to=origin/topic upstream-gone && + git update-ref -d refs/remotes/origin/topic && + git branch --set-upstream-to=origin/main main && + git config branch.main.pushRemote origin && + git checkout -b tracks-other other/main && + git branch --set-upstream-to=other/main tracks-other && + git checkout checked-out + ) && + + git -C repo branch --delete-merged origin/next mainline && + + git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + checked-out + main + mainline + tracks-other + upstream-gone + EOF + test_cmp expect actual +' + +test_expect_success '--delete-merged requires at least one ' ' + test_must_fail git -C forked branch --delete-merged 2>err && + test_grep "requires at least one " err +' + +test_expect_success '--delete-merged keeps a branch that is an upstream' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch feature origin/next && + ( + cd repo && + git checkout -b topic feature && + git commit --allow-empty -m "topic work" && + git branch --set-upstream-to=feature topic && + git checkout --detach + ) && + + git -C repo branch --dry-run --delete-merged origin/next >out && + test_grep ! "feature" out && + + git -C repo branch --delete-merged origin/next 2>err && + + test_must_be_empty err && + git -C repo rev-parse --verify refs/heads/feature && + git -C repo rev-parse --verify refs/heads/topic && + echo origin/next >expect && + git -C repo rev-parse --abbrev-ref feature@{upstream} >actual && + test_cmp expect actual && + echo feature >expect && + git -C repo rev-parse --abbrev-ref topic@{upstream} >actual && + test_cmp expect actual +' + +test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + ( + cd repo && + git branch b3 origin/next && + git branch --set-upstream-to=origin/next b3 && + git branch b2 origin/next && + git branch --set-upstream-to=b3 b2 && + git checkout -b b1 b2 && + git commit --allow-empty -m "b1 work" && + git branch --set-upstream-to=b2 b1 && + git checkout --detach + ) && + + git -C repo branch --delete-merged origin/next && + + git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + b1 + b2 + b3 + main + EOF + test_cmp expect actual +' + +test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + ( + cd repo && + git branch lower origin/next && + git branch --set-upstream-to=origin/next lower && + git branch mid origin/next && + git branch --set-upstream-to=lower mid && + git checkout -b tip mid && + git commit --allow-empty -m "tip work" && + git branch --set-upstream-to=mid tip && + git checkout --detach + ) && + + git -C repo branch --delete-merged origin/next lower && + + test_must_fail git -C repo rev-parse --verify refs/heads/lower && + git -C repo rev-parse --verify refs/heads/mid && + test_must_fail git -C repo rev-parse mid@{upstream} && + echo mid >expect && + git -C repo rev-parse --abbrev-ref tip@{upstream} >actual && + test_cmp expect actual +' + test_done