diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc index a4db9fa5c8..6c1b5bb9cd 100644 --- a/Documentation/config/branch.adoc +++ b/Documentation/config/branch.adoc @@ -102,3 +102,10 @@ for details). `git branch --edit-description`. Branch description is automatically added to the `format-patch` cover letter or `request-pull` summary. + +`branch..pruneMerged`:: + If set to `false`, branch __ is exempt from + `git branch --prune-merged`. Useful for a topic branch you + intend to develop further after an initial round has been + merged upstream. Defaults to true. Explicit deletion via + `git branch -d` is unaffected. diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index c0afddc424..c579df4fe0 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -14,6 +14,7 @@ git branch [--color[=] | --no-color] [--show-current] [--merged []] [--no-merged []] [--contains []] [--no-contains []] [--points-at ] [--format=] + [(--forked )...] [(-r|--remotes) | (-a|--all)] [--list] [...] git branch [--track[=(direct|inherit)] | --no-track] [-f] @@ -24,6 +25,7 @@ git branch (-m|-M) [] git branch (-c|-C) [] git branch (-d|-D) [-r] ... git branch --edit-description [] +git branch [--dry-run] (--prune-merged )... DESCRIPTION ----------- @@ -199,6 +201,41 @@ This option is only applicable in non-verbose mode. Print the name of the current branch. In detached `HEAD` state, nothing is printed. +`--forked `:: + List only branches whose configured upstream matches + __. The argument can be a ref (e.g. `origin/main`, + `master`) or a shell-style glob (e.g. `'origin/*'`). The + option can be repeated to widen the filter. + +`--prune-merged `:: + Delete the local branches that `--forked` would list for the + same __, 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. May be given more than once to + union the matches; positional arguments are not accepted. ++ +Reachability is checked against whatever the upstream refs say +locally; nothing is fetched. Run `git fetch` first if you want +the upstream refs refreshed. ++ +A branch is left alone if any of the following holds: +its upstream no longer resolves locally; it is checked out in any +worktree; its push destination (`@{push}`) equals its +upstream (`@{upstream}`), so it cannot be distinguished +from a freshly pulled trunk that just looks "fully merged"; or +`branch..pruneMerged` is set to `false`. ++ +Branches refused by the "fully merged" safety check are listed as +warnings and skipped; pass them to `git branch -D` explicitly if +you want them gone. + +`--dry-run`:: + With `--prune-merged`, print which branches would be + deleted and exit without touching any ref. Useful for + sanity-checking a wide pattern like `'origin/*'` before + committing to the deletion. + `-v`:: `-vv`:: `--verbose`:: diff --git a/builtin/branch.c b/builtin/branch.c index 1572a4f9ef..1811511b9e 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -28,9 +28,10 @@ #include "help.h" #include "advice.h" #include "commit-reach.h" +#include "wildmatch.h" static const char * const builtin_branch_usage[] = { - N_("git branch [] [-r | -a] [--merged] [--no-merged]"), + N_("git branch [] [-r | -a] [--merged] [--no-merged] [(--forked )...]"), N_("git branch [] [-f] [--recurse-submodules] []"), N_("git branch [] [-l] [...]"), N_("git branch [] [-r] (-d | -D) ..."), @@ -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 [] (--prune-merged )..."), NULL }; @@ -168,10 +170,13 @@ static int branch_merged(int kind, const char *name, * upstream, if any, otherwise with HEAD", we should just * return the result of the repo_in_merge_bases() above without * any of the following code, but during the transition period, - * a gentle reminder is in order. + * a gentle reminder is in order. Callers that opt out of the + * HEAD fallback by passing head_rev=NULL are not interested in + * the reminder either: they have already established that the + * branch has an upstream, so HEAD is irrelevant to the decision. */ - if (head_rev != reference_rev) { - int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0; + if (head_rev && head_rev != reference_rev) { + int expect = repo_in_merge_bases(the_repository, rev, head_rev); if (expect < 0) exit(128); if (expect == merged) @@ -191,7 +196,7 @@ static int branch_merged(int kind, const char *name, static int check_branch_commit(const char *branchname, const char *refname, const struct object_id *oid, struct commit *head_rev, - int kinds, int force) + int kinds, int force, int warn_only) { struct commit *rev = lookup_commit_reference(the_repository, oid); if (!force && !rev) { @@ -199,10 +204,16 @@ static int check_branch_commit(const char *branchname, const char *refname, return -1; } if (!force && !branch_merged(kinds, branchname, rev, head_rev)) { - error(_("the branch '%s' is not fully merged"), branchname); - advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH, - _("If you are sure you want to delete it, " - "run 'git branch -D %s'"), branchname); + if (warn_only) { + warning(_("the branch '%s' is not fully merged"), + branchname); + } else { + error(_("the branch '%s' is not fully merged"), + branchname); + advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH, + _("If you are sure you want to delete it, " + "run 'git branch -D %s'"), branchname); + } return -1; } return 0; @@ -218,7 +229,8 @@ static void delete_branch_config(const char *branchname) } static int delete_branches(int argc, const char **argv, int force, int kinds, - int quiet) + int quiet, int warn_only, int no_head_fallback, + int dry_run) { struct commit *head_rev = NULL; struct object_id oid; @@ -252,7 +264,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, } branch_name_pos = strcspn(fmt, "%"); - if (!force) + if (!force && !no_head_fallback) head_rev = lookup_commit_reference(the_repository, &head_oid); for (i = 0; i < argc; i++, strbuf_reset(&bname)) { @@ -308,8 +320,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) && check_branch_commit(bname.buf, name, &oid, head_rev, kinds, - force)) { - ret = 1; + force, warn_only)) { + if (!warn_only) + ret = 1; goto next; } @@ -322,13 +335,20 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, free(target); } - if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF)) + if (!dry_run && + refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF)) ret = 1; for_each_string_list_item(item, &refs_to_delete) { char *describe_ref = item->util; char *name = item->string; - if (!refs_ref_exists(get_main_ref_store(the_repository), name)) { + if (dry_run) { + if (!quiet) + 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 (!quiet) printf(remote_branch @@ -442,8 +462,12 @@ static char *build_format(struct ref_filter *filter, int maxwidth, const char *r return strbuf_detach(&fmt, NULL); } +static void filter_array_by_forked(struct ref_array *array, + const struct string_list *upstreams); + static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sorting, - struct ref_format *format, struct string_list *output) + struct ref_format *format, struct string_list *output, + const struct string_list *forked_upstreams) { int i; struct ref_array array; @@ -463,6 +487,9 @@ static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sortin filter_refs(&array, filter, filter->kind); + if (forked_upstreams->nr) + filter_array_by_forked(&array, forked_upstreams); + if (filter->verbose) maxwidth = calc_maxwidth(&array, strlen(remote_prefix)); @@ -673,6 +700,230 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int free_worktrees(worktrees); } +struct upstream_pattern { + char *name; + int is_wildcard; +}; + +static void upstream_pattern_list_clear(struct upstream_pattern *items, + size_t nr) +{ + size_t i; + for (i = 0; i < nr; i++) + free(items[i].name); + free(items); +} + +static const char *short_upstream_name(const char *full_ref) +{ + const char *short_name = full_ref; + (void)(skip_prefix(short_name, "refs/heads/", &short_name) || + skip_prefix(short_name, "refs/remotes/", &short_name)); + return short_name; +} + +static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out) +{ + struct object_id oid; + char *full_ref = NULL; + + if (has_glob_specials(arg)) { + out->name = xstrdup(arg); + out->is_wildcard = 1; + return 0; + } + + if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid, + &full_ref, 0) == 1 && + (starts_with(full_ref, "refs/heads/") || + starts_with(full_ref, "refs/remotes/"))) { + out->name = xstrdup(short_upstream_name(full_ref)); + out->is_wildcard = 0; + free(full_ref); + return 0; + } + free(full_ref); + return -1; +} + +static void parse_forked_args(const struct string_list *args, + struct upstream_pattern **patterns_out, + size_t *nr_out) +{ + struct upstream_pattern *patterns; + size_t i; + + ALLOC_ARRAY(patterns, args->nr); + for (i = 0; i < args->nr; i++) { + const char *arg = args->items[i].string; + if (parse_one_forked_arg(arg, &patterns[i]) < 0) { + upstream_pattern_list_clear(patterns, i); + die(_("'%s' is not a valid branch or pattern"), arg); + } + } + *patterns_out = patterns; + *nr_out = args->nr; +} + +static int upstream_matches(const char *short_upstream, + const struct upstream_pattern *patterns, + size_t nr) +{ + size_t i; + + for (i = 0; i < nr; i++) { + const struct upstream_pattern *p = &patterns[i]; + if (p->is_wildcard) { + if (!wildmatch(p->name, short_upstream, WM_PATHNAME)) + return 1; + } else if (!strcmp(p->name, short_upstream)) { + return 1; + } + } + return 0; +} + +static int branch_upstream_matches(const char *short_branch_name, + const struct upstream_pattern *patterns, + size_t nr_patterns) +{ + struct branch *branch = branch_get(short_branch_name); + const char *upstream; + + if (!branch) + return 0; + upstream = branch_get_upstream(branch, NULL); + if (!upstream) + return 0; + return upstream_matches(short_upstream_name(upstream), + patterns, nr_patterns); +} + +static void filter_array_by_forked(struct ref_array *array, + const struct string_list *upstreams) +{ + struct upstream_pattern *patterns = NULL; + size_t nr_patterns = 0; + int i, kept = 0; + + parse_forked_args(upstreams, &patterns, &nr_patterns); + + for (i = 0; i < array->nr; i++) { + struct ref_array_item *item = array->items[i]; + const char *short_name; + if (skip_prefix(item->refname, "refs/heads/", &short_name) && + branch_upstream_matches(short_name, patterns, nr_patterns)) + array->items[kept++] = item; + else + free_ref_array_item(item); + } + array->nr = kept; + + upstream_pattern_list_clear(patterns, nr_patterns); +} + +struct forked_cb { + const struct upstream_pattern *patterns; + size_t nr_patterns; + struct string_list *out; +}; + +static int collect_forked_branch(const struct reference *ref, void *cb_data) +{ + struct forked_cb *cb = cb_data; + + if (ref->flags & REF_ISSYMREF) + return 0; + if (branch_upstream_matches(ref->name, cb->patterns, cb->nr_patterns)) + string_list_append(cb->out, ref->name); + return 0; +} + +static void collect_forked_set(const struct string_list *upstreams, + struct string_list *out) +{ + struct upstream_pattern *patterns = NULL; + size_t nr_patterns = 0; + struct forked_cb cb; + + parse_forked_args(upstreams, &patterns, &nr_patterns); + cb.patterns = patterns; + cb.nr_patterns = nr_patterns; + cb.out = out; + + refs_for_each_branch_ref(get_main_ref_store(the_repository), + collect_forked_branch, &cb); + + string_list_sort(out); + + upstream_pattern_list_clear(patterns, nr_patterns); +} + +static int prune_merged_branches(const struct string_list *upstreams, + int quiet, int dry_run) +{ + struct ref_store *refs = get_main_ref_store(the_repository); + struct string_list candidates = STRING_LIST_INIT_DUP; + struct strvec deletable = STRVEC_INIT; + struct string_list_item *item; + int ret = 0; + + if (!upstreams->nr) + die(_("--prune-merged requires at least one ")); + + collect_forked_set(upstreams, &candidates); + + for_each_string_list_item(item, &candidates) { + const char *short_name = item->string; + struct branch *branch = branch_get(short_name); + const char *upstream, *push; + struct strbuf full = STRBUF_INIT; + struct strbuf key = STRBUF_INIT; + int skip; + int opt_out; + + strbuf_addf(&full, "refs/heads/%s", short_name); + skip = !!branch_checked_out(full.buf); + strbuf_release(&full); + if (skip) + continue; + + upstream = branch ? branch_get_upstream(branch, NULL) : NULL; + if (!upstream || !refs_ref_exists(refs, upstream)) + continue; + push = branch ? branch_get_push(branch, NULL) : NULL; + if (!push || !strcmp(push, upstream)) + continue; + + strbuf_addf(&key, "branch.%s.prunemerged", short_name); + if (!repo_config_get_bool(the_repository, key.buf, &opt_out) && + !opt_out) { + if (!quiet) + fprintf(stderr, + _("Skipping '%s' (branch.%s.pruneMerged is false)\n"), + short_name, short_name); + strbuf_release(&key); + continue; + } + strbuf_release(&key); + + strvec_push(&deletable, short_name); + } + + if (deletable.nr) + ret = delete_branches(deletable.nr, deletable.v, + 0, /* force */ + FILTER_REFS_BRANCHES, + quiet, + 1, /* warn_only */ + 1, /* no_head_fallback */ + dry_run); + + strvec_clear(&deletable); + string_list_clear(&candidates, 0); + return ret; +} + static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION") static int edit_branch_description(const char *branch_name) @@ -714,6 +965,9 @@ 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; + struct string_list forked_upstreams = STRING_LIST_INIT_DUP; + struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP; + int dry_run = 0; const char *new_upstream = NULL; int noncreate_actions = 0; /* possible options */ @@ -767,6 +1021,12 @@ 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_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"), + N_("list local branches whose upstream matches (repeatable)")), + OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"), + N_("delete local branches whose upstream matches and is merged (repeatable)")), + OPT_BOOL(0, "dry-run", &dry_run, + N_("with --prune-merged, only print which branches would be deleted")), 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")), @@ -811,19 +1071,24 @@ int cmd_branch(int argc, 0); if (!delete && !rename && !copy && !edit_description && !new_upstream && - !show_current && !unset_upstream && argc == 0) + !show_current && !unset_upstream && !prune_merged_upstreams.nr && + argc == 0) list = 1; if (filter.with_commit || filter.no_commit || - filter.reachable_from || filter.unreachable_from || filter.points_at.nr) + filter.reachable_from || filter.unreachable_from || + filter.points_at.nr || forked_upstreams.nr) list = 1; noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream + !!show_current + !!list + !!edit_description + - !!unset_upstream; + !!unset_upstream + !!prune_merged_upstreams.nr; if (noncreate_actions > 1) usage_with_options(builtin_branch_usage, options); + if (dry_run && !prune_merged_upstreams.nr) + die(_("--dry-run requires --prune-merged")); + if (recurse_submodules_explicit) { if (!submodule_propagate_branches) die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled")); @@ -858,7 +1123,14 @@ int cmd_branch(int argc, if (delete) { if (!argc) die(_("branch name required")); - ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet); + ret = delete_branches(argc, argv, delete > 1, filter.kind, + quiet, 0, 0, 0); + goto out; + } else if (prune_merged_upstreams.nr) { + if (argc) + die(_("--prune-merged does not take positional arguments; " + "repeat --prune-merged for each ")); + ret = prune_merged_branches(&prune_merged_upstreams, quiet, dry_run); goto out; } else if (show_current) { print_current_branch_name(); @@ -880,7 +1152,8 @@ int cmd_branch(int argc, ref_sorting_set_sort_flags_all(sorting, REF_SORTING_ICASE, icase); ref_sorting_set_sort_flags_all( sorting, REF_SORTING_DETACHED_HEAD_FIRST, 1); - print_ref_list(&filter, sorting, &format, &output); + print_ref_list(&filter, sorting, &format, &output, + &forked_upstreams); print_columns(&output, colopts, NULL); string_list_clear(&output, 0); ref_sorting_release(sorting); @@ -1020,5 +1293,7 @@ int cmd_branch(int argc, out: string_list_clear(&sorting_options, 0); + string_list_clear(&forked_upstreams, 0); + string_list_clear(&prune_merged_upstreams, 0); return ret; } diff --git a/ref-filter.c b/ref-filter.c index 1da4c0e60d..65e7bc6785 100644 --- a/ref-filter.c +++ b/ref-filter.c @@ -3035,7 +3035,7 @@ static int filter_one(const struct reference *ref, void *cb_data) } /* Free memory allocated for a ref_array_item */ -static void free_array_item(struct ref_array_item *item) +void free_ref_array_item(struct ref_array_item *item) { free((char *)item->symref); if (item->value) { @@ -3078,7 +3078,7 @@ static int filter_and_format_one(const struct reference *ref, void *cb_data) strbuf_release(&output); strbuf_release(&err); - free_array_item(item); + free_ref_array_item(item); /* * Increment the running count of refs that match the filter. If @@ -3098,7 +3098,7 @@ void ref_array_clear(struct ref_array *array) int i; for (i = 0; i < array->nr; i++) - free_array_item(array->items[i]); + free_ref_array_item(array->items[i]); FREE_AND_NULL(array->items); array->nr = array->alloc = 0; @@ -3171,7 +3171,7 @@ static void reach_filter(struct ref_array *array, if (is_merged == include_reached) array->items[array->nr++] = array->items[i]; else - free_array_item(item); + free_ref_array_item(item); } clear_commit_marks_many(old_nr, to_clear, ALL_REV_FLAGS); @@ -3667,7 +3667,7 @@ void pretty_print_ref(const char *name, const struct object_id *oid, strbuf_release(&err); strbuf_release(&output); - free_array_item(ref_item); + free_ref_array_item(ref_item); } static int parse_sorting_atom(const char *atom) diff --git a/ref-filter.h b/ref-filter.h index 120221b47f..3883b9dc62 100644 --- a/ref-filter.h +++ b/ref-filter.h @@ -155,6 +155,8 @@ void filter_and_format_refs(struct ref_filter *filter, unsigned int type, struct ref_format *format); /* Clear all memory allocated to ref_array */ void ref_array_clear(struct ref_array *array); +/* Free a single item from a ref_array */ +void free_ref_array_item(struct ref_array_item *item); /* Used to verify if the given format is correct and to parse out the used atoms */ int verify_ref_format(struct ref_format *format); /* Sort the given ref_array as per the ref_sorting provided */ diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index 5f8a31c21d..84fefdb610 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1719,4 +1719,358 @@ test_expect_success 'errors if given a bad branch name' ' test_cmp expect actual ' +test_expect_success '--forked: setup' ' + test_create_repo forked-upstream && + test_commit -C forked-upstream base && + git -C forked-upstream branch one base && + git -C forked-upstream branch two base && + + test_create_repo forked-other && + test_commit -C forked-other other-base && + git -C forked-other branch foreign other-base && + + git clone forked-upstream forked && + git -C forked remote add other ../forked-other && + git -C forked fetch other && + git -C forked branch local-base && + git -C forked branch --track local-one origin/one && + git -C forked branch --track local-two origin/two && + git -C forked branch --track local-foreign other/foreign && + git -C forked branch detached && + git -C forked branch --track local-trunk local-base +' + +test_expect_success '--forked filters by upstream' ' + git -C forked branch --forked origin/one --format="%(refname:short)" >actual && + echo local-one >expect && + test_cmp expect actual +' + +test_expect_success '--forked filters by wildmatch' ' + git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-one + local-two + main + EOF + test_cmp expect actual +' + +test_expect_success '--forked matches branches with local upstream' ' + git -C forked branch --forked local-base --format="%(refname:short)" >actual && + echo local-trunk >expect && + test_cmp expect actual +' + +test_expect_success '--forked can be repeated to widen the filter' ' + git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-foreign + local-one + EOF + test_cmp expect actual +' + +test_expect_success '--forked combines literal and glob arguments' ' + git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-foreign + local-trunk + EOF + test_cmp expect actual +' + +test_expect_success '--forked "*/*" covers every remote-tracking upstream' ' + git -C forked branch --forked "*/*" --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-foreign + local-one + local-two + main + EOF + test_cmp expect actual +' + +test_expect_success '--forked composes with --no-merged' ' + test_when_finished "git -C forked checkout detached" && + git -C forked checkout local-one && + test_commit -C forked local-only && + git -C forked branch --forked "origin/*" --no-merged origin/one \ + --format="%(refname:short)" >actual && + echo local-one >expect && + test_cmp expect actual +' + +test_expect_success '--forked rejects unknown branch/pattern' ' + test_must_fail git -C forked branch --forked nope 2>err && + test_grep "not a valid branch or pattern" err +' + +test_expect_success '--forked requires a value' ' + test_must_fail git -C forked branch --forked 2>err && + test_grep "requires a value" err +' + +test_expect_success '--prune-merged: setup' ' + test_create_repo pm-upstream && + test_commit -C pm-upstream base && + git -C pm-upstream checkout -b next && + test_commit -C pm-upstream one-commit && + test_commit -C pm-upstream two-commit && + git -C pm-upstream branch one HEAD~ && + git -C pm-upstream branch two HEAD && + git -C pm-upstream branch wip main && + git -C pm-upstream checkout main && + test_create_repo pm-fork +' + +test_expect_success '--prune-merged deletes branches integrated into upstream' ' + test_when_finished "rm -rf pm-merged" && + git clone pm-upstream pm-merged && + git -C pm-merged remote add fork ../pm-fork && + test_config -C pm-merged remote.pushDefault fork && + test_config -C pm-merged push.default current && + git -C pm-merged branch one one-commit && + git -C pm-merged branch --set-upstream-to=origin/next one && + git -C pm-merged branch two two-commit && + git -C pm-merged branch --set-upstream-to=origin/next two && + + git -C pm-merged branch --prune-merged "origin/*" && + + test_must_fail git -C pm-merged rev-parse --verify refs/heads/one && + test_must_fail git -C pm-merged rev-parse --verify refs/heads/two +' + +test_expect_success '--prune-merged accepts a literal upstream' ' + test_when_finished "rm -rf pm-literal" && + git clone pm-upstream pm-literal && + git -C pm-literal remote add fork ../pm-fork && + test_config -C pm-literal remote.pushDefault fork && + test_config -C pm-literal push.default current && + git -C pm-literal branch one one-commit && + git -C pm-literal branch --set-upstream-to=origin/next one && + + git -C pm-literal branch --prune-merged origin/next && + + test_must_fail git -C pm-literal rev-parse --verify refs/heads/one +' + +test_expect_success '--prune-merged unions multiple arguments' ' + test_when_finished "rm -rf pm-union" && + git clone pm-upstream pm-union && + git -C pm-union remote add fork ../pm-fork && + test_config -C pm-union remote.pushDefault fork && + test_config -C pm-union push.default current && + git -C pm-union branch one one-commit && + git -C pm-union branch --set-upstream-to=origin/next one && + git -C pm-union branch two base && + git -C pm-union branch --set-upstream-to=origin/main two && + git -C pm-union checkout --detach && + + git -C pm-union branch --prune-merged origin/next --prune-merged origin/main && + + test_must_fail git -C pm-union rev-parse --verify refs/heads/one && + test_must_fail git -C pm-union rev-parse --verify refs/heads/two +' + +test_expect_success '--prune-merged accepts a local upstream' ' + test_when_finished "rm -rf pm-local" && + git clone pm-upstream pm-local && + git -C pm-local remote add fork ../pm-fork && + test_config -C pm-local remote.pushDefault fork && + test_config -C pm-local push.default current && + git -C pm-local checkout -b trunk && + git -C pm-local branch one one-commit && + git -C pm-local branch --set-upstream-to=trunk one && + git -C pm-local merge --ff-only one-commit && + + git -C pm-local branch --prune-merged trunk && + + test_must_fail git -C pm-local rev-parse --verify refs/heads/one +' + +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' ' + test_when_finished "rm -rf pm-unmerged" && + git clone pm-upstream pm-unmerged && + git -C pm-unmerged remote add fork ../pm-fork && + test_config -C pm-unmerged remote.pushDefault fork && + test_config -C pm-unmerged push.default current && + git -C pm-unmerged checkout -b wip origin/wip && + git -C pm-unmerged branch --set-upstream-to=origin/next wip && + test_commit -C pm-unmerged local-only && + git -C pm-unmerged checkout - && + + git -C pm-unmerged branch --prune-merged "origin/*" 2>err && + test_grep "not fully merged" err && + test_grep ! "If you are sure you want to delete it" err && + git -C pm-unmerged rev-parse --verify refs/heads/wip +' + +test_expect_success '--prune-merged is silent about not-merged-to-HEAD' ' + test_when_finished "rm -rf pm-nohead" && + git clone pm-upstream pm-nohead && + git -C pm-nohead remote add fork ../pm-fork && + test_config -C pm-nohead remote.pushDefault fork && + test_config -C pm-nohead push.default current && + git -C pm-nohead branch topic one-commit && + git -C pm-nohead branch --set-upstream-to=origin/next topic && + + git -C pm-nohead branch --prune-merged "origin/*" 2>err && + + test_grep ! "not yet merged to HEAD" err && + test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic +' + +test_expect_success '--prune-merged skips branches whose upstream is gone' ' + test_when_finished "rm -rf pm-upstream-gone" && + git clone pm-upstream pm-upstream-gone && + git -C pm-upstream-gone remote add fork ../pm-fork && + test_config -C pm-upstream-gone remote.pushDefault fork && + test_config -C pm-upstream-gone push.default current && + git -C pm-upstream-gone branch one one-commit && + git -C pm-upstream-gone branch --set-upstream-to=origin/next one && + + git -C pm-upstream-gone update-ref -d refs/remotes/origin/next && + git -C pm-upstream-gone branch --prune-merged "origin/*" && + + git -C pm-upstream-gone rev-parse --verify refs/heads/one +' + +test_expect_success '--prune-merged never deletes the checked-out branch' ' + test_when_finished "rm -rf pm-head" && + git clone pm-upstream pm-head && + git -C pm-head remote add fork ../pm-fork && + test_config -C pm-head remote.pushDefault fork && + test_config -C pm-head push.default current && + git -C pm-head checkout -b one one-commit && + git -C pm-head branch --set-upstream-to=origin/next one && + + git -C pm-head branch --prune-merged "origin/*" && + + git -C pm-head rev-parse --verify refs/heads/one +' + +test_expect_success '--prune-merged spares branches that push back to their upstream' ' + test_when_finished "rm -rf pm-push-eq" && + git clone pm-upstream pm-push-eq && + git -C pm-push-eq checkout --detach && + + git -C pm-push-eq branch --prune-merged "origin/*" && + + git -C pm-push-eq rev-parse --verify refs/heads/main +' + +test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' ' + test_when_finished "rm -rf pm-push-branch" && + git clone pm-upstream pm-push-branch && + git -C pm-push-branch remote add fork ../pm-fork && + test_config -C pm-push-branch remote.pushDefault fork && + test_config -C pm-push-branch push.default current && + test_config -C pm-push-branch branch.main.pushRemote origin && + git -C pm-push-branch checkout --detach && + + git -C pm-push-branch branch --prune-merged "origin/*" && + + git -C pm-push-branch rev-parse --verify refs/heads/main +' + +test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' ' + test_when_finished "rm -rf pm-push-diff" && + git clone pm-upstream pm-push-diff && + git -C pm-push-diff remote add fork ../pm-fork && + test_config -C pm-push-diff remote.pushDefault fork && + test_config -C pm-push-diff push.default current && + git -C pm-push-diff branch topic one-commit && + git -C pm-push-diff branch --set-upstream-to=origin/next topic && + git -C pm-push-diff checkout --detach && + + git -C pm-push-diff branch --prune-merged "origin/*" && + + test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic +' + +test_expect_success '--prune-merged requires a value' ' + test_must_fail git -C forked branch --prune-merged 2>err && + test_grep "requires a value" err +' + +test_expect_success '--prune-merged rejects positional arguments' ' + test_must_fail git -C forked branch --prune-merged origin/one other/foreign 2>err && + test_grep "does not take positional arguments" err +' + +test_expect_success '--prune-merged honours branch..pruneMerged=false' ' + test_when_finished "rm -rf pm-optout" && + git clone pm-upstream pm-optout && + git -C pm-optout remote add fork ../pm-fork && + test_config -C pm-optout remote.pushDefault fork && + test_config -C pm-optout push.default current && + git -C pm-optout branch one one-commit && + git -C pm-optout branch --set-upstream-to=origin/next one && + git -C pm-optout branch two two-commit && + git -C pm-optout branch --set-upstream-to=origin/next two && + test_config -C pm-optout branch.one.pruneMerged false && + + git -C pm-optout branch --prune-merged "origin/*" 2>err && + + git -C pm-optout rev-parse --verify refs/heads/one && + test_must_fail git -C pm-optout rev-parse --verify refs/heads/two && + test_grep "Skipping .one." err +' + +test_expect_success 'branch -d still deletes a pruneMerged=false branch' ' + test_when_finished "rm -rf pm-optout-d" && + git clone pm-upstream pm-optout-d && + git -C pm-optout-d branch one one-commit && + git -C pm-optout-d branch --set-upstream-to=origin/next one && + test_config -C pm-optout-d branch.one.pruneMerged false && + + git -C pm-optout-d branch -d one && + test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one +' + +test_expect_success '--prune-merged --dry-run lists but does not delete' ' + test_when_finished "rm -rf pm-dry" && + git clone pm-upstream pm-dry && + git -C pm-dry remote add fork ../pm-fork && + test_config -C pm-dry remote.pushDefault fork && + test_config -C pm-dry push.default current && + git -C pm-dry branch one one-commit && + git -C pm-dry branch --set-upstream-to=origin/next one && + git -C pm-dry branch two two-commit && + git -C pm-dry branch --set-upstream-to=origin/next two && + + git -C pm-dry branch --dry-run --prune-merged "origin/*" >actual && + test_grep "Would delete branch one " actual && + test_grep "Would delete branch two " actual && + + git -C pm-dry rev-parse --verify refs/heads/one && + git -C pm-dry rev-parse --verify refs/heads/two +' + +test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' ' + test_when_finished "rm -rf pm-dry-mixed" && + git clone pm-upstream pm-dry-mixed && + git -C pm-dry-mixed remote add fork ../pm-fork && + test_config -C pm-dry-mixed remote.pushDefault fork && + test_config -C pm-dry-mixed push.default current && + git -C pm-dry-mixed checkout -b wip origin/next && + git -C pm-dry-mixed branch --set-upstream-to=origin/next wip && + test_commit -C pm-dry-mixed local-only && + git -C pm-dry-mixed checkout - && + git -C pm-dry-mixed branch merged one-commit && + git -C pm-dry-mixed branch --set-upstream-to=origin/next merged && + + git -C pm-dry-mixed branch --dry-run --prune-merged "origin/*" >out && + test_grep "Would delete branch merged" out && + test_grep ! "Would delete branch wip" out && + git -C pm-dry-mixed rev-parse --verify refs/heads/wip && + git -C pm-dry-mixed rev-parse --verify refs/heads/merged +' + +test_expect_success '--dry-run without --prune-merged is rejected' ' + test_must_fail git -C forked branch --dry-run 2>err && + test_grep "requires --prune-merged" err +' + test_done