Merge branch 'hn/branch-delete-merged' into jch
The 'git branch' command has been taught the '--delete-merged' option to remove local branches that are already merged into their tracked remote-tracking branches. * hn/branch-delete-merged: branch: add --dry-run for --delete-merged branch: add branch.<name>.deleteMerged opt-out branch: add --delete-merged <branch> branch: prepare delete_branches for a bulk caller branch: let delete_branches skip unmerged branches on bulk refusal branch: convert delete_branches() to a flags argument branch: add --forked filter for --list modejch
commit
5d242567f8
|
|
@ -103,3 +103,10 @@ for details).
|
|||
`git branch --edit-description`. Branch description is
|
||||
automatically added to the `format-patch` cover letter or
|
||||
`request-pull` summary.
|
||||
|
||||
`branch.<name>.deleteMerged`::
|
||||
If set to `false`, branch _<name>_ is exempt from
|
||||
`git branch --delete-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.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
|
|||
[--column[=<options>] | --no-column] [--sort=<key>]
|
||||
[--merged [<commit>]] [--no-merged [<commit>]]
|
||||
[--contains [<commit>]] [--no-contains [<commit>]]
|
||||
[(--forked <branch>)...]
|
||||
[--points-at <object>] [--format=<format>]
|
||||
[(-r|--remotes) | (-a|--all)]
|
||||
[--list] [<pattern>...]
|
||||
|
|
@ -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] (--delete-merged <branch>)... [<pattern>...]
|
||||
|
||||
DESCRIPTION
|
||||
-----------
|
||||
|
|
@ -51,7 +53,8 @@ merged into the named commit (i.e. the branches whose tip commits are
|
|||
reachable from the named commit) will be listed. With `--no-merged` only
|
||||
branches not merged into the named commit will be listed. If the _<commit>_
|
||||
argument is missing it defaults to `HEAD` (i.e. the tip of the current
|
||||
branch).
|
||||
branch). With `--forked`, only branches whose configured upstream matches
|
||||
the given branch or pattern will be listed.
|
||||
|
||||
The command's second form creates a new branch head named _<branch-name>_
|
||||
which points to the current `HEAD`, or _<start-point>_ if given. As a
|
||||
|
|
@ -199,6 +202,42 @@ 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 <branch>`::
|
||||
Delete local branches whose configured upstream matches
|
||||
_<branch>_, but only when their tip is reachable from that
|
||||
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. The option can be repeated to widen the upstream match.
|
||||
Optional _<pattern>_ arguments limit which local branches are
|
||||
considered, e.g. `git branch --delete-merged 'origin/*'
|
||||
'topic-*'`.
|
||||
+
|
||||
A branch is not deleted when:
|
||||
+
|
||||
--
|
||||
* its configured upstream ref no longer exists,
|
||||
* it is checked out in any worktree,
|
||||
* pushing it by name to the remote configured by
|
||||
`branch.<name>.remote` would update its upstream, so it cannot be
|
||||
distinguished from a branch that just looks "fully merged" right
|
||||
after a pull, or
|
||||
* `branch.<name>.deleteMerged` is set to `false`.
|
||||
--
|
||||
+
|
||||
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 a surviving branch depends on through a chain of local
|
||||
upstreams is kept, so a branch is never deleted out from under stacked
|
||||
work.
|
||||
|
||||
`--dry-run`::
|
||||
With `--delete-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`::
|
||||
|
|
@ -311,6 +350,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main".
|
|||
Only list branches whose tips are not reachable from
|
||||
_<commit>_ (`HEAD` if not specified). Implies `--list`.
|
||||
|
||||
`--forked <branch>`::
|
||||
Only list branches whose configured upstream matches
|
||||
_<branch>_. The argument can be a ref (e.g. `origin/main`,
|
||||
`master`), a remote name like `origin` for the branch its
|
||||
`origin/HEAD` points at, or a shell-style glob (e.g.
|
||||
`'origin/*'`). The option can be repeated to widen the
|
||||
filter. Implies `--list`.
|
||||
|
||||
`--points-at <object>`::
|
||||
Only list branches of _<object>_.
|
||||
|
||||
|
|
|
|||
275
builtin/branch.c
275
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"
|
||||
|
|
@ -30,7 +31,7 @@
|
|||
#include "commit-reach.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>] (--delete-merged <branch>)... [<pattern>...]"),
|
||||
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)
|
||||
|
|
@ -189,20 +194,32 @@ static int branch_merged(int kind, const char *name,
|
|||
return merged;
|
||||
}
|
||||
|
||||
enum delete_branch_flags {
|
||||
DELETE_BRANCH_FORCE = (1 << 0),
|
||||
DELETE_BRANCH_QUIET = (1 << 1),
|
||||
DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
|
||||
DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
|
||||
DELETE_BRANCH_DRY_RUN = (1 << 4),
|
||||
};
|
||||
|
||||
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, unsigned int flags)
|
||||
{
|
||||
struct commit *rev = lookup_commit_reference(the_repository, oid);
|
||||
if (!force && !rev) {
|
||||
if (!(flags & DELETE_BRANCH_FORCE) && !rev) {
|
||||
error(_("couldn't look up commit object for '%s'"), 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 (!(flags & DELETE_BRANCH_FORCE) &&
|
||||
!branch_merged(kinds, branchname, rev, head_rev)) {
|
||||
if (!(flags & DELETE_BRANCH_SKIP_UNMERGED)) {
|
||||
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;
|
||||
|
|
@ -217,8 +234,8 @@ static void delete_branch_config(const char *branchname)
|
|||
strbuf_release(&buf);
|
||||
}
|
||||
|
||||
static int delete_branches(int argc, const char **argv, int force, int kinds,
|
||||
int quiet)
|
||||
static int delete_branches(int argc, const char **argv, int kinds,
|
||||
unsigned int flags)
|
||||
{
|
||||
struct commit *head_rev = NULL;
|
||||
struct object_id oid;
|
||||
|
|
@ -241,7 +258,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
|
|||
remote_branch = 1;
|
||||
allowed_interpret = INTERPRET_BRANCH_REMOTE;
|
||||
|
||||
force = 1;
|
||||
flags |= DELETE_BRANCH_FORCE;
|
||||
break;
|
||||
case FILTER_REFS_BRANCHES:
|
||||
fmt = "refs/heads/%s";
|
||||
|
|
@ -252,12 +269,13 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
|
|||
}
|
||||
branch_name_pos = strcspn(fmt, "%");
|
||||
|
||||
if (!force)
|
||||
if (!(flags & DELETE_BRANCH_FORCE) &&
|
||||
!(flags & DELETE_BRANCH_NO_HEAD_FALLBACK))
|
||||
head_rev = lookup_commit_reference(the_repository, &head_oid);
|
||||
|
||||
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
|
||||
char *target = NULL;
|
||||
int flags = 0;
|
||||
int ref_flags = 0;
|
||||
|
||||
copy_branchname(the_repository, &bname,
|
||||
argv[i], allowed_interpret);
|
||||
|
|
@ -280,7 +298,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
|
|||
RESOLVE_REF_READING
|
||||
| RESOLVE_REF_NO_RECURSE
|
||||
| RESOLVE_REF_ALLOW_BAD_NAME,
|
||||
&oid, &flags);
|
||||
&oid, &ref_flags);
|
||||
if (!target) {
|
||||
if (remote_branch) {
|
||||
error(_("remote-tracking branch '%s' not found"), bname.buf);
|
||||
|
|
@ -292,7 +310,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
|
|||
| RESOLVE_REF_NO_RECURSE
|
||||
| RESOLVE_REF_ALLOW_BAD_NAME,
|
||||
&oid,
|
||||
&flags);
|
||||
&ref_flags);
|
||||
FREE_AND_NULL(virtual_name);
|
||||
|
||||
if (virtual_target)
|
||||
|
|
@ -307,31 +325,39 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
|
||||
if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
|
||||
check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
|
||||
force)) {
|
||||
ret = 1;
|
||||
flags)) {
|
||||
if (!(flags & DELETE_BRANCH_SKIP_UNMERGED))
|
||||
ret = 1;
|
||||
goto next;
|
||||
}
|
||||
|
||||
item = string_list_append(&refs_to_delete, name);
|
||||
item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
|
||||
: (flags & REF_ISSYMREF) ? target
|
||||
item->util = xstrdup((ref_flags & REF_ISBROKEN) ? "broken"
|
||||
: (ref_flags & REF_ISSYMREF) ? target
|
||||
: repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));
|
||||
|
||||
next:
|
||||
free(target);
|
||||
}
|
||||
|
||||
if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
|
||||
if (!(flags & DELETE_BRANCH_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 (flags & DELETE_BRANCH_DRY_RUN) {
|
||||
if (!(flags & DELETE_BRANCH_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)
|
||||
if (!(flags & DELETE_BRANCH_QUIET))
|
||||
printf(remote_branch
|
||||
? _("Deleted remote-tracking branch %s (was %s).\n")
|
||||
: _("Deleted branch %s (was %s).\n"),
|
||||
|
|
@ -674,6 +700,172 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
|
|||
free_worktrees(worktrees);
|
||||
}
|
||||
|
||||
static int parse_opt_forked(const struct option *opt, const char *arg, int unset)
|
||||
{
|
||||
struct ref_filter *filter = opt->value;
|
||||
|
||||
BUG_ON_OPT_NEG(unset);
|
||||
if (ref_filter_forked_add(filter, arg) < 0)
|
||||
die(_("'%s' is not a valid branch or pattern"), arg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
struct stacked_branch_data {
|
||||
struct strset *deletable_branch_names;
|
||||
struct strset *protected_branch_names;
|
||||
struct strset *visited_branch_names;
|
||||
};
|
||||
|
||||
static int collect_stacked_branch_bases(const struct reference *ref,
|
||||
void *cb_data)
|
||||
{
|
||||
struct stacked_branch_data *data = cb_data;
|
||||
const char *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;
|
||||
|
||||
while (strset_add(data->visited_branch_names, branch_name)) {
|
||||
struct branch *branch = branch_get(branch_name);
|
||||
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(data->deletable_branch_names,
|
||||
upstream_branch_name))
|
||||
break;
|
||||
|
||||
strset_add(data->protected_branch_names, upstream_branch_name);
|
||||
branch_name = 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 = STRSET_INIT;
|
||||
struct strset visited_branch_names = STRSET_INIT;
|
||||
struct stacked_branch_data data = {
|
||||
.deletable_branch_names = deletable_branch_names,
|
||||
.protected_branch_names = &protected_branch_names,
|
||||
.visited_branch_names = &visited_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_bases, &data, &opts);
|
||||
|
||||
strset_for_each_entry(&protected_branch_names, &iter, entry)
|
||||
strset_remove(deletable_branch_names, entry->key);
|
||||
|
||||
strset_clear(&visited_branch_names);
|
||||
strset_clear(&protected_branch_names);
|
||||
}
|
||||
|
||||
static int branch_pushes_to_upstream(struct branch *branch,
|
||||
const char *upstream)
|
||||
{
|
||||
struct remote *remote = remote_get(remote_for_branch(branch, NULL));
|
||||
char *tracking = NULL;
|
||||
int ret = 0;
|
||||
|
||||
if (remote)
|
||||
tracking = apply_refspecs(&remote->fetch, branch->refname);
|
||||
if (tracking && !strcmp(tracking, upstream))
|
||||
ret = 1;
|
||||
|
||||
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 strvec branches_to_delete = STRVEC_INIT;
|
||||
struct strbuf key = STRBUF_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;
|
||||
int opt_out;
|
||||
|
||||
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;
|
||||
|
||||
strbuf_reset(&key);
|
||||
strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
|
||||
if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
|
||||
!opt_out) {
|
||||
if (!(flags & DELETE_BRANCH_QUIET))
|
||||
fprintf(stderr,
|
||||
_("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
|
||||
branch_name, branch_name);
|
||||
continue;
|
||||
}
|
||||
|
||||
strset_add(&deletable_branch_names, branch_name);
|
||||
}
|
||||
|
||||
protect_stacked_branch_bases(refs, &deletable_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);
|
||||
|
||||
strbuf_release(&key);
|
||||
strvec_clear(&branches_to_delete);
|
||||
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)
|
||||
|
|
@ -738,6 +930,8 @@ 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 strvec delete_merged = STRVEC_INIT;
|
||||
int dry_run = 0;
|
||||
const char *new_upstream = NULL;
|
||||
int noncreate_actions = 0;
|
||||
/* possible options */
|
||||
|
|
@ -791,9 +985,17 @@ 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_CALLBACK_F(0, "delete-merged", &delete_merged, N_("branch"),
|
||||
N_("delete merged branches whose upstream matches <branch> (repeatable)"),
|
||||
PARSE_OPT_NONEG, parse_opt_strvec),
|
||||
OPT_BOOL(0, "dry-run", &dry_run,
|
||||
N_("with --delete-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")),
|
||||
OPT_CALLBACK_F(0, "forked", &filter, N_("branch"),
|
||||
N_("print only branches whose upstream matches <branch> (repeatable)"),
|
||||
PARSE_OPT_NONEG, parse_opt_forked),
|
||||
OPT_COLUMN(0, "column", &colopts, N_("list branches in columns")),
|
||||
OPT_REF_SORT(&sorting_options),
|
||||
OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
|
||||
|
|
@ -835,19 +1037,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 && !delete_merged.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 || filter.forked.nr)
|
||||
list = 1;
|
||||
|
||||
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);
|
||||
|
||||
if (dry_run && !delete_merged.nr)
|
||||
die(_("--dry-run requires --delete-merged"));
|
||||
|
||||
if (recurse_submodules_explicit) {
|
||||
if (!submodule_propagate_branches)
|
||||
die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
|
||||
|
|
@ -882,7 +1089,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, filter.kind,
|
||||
(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) |
|
||||
(dry_run ? DELETE_BRANCH_DRY_RUN : 0));
|
||||
goto out;
|
||||
} else if (show_current) {
|
||||
print_current_branch_name();
|
||||
|
|
@ -1055,6 +1269,7 @@ int cmd_branch(int argc,
|
|||
ret = 0;
|
||||
|
||||
out:
|
||||
strvec_clear(&delete_merged);
|
||||
string_list_clear(&sorting_options, 0);
|
||||
return ret;
|
||||
}
|
||||
|
|
|
|||
70
ref-filter.c
70
ref-filter.c
|
|
@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)
|
|||
return match_pattern(filter->exclude.v, refname, filter->ignore_case);
|
||||
}
|
||||
|
||||
static const char *short_upstream_name(const char *full_ref)
|
||||
{
|
||||
const char *short_name = full_ref;
|
||||
|
||||
if (!skip_prefix(short_name, "refs/heads/", &short_name))
|
||||
skip_prefix(short_name, "refs/remotes/", &short_name);
|
||||
return short_name;
|
||||
}
|
||||
|
||||
/*
|
||||
* Match the configured upstream of a branch against the registered
|
||||
* --forked patterns. Exact patterns are compared against the full
|
||||
* upstream refname so they are unambiguous; glob patterns are matched
|
||||
* against the abbreviated upstream so that a glob such as origin/...
|
||||
* works as typed.
|
||||
*/
|
||||
static int filter_forked_match(struct ref_filter *filter, const char *refname)
|
||||
{
|
||||
const char *short_name;
|
||||
struct branch *branch;
|
||||
const char *upstream;
|
||||
|
||||
if (!skip_prefix(refname, "refs/heads/", &short_name))
|
||||
return 0;
|
||||
branch = branch_get(short_name);
|
||||
if (!branch)
|
||||
return 0;
|
||||
upstream = branch_get_upstream(branch, NULL);
|
||||
if (!upstream)
|
||||
return 0;
|
||||
|
||||
for (size_t i = 0; i < filter->forked.nr; i++) {
|
||||
const char *pattern = filter->forked.v[i];
|
||||
if (has_glob_specials(pattern)) {
|
||||
if (!wildmatch(pattern, short_upstream_name(upstream),
|
||||
WM_PATHNAME))
|
||||
return 1;
|
||||
} else if (!strcmp(pattern, upstream)) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ref_filter_forked_add(struct ref_filter *filter, const char *arg)
|
||||
{
|
||||
struct object_id oid;
|
||||
char *full_ref = NULL;
|
||||
|
||||
if (has_glob_specials(arg)) {
|
||||
strvec_push(&filter->forked, arg);
|
||||
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/"))) {
|
||||
strvec_push(&filter->forked, full_ref);
|
||||
free(full_ref);
|
||||
return 0;
|
||||
}
|
||||
free(full_ref);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/*
|
||||
* We need to seek to the reference right after a given marker but excluding any
|
||||
* matching references. So we seek to the lexicographically next reference.
|
||||
|
|
@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,
|
|||
if (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))
|
||||
return NULL;
|
||||
|
||||
if (filter->forked.nr && !filter_forked_match(filter, ref->name))
|
||||
return NULL;
|
||||
|
||||
/*
|
||||
* A merge filter is applied on refs pointing to commits. Hence
|
||||
* obtain the commit using the 'oid' available and discard all
|
||||
|
|
@ -3764,6 +3833,7 @@ void ref_filter_init(struct ref_filter *filter)
|
|||
void ref_filter_clear(struct ref_filter *filter)
|
||||
{
|
||||
strvec_clear(&filter->exclude);
|
||||
strvec_clear(&filter->forked);
|
||||
oid_array_clear(&filter->points_at);
|
||||
commit_list_free(filter->with_commit);
|
||||
commit_list_free(filter->no_commit);
|
||||
|
|
|
|||
10
ref-filter.h
10
ref-filter.h
|
|
@ -67,6 +67,7 @@ struct ref_filter {
|
|||
const char **name_patterns;
|
||||
const char *start_after;
|
||||
struct strvec exclude;
|
||||
struct strvec forked;
|
||||
struct oid_array points_at;
|
||||
struct commit_list *with_commit;
|
||||
struct commit_list *no_commit;
|
||||
|
|
@ -110,6 +111,7 @@ struct ref_format {
|
|||
#define REF_FILTER_INIT { \
|
||||
.points_at = OID_ARRAY_INIT, \
|
||||
.exclude = STRVEC_INIT, \
|
||||
.forked = STRVEC_INIT, \
|
||||
}
|
||||
#define REF_FORMAT_INIT { \
|
||||
.use_color = GIT_COLOR_UNKNOWN, \
|
||||
|
|
@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);
|
|||
struct ref_sorting *ref_sorting_options(struct string_list *);
|
||||
/* Function to parse --merged and --no-merged options */
|
||||
int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);
|
||||
/*
|
||||
* Register a --forked <branch> pattern on the filter. The argument is
|
||||
* either a ref, which is resolved to its full refname, or a shell-style
|
||||
* glob. Branches are kept only when their configured upstream matches
|
||||
* one of the registered patterns. Returns -1 if the argument is not a
|
||||
* valid ref or pattern.
|
||||
*/
|
||||
int ref_filter_forked_add(struct ref_filter *filter, const char *arg);
|
||||
/* Get the current HEAD's description */
|
||||
char *get_head_description(void);
|
||||
/* Set up translated strings in the output. */
|
||||
|
|
|
|||
|
|
@ -1757,4 +1757,397 @@ test_expect_success 'errors if given a bad branch name' '
|
|||
test_cmp expect actual
|
||||
'
|
||||
|
||||
test_expect_success '--forked: setup' '
|
||||
test_create_repo forked-upstream &&
|
||||
(
|
||||
cd forked-upstream &&
|
||||
test_commit base &&
|
||||
git branch one base &&
|
||||
git branch two base
|
||||
) &&
|
||||
|
||||
test_create_repo forked-other &&
|
||||
(
|
||||
cd forked-other &&
|
||||
test_commit other-base &&
|
||||
git branch foreign other-base
|
||||
) &&
|
||||
|
||||
git clone forked-upstream forked &&
|
||||
(
|
||||
cd forked &&
|
||||
git remote add -f other ../forked-other &&
|
||||
git branch local-base &&
|
||||
git branch --track local-one origin/one &&
|
||||
git branch --track local-two origin/two &&
|
||||
git branch --track local-foreign other/foreign &&
|
||||
git branch --track local-onbase local-base &&
|
||||
|
||||
git checkout local-one &&
|
||||
test_commit --no-tag local-one-work local-one.t &&
|
||||
git checkout local-foreign &&
|
||||
test_commit --no-tag local-foreign-work local-foreign.t
|
||||
)
|
||||
'
|
||||
|
||||
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-onbase >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-onbase
|
||||
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' '
|
||||
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 <remote> uses the branch <remote>/HEAD points at' '
|
||||
git -C forked branch --forked origin --format="%(refname:short)" >actual &&
|
||||
echo main >expect &&
|
||||
test_cmp expect actual
|
||||
'
|
||||
|
||||
test_expect_success '--forked narrows a <pattern> argument' '
|
||||
git -C forked branch --forked "origin/*" "local-*" \
|
||||
--format="%(refname:short)" >actual &&
|
||||
cat >expect <<-\EOF &&
|
||||
local-one
|
||||
local-two
|
||||
EOF
|
||||
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 '--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" origin/next --track &&
|
||||
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 a default push remote' '
|
||||
setup_repo_for_delete_merged &&
|
||||
(
|
||||
cd repo &&
|
||||
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 origin/next --track &&
|
||||
git commit --allow-empty -m "unmerged work" &&
|
||||
git checkout -b tracks-other other/main --track &&
|
||||
sha=$(git rev-parse --short merged) &&
|
||||
|
||||
git branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&
|
||||
echo "Would delete branch merged (was $sha)." >expect &&
|
||||
test_cmp expect actual &&
|
||||
git rev-parse --verify refs/heads/merged &&
|
||||
|
||||
check_branches <<-\EOF &&
|
||||
also-merged
|
||||
main
|
||||
merged
|
||||
tracks-other
|
||||
unmerged
|
||||
EOF
|
||||
|
||||
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 main --track &&
|
||||
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 checkout -b tracks-other other/main --track &&
|
||||
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 keeps the upstream of a surviving branch' '
|
||||
setup_repo_for_delete_merged &&
|
||||
create_merged_branch feature &&
|
||||
(
|
||||
cd repo &&
|
||||
git checkout -b topic feature --track &&
|
||||
git commit --allow-empty -m "topic work" &&
|
||||
|
||||
git branch --dry-run --delete-merged origin/next >out &&
|
||||
test_grep ! "feature" out &&
|
||||
|
||||
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 keeps the upstream chain of a surviving branch' '
|
||||
setup_repo_for_delete_merged &&
|
||||
(
|
||||
cd repo &&
|
||||
git config remote.pushDefault fork &&
|
||||
git branch lower origin/next --track &&
|
||||
git branch mid lower --track &&
|
||||
git checkout -b tip mid --track &&
|
||||
git commit --allow-empty -m "tip work" &&
|
||||
|
||||
git branch --dry-run --delete-merged origin/next \
|
||||
--delete-merged lower >actual 2>&1 &&
|
||||
test_must_be_empty actual &&
|
||||
|
||||
git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual &&
|
||||
cat >expect <<-\EOF &&
|
||||
branch.lower.remote origin
|
||||
branch.lower.merge refs/heads/next
|
||||
branch.mid.remote .
|
||||
branch.mid.merge refs/heads/lower
|
||||
branch.tip.remote .
|
||||
branch.tip.merge refs/heads/mid
|
||||
EOF
|
||||
test_cmp expect actual &&
|
||||
|
||||
git branch --delete-merged origin/next \
|
||||
--delete-merged lower >actual 2>&1 &&
|
||||
test_must_be_empty actual &&
|
||||
|
||||
check_branches <<-\EOF &&
|
||||
lower
|
||||
main
|
||||
mid
|
||||
tip
|
||||
EOF
|
||||
|
||||
git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual &&
|
||||
cat >expect <<-\EOF &&
|
||||
branch.lower.remote origin
|
||||
branch.lower.merge refs/heads/next
|
||||
branch.mid.remote .
|
||||
branch.mid.merge refs/heads/lower
|
||||
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 c-lower origin/next --track &&
|
||||
git branch b-mid c-lower --track &&
|
||||
git checkout -b a-tip b-mid --track &&
|
||||
git commit --allow-empty -m "tip work" &&
|
||||
|
||||
git branch --delete-merged origin/next \
|
||||
--delete-merged "c-*" &&
|
||||
|
||||
check_branches <<-\EOF &&
|
||||
a-tip
|
||||
b-mid
|
||||
c-lower
|
||||
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
|
||||
c-lower
|
||||
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_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '
|
||||
setup_repo_for_delete_merged &&
|
||||
create_merged_branch deleted &&
|
||||
create_merged_branch kept &&
|
||||
(
|
||||
cd repo &&
|
||||
git config branch.kept.deleteMerged false &&
|
||||
git checkout --detach &&
|
||||
|
||||
git branch --delete-merged origin/next 2>err &&
|
||||
|
||||
test_grep "Skipping .kept." err &&
|
||||
check_branches <<-\EOF
|
||||
kept
|
||||
main
|
||||
EOF
|
||||
)
|
||||
'
|
||||
|
||||
test_expect_success "branch -d still deletes a deleteMerged=false branch" '
|
||||
setup_repo_for_delete_merged &&
|
||||
create_merged_branch kept &&
|
||||
(
|
||||
cd repo &&
|
||||
git config branch.kept.deleteMerged false &&
|
||||
git checkout --detach &&
|
||||
|
||||
git branch -d kept &&
|
||||
|
||||
check_branches <<-\EOF
|
||||
main
|
||||
EOF
|
||||
)
|
||||
'
|
||||
|
||||
test_expect_success '--dry-run without --delete-merged is rejected' '
|
||||
test_must_fail git -C forked branch --dry-run 2>err &&
|
||||
test_grep "requires --delete-merged" err
|
||||
'
|
||||
|
||||
test_done
|
||||
|
|
|
|||
Loading…
Reference in New Issue