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 mode
jch
Junio C Hamano 2026-07-24 14:40:41 -07:00
commit a157b44ddb
6 changed files with 773 additions and 31 deletions

View File

@ -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.

View File

@ -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>_.


View File

@ -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;
}

View File

@ -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;
(void)(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;
int i;

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 (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);

View File

@ -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. */

View File

@ -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