From 7b88ac93f93d801a05de4d150bde4310518d0556 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 9 Jun 2026 10:11:52 +0000 Subject: [PATCH 1/6] branch: add --forked filter for --list mode Add a --forked option to "git branch" list mode that lists only branches whose configured upstream matches . The argument can be a ref (e.g. "origin/main", "master") or a shell glob (e.g. "origin/*"), and may be repeated to widen the filter. It is an ordinary list filter, so it combines with the others: git branch --merged origin/main --forked 'origin/*' lists branches forked from origin that are already merged into origin/main, and --no-merged inverts the question. This is the building block for --prune-merged, which deletes the listed branches once they have landed on their upstream. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-branch.adoc | 10 +++- builtin/branch.c | 18 ++++++- ref-filter.c | 70 ++++++++++++++++++++++++++ ref-filter.h | 10 ++++ t/t3200-branch.sh | 92 +++++++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 3 deletions(-) diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index c0afddc424..62ebab6051 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -13,6 +13,7 @@ git branch [--color[=] | --no-color] [--show-current] [--column[=] | --no-column] [--sort=] [--merged []] [--no-merged []] [--contains []] [--no-contains []] + [(--forked )...] [--points-at ] [--format=] [(-r|--remotes) | (-a|--all)] [--list] [...] @@ -51,7 +52,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 __ 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 __ which points to the current `HEAD`, or __ if given. As a @@ -311,6 +313,12 @@ superproject's "origin/main", but tracks the submodule's "origin/main". Only list branches whose tips are not reachable from __ (`HEAD` if not specified). Implies `--list`. +`--forked `:: + Only list branches whose configured upstream matches + __. The argument can be a ref (e.g. `origin/main`, + `master`) or a shell-style glob (e.g. `'origin/*'`). The + option can be repeated to widen the filter. Implies `--list`. + `--points-at `:: Only list branches of __. diff --git a/builtin/branch.c b/builtin/branch.c index 1572a4f9ef..c159f45b4c 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -30,7 +30,7 @@ #include "commit-reach.h" static const char * const builtin_branch_usage[] = { - N_("git branch [] [-r | -a] [--merged] [--no-merged]"), + N_("git branch [] [-r | -a] [--merged] [--no-merged] [(--forked )...]"), N_("git branch [] [-f] [--recurse-submodules] []"), N_("git branch [] [-l] [...]"), N_("git branch [] [-r] (-d | -D) ..."), @@ -673,6 +673,16 @@ 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; +} + static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION") static int edit_branch_description(const char *branch_name) @@ -770,6 +780,9 @@ int cmd_branch(int argc, 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 (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"), @@ -815,7 +828,8 @@ int cmd_branch(int argc, 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 + diff --git a/ref-filter.c b/ref-filter.c index 1da4c0e60d..1ddd5a3f6d 100644 --- a/ref-filter.c +++ b/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; + (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 @@ -3765,6 +3834,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); diff --git a/ref-filter.h b/ref-filter.h index 120221b47f..9361296e2a 100644 --- a/ref-filter.h +++ b/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 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. */ diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index e7829c2c4b..4e7deddc04 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' ' test_cmp expect actual ' +test_expect_success '--forked: setup' ' + test_create_repo forked-upstream && + test_commit -C forked-upstream base && + git -C forked-upstream branch one base && + git -C forked-upstream branch two base && + + test_create_repo forked-other && + test_commit -C forked-other other-base && + git -C forked-other branch foreign other-base && + + git clone forked-upstream forked && + git -C forked remote add other ../forked-other && + git -C forked fetch other && + git -C forked branch local-base && + git -C forked branch --track local-one origin/one && + git -C forked branch --track local-two origin/two && + git -C forked branch --track local-foreign other/foreign && + git -C forked branch detached && + git -C forked branch --track local-trunk local-base +' + +test_expect_success '--forked filters by upstream' ' + git -C forked branch --forked origin/one --format="%(refname:short)" >actual && + echo local-one >expect && + test_cmp expect actual +' + +test_expect_success '--forked filters by wildmatch' ' + git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-one + local-two + main + EOF + test_cmp expect actual +' + +test_expect_success '--forked matches branches with local upstream' ' + git -C forked branch --forked local-base --format="%(refname:short)" >actual && + echo local-trunk >expect && + test_cmp expect actual +' + +test_expect_success '--forked can be repeated to widen the filter' ' + git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-foreign + local-one + EOF + test_cmp expect actual +' + +test_expect_success '--forked combines literal and glob arguments' ' + git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-foreign + local-trunk + EOF + test_cmp expect actual +' + +test_expect_success '--forked "*/*" covers every remote-tracking upstream' ' + git -C forked branch --forked "*/*" --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-foreign + local-one + local-two + main + EOF + test_cmp expect actual +' + +test_expect_success '--forked composes with --no-merged' ' + test_when_finished "git -C forked checkout detached" && + git -C forked checkout local-one && + test_commit -C forked local-only && + git -C forked branch --forked "origin/*" --no-merged origin/one \ + --format="%(refname:short)" >actual && + echo local-one >expect && + test_cmp expect actual +' + +test_expect_success '--forked rejects unknown branch/pattern' ' + test_must_fail git -C forked branch --forked nope 2>err && + test_grep "not a valid branch or pattern" err +' + +test_expect_success '--forked requires a value' ' + test_must_fail git -C forked branch --forked 2>err && + test_grep "requires a value" err +' + test_done From b03b54cad9d1fc9468358014f5d04097118f5d87 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 9 Jun 2026 10:11:53 +0000 Subject: [PATCH 2/6] branch: let delete_branches warn instead of error on bulk refusal Add a warn-only mode to delete_branches() and check_branch_commit() so a bulk caller can report branches that are not fully merged as a short warning and carry on, rather than erroring with the longer "use 'git branch -D'" advice that the plain "git branch -d" path emits. Existing callers are unaffected. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/branch.c | 54 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/builtin/branch.c b/builtin/branch.c index c159f45b4c..4fb012c7a4 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -189,20 +189,33 @@ 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_WARN_ONLY = (1 << 2), +}; + 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) { + int force = flags & DELETE_BRANCH_FORCE; struct commit *rev = lookup_commit_reference(the_repository, oid); if (!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_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; @@ -217,8 +230,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; @@ -227,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, int i; int ret = 0; int remote_branch = 0; + int force, quiet; struct strbuf bname = STRBUF_INIT; enum interpret_branch_kind allowed_interpret; struct string_list refs_to_delete = STRING_LIST_INIT_DUP; @@ -241,7 +255,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 +266,15 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, } branch_name_pos = strcspn(fmt, "%"); + force = flags & DELETE_BRANCH_FORCE; + quiet = flags & DELETE_BRANCH_QUIET; + if (!force) 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(&bname, argv[i], allowed_interpret); free(name); @@ -279,7 +296,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); @@ -291,7 +308,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) @@ -306,16 +323,17 @@ 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_WARN_ONLY)) + 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: @@ -872,7 +890,9 @@ 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 (show_current) { print_current_branch_name(); From c0c8bbb699828d68e9680e69727e6e7a91b0ee2e Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 9 Jun 2026 10:11:54 +0000 Subject: [PATCH 3/6] branch: prepare delete_branches for a bulk caller Teach delete_branches() two new modes for the upcoming --prune-merged: one that asks only whether a branch is merged into its upstream, without falling back to HEAD when there is no upstream, and one that rehearses the deletions without removing any ref. Existing callers keep their current behavior. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/branch.c | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/builtin/branch.c b/builtin/branch.c index 4fb012c7a4..2cc5a8cde0 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -168,10 +168,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) @@ -193,6 +196,8 @@ enum delete_branch_flags { DELETE_BRANCH_FORCE = (1 << 0), DELETE_BRANCH_QUIET = (1 << 1), DELETE_BRANCH_WARN_ONLY = (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, @@ -240,7 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds, int i; int ret = 0; int remote_branch = 0; - int force, quiet; + int force, quiet, dry_run, no_head_fallback; struct strbuf bname = STRBUF_INIT; enum interpret_branch_kind allowed_interpret; struct string_list refs_to_delete = STRING_LIST_INIT_DUP; @@ -268,8 +273,10 @@ static int delete_branches(int argc, const char **argv, int kinds, force = flags & DELETE_BRANCH_FORCE; quiet = flags & DELETE_BRANCH_QUIET; + dry_run = flags & DELETE_BRANCH_DRY_RUN; + no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK; - 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)) { @@ -340,13 +347,20 @@ static int delete_branches(int argc, const char **argv, 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 From 487610435d25045ccb9407c38b6b83520c5f3316 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 9 Jun 2026 10:11:55 +0000 Subject: [PATCH 4/6] branch: add --prune-merged git branch --prune-merged ... deletes the local branches that "--forked " would list, keeping only those whose tip is reachable from their configured upstream: the work has already landed on the upstream they track, so the local copy is no longer needed. Reachability is read from local refs; nothing is fetched. Run "git fetch" first if you want fresh upstream refs. Three kinds of branches are spared: * any branch checked out in any worktree; * any branch whose upstream no longer resolves locally, since a missing upstream is not by itself a sign of integration; * any branch whose push destination equals its upstream (@{push} is the same as @{upstream}), such as a local "main" that tracks and pushes to "origin/main". Right after a pull it just looks "fully merged", so it is left alone. Only branches that push somewhere other than their upstream, typically topics in a fork workflow, are candidates. Branches that are not yet merged into their upstream are reported as a short warning and skipped, so one unmerged topic does not abort the whole sweep. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-branch.adoc | 24 ++++ builtin/branch.c | 67 +++++++++++- t/t3200-branch.sh | 201 ++++++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 2 deletions(-) diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index 62ebab6051..fdaccc9662 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -25,6 +25,7 @@ git branch (-m|-M) [] git branch (-c|-C) [] git branch (-d|-D) [-r] ... git branch --edit-description [] +git branch --prune-merged ... DESCRIPTION ----------- @@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode. Print the name of the current branch. In detached `HEAD` state, nothing is printed. +`--prune-merged ...`:: + Delete the local branches that `--forked` would list for the + given __ arguments, 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. Several + __ patterns may be given, e.g. `git branch + --prune-merged origin/main 'feature*'`. ++ +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; or its push destination (`@{push}`) equals its +upstream (`@{upstream}`), so it cannot be distinguished +from a freshly pulled trunk that just looks "fully merged". ++ +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. + `-v`:: `-vv`:: `--verbose`:: diff --git a/builtin/branch.c b/builtin/branch.c index 2cc5a8cde0..af37a0ceb7 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = { N_("git branch [] (-c | -C) [] "), N_("git branch [] [-r | -a] [--points-at]"), N_("git branch [] [-r | -a] [--format]"), + N_("git branch [] --prune-merged ..."), NULL }; @@ -715,6 +716,61 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset return 0; } +static int prune_merged_branches(int argc, const char **argv, + int quiet) +{ + struct ref_store *refs = get_main_ref_store(the_repository); + struct ref_filter filter = REF_FILTER_INIT; + struct ref_array candidates; + struct strvec deletable = STRVEC_INIT; + int i, ret = 0; + + if (!argc) + die(_("--prune-merged requires at least one ")); + + for (i = 0; i < argc; i++) + if (ref_filter_forked_add(&filter, argv[i]) < 0) + die(_("'%s' is not a valid branch or pattern"), argv[i]); + + filter.kind = FILTER_REFS_BRANCHES; + memset(&candidates, 0, sizeof(candidates)); + filter_refs(&candidates, &filter, filter.kind); + + for (i = 0; i < candidates.nr; i++) { + const char *full_name = candidates.items[i]->refname; + const char *short_name; + struct branch *branch; + const char *upstream, *push; + + if (!skip_prefix(full_name, "refs/heads/", &short_name)) + continue; + if (branch_checked_out(full_name)) + continue; + + branch = branch_get(short_name); + 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; + + strvec_push(&deletable, short_name); + } + + if (deletable.nr) + ret = delete_branches(deletable.nr, deletable.v, + FILTER_REFS_BRANCHES, + DELETE_BRANCH_WARN_ONLY | + DELETE_BRANCH_NO_HEAD_FALLBACK | + (quiet ? DELETE_BRANCH_QUIET : 0)); + + strvec_clear(&deletable); + 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) @@ -756,6 +812,7 @@ 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; + int prune_merged = 0; const char *new_upstream = NULL; int noncreate_actions = 0; /* possible options */ @@ -809,6 +866,8 @@ 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_BOOL(0, "prune-merged", &prune_merged, + N_("delete local branches whose upstream matches and is merged")), 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")), @@ -856,7 +915,8 @@ 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 && + argc == 0) list = 1; if (filter.with_commit || filter.no_commit || @@ -866,7 +926,7 @@ int cmd_branch(int argc, noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream + !!show_current + !!list + !!edit_description + - !!unset_upstream; + !!unset_upstream + !!prune_merged; if (noncreate_actions > 1) usage_with_options(builtin_branch_usage, options); @@ -908,6 +968,9 @@ int cmd_branch(int argc, (delete > 1 ? DELETE_BRANCH_FORCE : 0) | (quiet ? DELETE_BRANCH_QUIET : 0)); goto out; + } else if (prune_merged) { + ret = prune_merged_branches(argc, argv, quiet); + goto out; } else if (show_current) { print_current_branch_name(); ret = 0; diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index 4e7deddc04..27ea1319bb 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' ' test_grep "requires a value" err ' +test_expect_success '--prune-merged: setup' ' + test_create_repo pm-upstream && + test_commit -C pm-upstream base && + git -C pm-upstream checkout -b next && + test_commit -C pm-upstream one-commit && + test_commit -C pm-upstream two-commit && + git -C pm-upstream branch one HEAD~ && + git -C pm-upstream branch two HEAD && + git -C pm-upstream branch wip main && + git -C pm-upstream checkout main && + test_create_repo pm-fork +' + +test_expect_success '--prune-merged deletes branches integrated into upstream' ' + test_when_finished "rm -rf pm-merged" && + git clone pm-upstream pm-merged && + git -C pm-merged remote add fork ../pm-fork && + test_config -C pm-merged remote.pushDefault fork && + test_config -C pm-merged push.default current && + git -C pm-merged branch one one-commit && + git -C pm-merged branch --set-upstream-to=origin/next one && + git -C pm-merged branch two two-commit && + git -C pm-merged branch --set-upstream-to=origin/next two && + + git -C pm-merged branch --prune-merged "origin/*" && + + test_must_fail git -C pm-merged rev-parse --verify refs/heads/one && + test_must_fail git -C pm-merged rev-parse --verify refs/heads/two +' + +test_expect_success '--prune-merged accepts a literal upstream' ' + test_when_finished "rm -rf pm-literal" && + git clone pm-upstream pm-literal && + git -C pm-literal remote add fork ../pm-fork && + test_config -C pm-literal remote.pushDefault fork && + test_config -C pm-literal push.default current && + git -C pm-literal branch one one-commit && + git -C pm-literal branch --set-upstream-to=origin/next one && + + git -C pm-literal branch --prune-merged origin/next && + + test_must_fail git -C pm-literal rev-parse --verify refs/heads/one +' + +test_expect_success '--prune-merged unions multiple arguments' ' + test_when_finished "rm -rf pm-union" && + git clone pm-upstream pm-union && + git -C pm-union remote add fork ../pm-fork && + test_config -C pm-union remote.pushDefault fork && + test_config -C pm-union push.default current && + git -C pm-union branch one one-commit && + git -C pm-union branch --set-upstream-to=origin/next one && + git -C pm-union branch two base && + git -C pm-union branch --set-upstream-to=origin/main two && + git -C pm-union checkout --detach && + + git -C pm-union branch --prune-merged origin/next 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 at least one ' ' + test_must_fail git -C forked branch --prune-merged 2>err && + test_grep "requires at least one " err +' + +test_expect_success '--prune-merged takes positional arguments' ' + test_when_finished "rm -rf pm-positional" && + git clone pm-upstream pm-positional && + git -C pm-positional remote add fork ../pm-fork && + test_config -C pm-positional remote.pushDefault fork && + test_config -C pm-positional push.default current && + git -C pm-positional branch one one-commit && + git -C pm-positional branch --set-upstream-to=origin/next one && + git -C pm-positional branch two base && + git -C pm-positional branch --set-upstream-to=origin/main two && + git -C pm-positional checkout --detach && + + git -C pm-positional branch --prune-merged origin/next origin/main && + + test_must_fail git -C pm-positional rev-parse --verify refs/heads/one && + test_must_fail git -C pm-positional rev-parse --verify refs/heads/two +' + test_done From 0c1a58982de253f7874a4ed0ddd1ef1ae2e02807 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 9 Jun 2026 10:11:56 +0000 Subject: [PATCH 5/6] branch: add branch..pruneMerged opt-out Setting branch..pruneMerged=false exempts that branch from "git branch --prune-merged", which is useful for a topic you want to keep developing after an early round of it has been merged upstream. Unless --quiet is given, each skip is reported so the user knows why their topic was kept. Explicit deletion with "git branch -d" still uses the normal merge check and ignores this setting. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/config/branch.adoc | 7 +++++++ Documentation/git-branch.adoc | 5 +++-- builtin/branch.c | 14 ++++++++++++++ t/t3200-branch.sh | 30 ++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc index a4db9fa5c8..6c1b5bb9cd 100644 --- a/Documentation/config/branch.adoc +++ b/Documentation/config/branch.adoc @@ -102,3 +102,10 @@ for details). `git branch --edit-description`. Branch description is automatically added to the `format-patch` cover letter or `request-pull` summary. + +`branch..pruneMerged`:: + If set to `false`, branch __ is exempt from + `git branch --prune-merged`. Useful for a topic branch you + intend to develop further after an initial round has been + merged upstream. Defaults to true. Explicit deletion via + `git branch -d` is unaffected. diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index fdaccc9662..5c43dc55a8 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -217,9 +217,10 @@ 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; or its push destination (`@{push}`) equals its +worktree; its push destination (`@{push}`) equals its upstream (`@{upstream}`), so it cannot be distinguished -from a freshly pulled trunk that just looks "fully merged". +from a freshly pulled trunk that just looks "fully merged"; or +`branch..pruneMerged` is set to `false`. + Branches refused by the "fully merged" safety check are listed as warnings and skipped; pass them to `git branch -D` explicitly if diff --git a/builtin/branch.c b/builtin/branch.c index af37a0ceb7..52a0371292 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -741,6 +741,8 @@ static int prune_merged_branches(int argc, const char **argv, const char *short_name; struct branch *branch; const char *upstream, *push; + struct strbuf key = STRBUF_INIT; + int opt_out; if (!skip_prefix(full_name, "refs/heads/", &short_name)) continue; @@ -755,6 +757,18 @@ static int prune_merged_branches(int argc, const char **argv, 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); } diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index 27ea1319bb..3f7b1fc3d6 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -2010,4 +2010,34 @@ test_expect_success '--prune-merged takes positional arguments' ' test_must_fail git -C pm-positional rev-parse --verify refs/heads/two ' +test_expect_success '--prune-merged honours branch..pruneMerged=false' ' + test_when_finished "rm -rf pm-optout" && + git clone pm-upstream pm-optout && + git -C pm-optout remote add fork ../pm-fork && + test_config -C pm-optout remote.pushDefault fork && + test_config -C pm-optout push.default current && + git -C pm-optout branch one one-commit && + git -C pm-optout branch --set-upstream-to=origin/next one && + git -C pm-optout branch two two-commit && + git -C pm-optout branch --set-upstream-to=origin/next two && + test_config -C pm-optout branch.one.pruneMerged false && + + git -C pm-optout branch --prune-merged "origin/*" 2>err && + + git -C pm-optout rev-parse --verify refs/heads/one && + test_must_fail git -C pm-optout rev-parse --verify refs/heads/two && + test_grep "Skipping .one." err +' + +test_expect_success 'branch -d still deletes a pruneMerged=false branch' ' + test_when_finished "rm -rf pm-optout-d" && + git clone pm-upstream pm-optout-d && + git -C pm-optout-d branch one one-commit && + git -C pm-optout-d branch --set-upstream-to=origin/next one && + test_config -C pm-optout-d branch.one.pruneMerged false && + + git -C pm-optout-d branch -d one && + test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one +' + test_done From 21b72739207733b495cf285dd7f6e3a1c4485715 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 9 Jun 2026 10:11:57 +0000 Subject: [PATCH 6/6] branch: add --dry-run for --prune-merged With --dry-run, --prune-merged prints the local branches it would delete, one "Would delete branch " line each, and exits without touching any ref. The same filtering applies, so the output is exactly the set that the real run would delete. --dry-run is only meaningful together with --prune-merged and is rejected otherwise. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-branch.adoc | 8 ++++++- builtin/branch.c | 13 ++++++++--- t/t3200-branch.sh | 44 +++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index 5c43dc55a8..1f49a831fd 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -25,7 +25,7 @@ git branch (-m|-M) [] git branch (-c|-C) [] git branch (-d|-D) [-r] ... git branch --edit-description [] -git branch --prune-merged ... +git branch [--dry-run] --prune-merged ... DESCRIPTION ----------- @@ -226,6 +226,12 @@ Branches refused by the "fully merged" safety check are listed as warnings and skipped; pass them to `git branch -D` explicitly if you want them gone. +`--dry-run`:: + With `--prune-merged`, print which branches would be + deleted and exit without touching any ref. Useful for + sanity-checking a wide pattern like `'origin/*'` before + committing to the deletion. + `-v`:: `-vv`:: `--verbose`:: diff --git a/builtin/branch.c b/builtin/branch.c index 52a0371292..7c52a88af2 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -717,7 +717,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset } static int prune_merged_branches(int argc, const char **argv, - int quiet) + int quiet, int dry_run) { struct ref_store *refs = get_main_ref_store(the_repository); struct ref_filter filter = REF_FILTER_INIT; @@ -777,7 +777,8 @@ static int prune_merged_branches(int argc, const char **argv, FILTER_REFS_BRANCHES, DELETE_BRANCH_WARN_ONLY | DELETE_BRANCH_NO_HEAD_FALLBACK | - (quiet ? DELETE_BRANCH_QUIET : 0)); + (quiet ? DELETE_BRANCH_QUIET : 0) | + (dry_run ? DELETE_BRANCH_DRY_RUN : 0)); strvec_clear(&deletable); ref_array_clear(&candidates); @@ -827,6 +828,7 @@ int cmd_branch(int argc, int delete = 0, rename = 0, copy = 0, list = 0, unset_upstream = 0, show_current = 0, edit_description = 0; int prune_merged = 0; + int dry_run = 0; const char *new_upstream = NULL; int noncreate_actions = 0; /* possible options */ @@ -882,6 +884,8 @@ int cmd_branch(int argc, N_("edit the description for the branch")), OPT_BOOL(0, "prune-merged", &prune_merged, N_("delete local branches whose upstream matches and is merged")), + 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")), @@ -944,6 +948,9 @@ int cmd_branch(int argc, if (noncreate_actions > 1) usage_with_options(builtin_branch_usage, options); + if (dry_run && !prune_merged) + 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")); @@ -983,7 +990,7 @@ int cmd_branch(int argc, (quiet ? DELETE_BRANCH_QUIET : 0)); goto out; } else if (prune_merged) { - ret = prune_merged_branches(argc, argv, quiet); + ret = prune_merged_branches(argc, argv, quiet, dry_run); goto out; } else if (show_current) { print_current_branch_name(); diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index 3f7b1fc3d6..305c0141fc 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -2040,4 +2040,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' ' 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