mirror of
https://github.com/git-for-windows/git.git
synced 2026-06-13 08:57:56 -05:00
Merge branch 'hn/branch-prune-merged' into seen
"git branch" command learned "--prune-merged" option to remove local branches that have already been merged to the remote-tracking branches they track. * hn/branch-prune-merged: branch: add --dry-run for --prune-merged branch: add branch.<name>.pruneMerged opt-out branch: add --prune-merged <branch> branch: prepare delete_branches for a bulk caller branch: let delete_branches warn instead of error on bulk refusal branch: add --forked filter for --list mode
This commit is contained in:
@@ -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.<name>.pruneMerged`::
|
||||
If set to `false`, branch _<name>_ 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.
|
||||
|
||||
@@ -14,6 +14,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
|
||||
[--merged [<commit>]] [--no-merged [<commit>]]
|
||||
[--contains [<commit>]] [--no-contains [<commit>]]
|
||||
[--points-at <object>] [--format=<format>]
|
||||
[(--forked <branch>)...]
|
||||
[(-r|--remotes) | (-a|--all)]
|
||||
[--list] [<pattern>...]
|
||||
git branch [--track[=(direct|inherit)] | --no-track] [-f]
|
||||
@@ -24,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
|
||||
git branch (-c|-C) [<old-branch>] <new-branch>
|
||||
git branch (-d|-D) [-r] <branch-name>...
|
||||
git branch --edit-description [<branch-name>]
|
||||
git branch [--dry-run] (--prune-merged <branch>)...
|
||||
|
||||
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 <branch>`::
|
||||
List only branches whose configured upstream matches
|
||||
_<branch>_. 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 <branch>`::
|
||||
Delete the local branches that `--forked` would list for the
|
||||
same _<branch>_, 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 (`<branch>@{push}`) equals its
|
||||
upstream (`<branch>@{upstream}`), so it cannot be distinguished
|
||||
from a freshly pulled trunk that just looks "fully merged"; or
|
||||
`branch.<name>.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`::
|
||||
|
||||
317
builtin/branch.c
317
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 [<options>] [-r | -a] [--merged] [--no-merged]"),
|
||||
N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
|
||||
N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
|
||||
N_("git branch [<options>] [-l] [<pattern>...]"),
|
||||
N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
|
||||
@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {
|
||||
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>] (--prune-merged <branch>)..."),
|
||||
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 <branch>"));
|
||||
|
||||
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 <branch> (repeatable)")),
|
||||
OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
|
||||
N_("delete local branches whose upstream matches <branch> 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 <branch>"));
|
||||
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;
|
||||
}
|
||||
|
||||
10
ref-filter.c
10
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)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 <upstream-tracking-branch> 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 <glob> 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 <local-branch> 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 <branch> 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.<name>.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
|
||||
|
||||
Reference in New Issue
Block a user