#include "branch.h"
#include "path.h"
#include "string-list.h"
+#include "strmap.h"
#include "column.h"
#include "utf8.h"
#include "ref-filter.h"
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] (--delete-merged <pattern>)... "
+ "[<branch-pattern>...]"),
NULL
};
return 0;
}
+struct stacked_branch_data {
+ struct strset *deletable_branch_names;
+ struct strset *protected_branch_names;
+};
+
+static int collect_stacked_branch_base(const struct reference *ref,
+ void *cb_data)
+{
+ struct stacked_branch_data *data = cb_data;
+ const char *branch_name;
+ struct branch *branch;
+ const char *upstream_refname;
+ const char *upstream_branch_name;
+
+ if (!skip_prefix(ref->name, "refs/heads/", &branch_name))
+ BUG("expected local branch ref, got '%s'", ref->name);
+ if (strset_contains(data->deletable_branch_names, branch_name))
+ return 0;
+
+ branch = branch_get(branch_name);
+ upstream_refname = branch_get_upstream(branch, NULL);
+ if (!upstream_refname ||
+ !skip_prefix(upstream_refname, "refs/heads/",
+ &upstream_branch_name) ||
+ !strset_contains(data->deletable_branch_names,
+ upstream_branch_name))
+ return 0;
+
+ strset_add(data->protected_branch_names, upstream_branch_name);
+ return 0;
+}
+
+static void protect_stacked_branch_bases(struct ref_store *refs,
+ struct strset *deletable_branch_names,
+ struct strset *protected_branch_names)
+{
+ struct stacked_branch_data data = {
+ .deletable_branch_names = deletable_branch_names,
+ .protected_branch_names = protected_branch_names,
+ };
+ struct refs_for_each_ref_options opts = {
+ .prefix = "refs/heads/",
+ };
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+
+ refs_for_each_ref_ext(refs, collect_stacked_branch_base, &data, &opts);
+
+ strset_for_each_entry(protected_branch_names, &iter, entry)
+ strset_remove(deletable_branch_names, entry->key);
+}
+
+static void clear_deleted_upstreams(struct strset *protected_branch_names,
+ struct strset *deletable_branch_names)
+{
+ struct strbuf key = STRBUF_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+
+ strset_for_each_entry(protected_branch_names, &iter, entry) {
+ struct branch *branch = branch_get(entry->key);
+ const char *upstream_refname = branch_get_upstream(branch, NULL);
+ const char *upstream_branch_name;
+
+ if (!upstream_refname ||
+ !skip_prefix(upstream_refname, "refs/heads/",
+ &upstream_branch_name) ||
+ !strset_contains(deletable_branch_names,
+ upstream_branch_name))
+ continue;
+
+ 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_reset(&key);
+ }
+
+ strbuf_release(&key);
+}
+
+static int branch_pushes_to_upstream(struct branch *branch,
+ const char *upstream)
+{
+ struct remote *remote = remote_get(remote_for_branch(branch, NULL));
+ char *push_refname = NULL;
+ char *tracking = NULL;
+ int ret = 0;
+
+ if (!remote)
+ return 0;
+ if (remote->push.nr)
+ push_refname = apply_refspecs(&remote->push, branch->refname);
+ else
+ push_refname = xstrdup(branch->refname);
+ if (push_refname)
+ tracking = apply_refspecs(&remote->fetch, push_refname);
+ if (tracking && !strcmp(tracking, upstream))
+ ret = 1;
+
+ free(push_refname);
+ free(tracking);
+ return ret;
+}
+
+static int delete_merged_branches(const struct strvec *upstreams,
+ 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_branch_names = STRSET_INIT;
+ struct strset protected_branch_names = STRSET_INIT;
+ struct strvec branches_to_delete = STRVEC_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+ int ret = 0;
+
+ for (size_t i = 0; i < upstreams->nr; i++)
+ if (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)
+ die(_("'%s' is not a valid branch or pattern"),
+ upstreams->v[i]);
+
+ filter.kind = FILTER_REFS_BRANCHES;
+ filter.name_patterns = argv;
+ filter_refs(&candidates, &filter, filter.kind);
+
+ for (int i = 0; i < candidates.nr; i++) {
+ const char *branch_refname = candidates.items[i]->refname;
+ const char *branch_name;
+ struct branch *branch;
+ const char *upstream_refname;
+
+ if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
+ BUG("filter returned non-branch ref '%s'", branch_refname);
+ if (branch_checked_out(branch_refname))
+ continue;
+
+ branch = branch_get(branch_name);
+ upstream_refname = branch_get_upstream(branch, NULL);
+ if (!upstream_refname || !refs_ref_exists(refs, upstream_refname))
+ continue;
+ if (branch_pushes_to_upstream(branch, upstream_refname))
+ continue;
+ if (check_branch_commit(branch_name, branch_name,
+ &candidates.items[i]->objectname, NULL,
+ FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
+ continue;
+
+ strset_add(&deletable_branch_names, branch_name);
+ }
+
+ protect_stacked_branch_bases(refs, &deletable_branch_names,
+ &protected_branch_names);
+
+ strset_for_each_entry(&deletable_branch_names, &iter, entry)
+ strvec_push(&branches_to_delete, entry->key);
+
+ if (branches_to_delete.nr)
+ ret = delete_branches(branches_to_delete.nr, branches_to_delete.v,
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_SKIP_UNMERGED |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ flags);
+
+ if (!ret)
+ clear_deleted_upstreams(&protected_branch_names,
+ &deletable_branch_names);
+
+ strvec_clear(&branches_to_delete);
+ strset_clear(&protected_branch_names);
+ strset_clear(&deletable_branch_names);
+ 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)
/* possible actions */
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
+ struct strvec delete_merged = STRVEC_INIT;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
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_CALLBACK_F(0, "delete-merged", &delete_merged, N_("pattern"),
+ N_("delete merged branches whose upstream matches <pattern> (repeatable)"),
+ PARSE_OPT_NONEG, parse_opt_strvec),
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")),
0);
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
+ !show_current && !unset_upstream && !delete_merged.nr &&
+ argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
+ !!unset_upstream + !!delete_merged.nr;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
(delete > 1 ? DELETE_BRANCH_FORCE : 0) |
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
+ } else if (delete_merged.nr) {
+ ret = delete_merged_branches(&delete_merged, argv,
+ quiet ? DELETE_BRANCH_QUIET : 0);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
ret = 0;
out:
+ strvec_clear(&delete_merged);
string_list_clear(&sorting_options, 0);
return ret;
}
test_grep "requires a value" err
'
+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 push.default current &&
+ git fetch other
+ )
+}
+
+create_merged_branch () {
+ (
+ cd repo &&
+ git checkout -b "$1" --track origin/next &&
+ git commit --allow-empty -m "$1 work" &&
+ git push origin "$1:next"
+ )
+}
+
+check_branches () {
+ git for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
+ cat >expect &&
+ test_cmp expect actual
+}
+
+test_expect_success '--delete-merged keeps cloned main without explicit push configuration' '
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
+ test_cmp_config origin branch.main.remote &&
+ test_cmp_config refs/heads/main branch.main.merge &&
+ git checkout --detach &&
+
+ git branch --delete-merged */* &&
+
+ check_branches <<-\EOF
+ main
+ EOF
+ )
+'
+
+test_expect_success '--delete-merged deletes only selected merged branches' '
+ setup_repo_for_delete_merged &&
+ create_merged_branch also-merged &&
+ create_merged_branch merged &&
+ (
+ cd repo &&
+ git checkout -b unmerged --track origin/next &&
+ git commit --allow-empty -m "unmerged work" &&
+ git checkout -b tracks-other --track other/main &&
+ sha=$(git rev-parse --short merged) &&
+
+ git branch --delete-merged origin/next merged >actual 2>&1 &&
+ echo "Deleted branch merged (was $sha)." >expect &&
+ test_cmp expect actual &&
+
+ check_branches <<-\EOF
+ also-merged
+ main
+ tracks-other
+ unmerged
+ EOF
+ )
+'
+
+test_expect_success '--delete-merged keeps main despite a different default push remote' '
+ setup_repo_for_delete_merged &&
+ create_merged_branch on-next &&
+ create_merged_branch checked-out &&
+ create_merged_branch upstream-gone &&
+ (
+ cd repo &&
+ git config remote.pushDefault fork &&
+ git checkout -b local-to-delete --track main &&
+ git config branch.upstream-gone.merge refs/heads/topic &&
+ git checkout -b tracks-other --track other/main &&
+ git checkout checked-out &&
+
+ git branch --delete-merged origin/* --delete-merged main &&
+
+ check_branches <<-\EOF
+ checked-out
+ main
+ tracks-other
+ upstream-gone
+ EOF
+ )
+'
+
+test_expect_success '--delete-merged maps push refspecs to upstreams' '
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
+ git checkout -b topic &&
+ git config remote.origin.push "refs/heads/topic:refs/heads/published" &&
+ git push origin &&
+ git branch --set-upstream-to=origin/published topic &&
+ git checkout -b other-topic --track origin/published &&
+ git checkout --detach &&
+
+ git branch --delete-merged origin/published &&
+
+ check_branches <<-\EOF
+ main
+ topic
+ EOF
+ )
+'
+
+test_expect_success '--delete-merged keeps the upstream of a surviving branch' '
+ setup_repo_for_delete_merged &&
+ create_merged_branch feature &&
+ (
+ cd repo &&
+ git checkout -b topic --track feature &&
+ git commit --allow-empty -m "topic work" &&
+
+ git branch --delete-merged origin/next 2>err &&
+
+ test_must_be_empty err &&
+ check_branches <<-\EOF &&
+ feature
+ main
+ topic
+ EOF
+
+ git config --local --get-regexp "branch\\.(feature|topic)\\.(merge|remote)" >actual &&
+ cat >expect <<-\EOF &&
+ branch.feature.remote origin
+ branch.feature.merge refs/heads/next
+ branch.topic.remote .
+ branch.topic.merge refs/heads/feature
+ EOF
+ test_cmp expect actual
+ )
+'
+
+test_expect_success '--delete-merged clears the deleted upstream of a protected branch' '
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
+ git branch --track lower origin/next &&
+ git branch --track mid lower &&
+ git checkout -b tip --track mid &&
+ git commit --allow-empty -m "tip work" &&
+ sha=$(git rev-parse --short lower) &&
+
+ git branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&
+ echo "Deleted branch lower (was $sha)." >expect &&
+ test_cmp expect actual &&
+
+ check_branches <<-\EOF &&
+ main
+ mid
+ tip
+ EOF
+
+ git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
+ cat >expect <<-\EOF &&
+ branch.tip.remote .
+ branch.tip.merge refs/heads/mid
+ EOF
+ test_cmp expect actual
+ )
+'
+
+test_expect_success '--delete-merged result is independent of stacked branch names' '
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
+ git branch --track c-lower origin/next &&
+ git branch --track b-mid c-lower &&
+ git checkout -b a-tip --track b-mid &&
+ git commit --allow-empty -m "tip work" &&
+
+ git branch --delete-merged origin/next --delete-merged "c-*" &&
+
+ check_branches <<-\EOF &&
+ a-tip
+ b-mid
+ main
+ EOF
+
+ git branch --delete-merged origin/next --delete-merged "c-*" >actual 2>&1 &&
+ test_must_be_empty actual &&
+
+ check_branches <<-\EOF
+ a-tip
+ b-mid
+ main
+ EOF
+ )
+'
+
+test_expect_success '--delete-merged requires a value' '
+ test_must_fail git -C forked branch --delete-merged 2>err &&
+ test_grep "requires a value" err
+'
test_done