From: "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: Kristoffer Haugsbakk <kristofferhaugsbakk@fastmail.com>,
Johannes Sixt <j6t@kdbg.org>,
Phillip Wood <phillip.wood123@gmail.com>,
Harald Nordgren <haraldnordgren@gmail.com>
Subject: [PATCH v11 0/6] branch: prune-merged
Date: Fri, 22 May 2026 11:31:32 +0000 [thread overview]
Message-ID: <pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2285.v10.git.git.1779403204.gitgitgadget@gmail.com>
After releasing v10, I hard-reset back to v9 and reworked the series from
there.
* The flags now take a branch, not a remote. --forked and --prune-merged
accept a literal upstream short name like origin/main or a wildmatch
pattern like origin/*. The old --all-remotes flag is gone, since origin/*
covers that case.
* The prune guard now compares @{push} against @{upstream}. A branch is
spared when these are equal. That is the trunk like case, such as local
main tracking and pushing to origin/main, where "fully merged to
upstream" cannot be told apart from "just pulled". Only branches that
push somewhere other than their upstream, typically fork based topics,
are candidates. The earlier <remote>/HEAD by name guard that the reviewer
rejected is gone.
* New --dry-run for --prune-merged.
Harald Nordgren (6):
branch: add --forked <branch>
branch: let delete_branches warn instead of error on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --prune-merged <branch>
branch: add branch.<name>.pruneMerged opt-out
branch: add --dry-run for --prune-merged
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 42 ++++
builtin/branch.c | 303 +++++++++++++++++++++++++--
t/t3200-branch.sh | 347 +++++++++++++++++++++++++++++++
4 files changed, 682 insertions(+), 17 deletions(-)
base-commit: aec3f587505a472db67e9462d0702e7d463a449d
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v11
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v11
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v10:
1: f2df159830 ! 1: b9fddd124a branch: add --forked <branch>
@@ Metadata
## Commit message ##
branch: add --forked <branch>
- git branch --forked <branch>...
+ List local branches whose configured upstream
+ (branch.<name>.merge resolved against branch.<name>.remote)
+ matches any of the given <branch> arguments.
- lists local branches whose configured upstream matches any
- of the given <branch> arguments.
+ Each <branch> is interpreted against the local repository, not
+ against any specific remote:
- Each <branch> is resolved to the same kind of ref that
- branch.<name>.remote and branch.<name>.merge together point at:
- a remote-tracking branch (e.g. origin/master), or, for branches
- tracking a local upstream, a local branch (e.g. master).
- Shell-style globs are also accepted (e.g. 'origin/*'). Multiple
- arguments are unioned.
+ * a literal upstream short name, e.g. "origin/main" or "master"
+ for a branch whose upstream is local;
+ * a wildmatch pattern, e.g. "origin/*";
+ * a bare configured-remote name, e.g. "origin", which resolves
+ to whatever refs/remotes/origin/HEAD points at, matching how
+ "git checkout -b topic origin" picks a starting point.
- This is the building block for --prune-merged.
+ The literal-vs-wildcard distinction is settled at parse time so
+ the per-branch matching loop calls wildmatch() only for genuine
+ wildcards. Multiple <branch> arguments are unioned. Output is
+ sorted by branch name.
+
+ 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 <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
nothing is printed.
+`--forked`::
-+ List local branches whose configured upstream matches any
-+ of the given _<branch>_ arguments. Each argument is either
-+ a ref (e.g. `origin/master`, `master`) or a shell-style
-+ glob (e.g. `'origin/*'`). Multiple arguments are unioned.
++ List local branches whose configured upstream
++ (`branch.<name>.merge` resolved against `branch.<name>.remote`)
++ matches any of the given _<branch>_ arguments.
+++
++Each _<branch>_ is interpreted against the local repository: a literal
++upstream like `origin/main` or a local branch like `master`, or a
++wildmatch pattern like `'origin/*'`. A bare configured-remote name
++(e.g. `origin`) resolves to the target of `refs/remotes/<remote>/HEAD`,
++to match the way `git checkout -b topic origin` picks a starting
++point. Multiple _<branch>_ arguments are unioned.
+
`-v`::
`-vv`::
@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
NULL
};
-@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
-
- static int check_branch_commit(const char *branchname, const char *refname,
- const struct object_id *oid, struct commit *head_rev,
-- int kinds, int force)
-+ int kinds, int force, int warn_only,
-+ int *n_not_merged)
- {
- struct commit *rev = lookup_commit_reference(the_repository, oid);
- if (!force && !rev) {
-@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,
- return -1;
- }
- if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
-- error(_("the branch '%s' is not fully merged"), branchname);
-- advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
-- _("If you are sure you want to delete it, "
-- "run 'git branch -D %s'"), branchname);
-+ if (warn_only) {
-+ warning(_("the branch '%s' is not fully merged"),
-+ branchname);
-+ } else {
-+ error(_("the branch '%s' is not fully merged"),
-+ branchname);
-+ advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
-+ _("If you are sure you want to delete it, "
-+ "run 'git branch -D %s'"), branchname);
-+ }
-+ if (n_not_merged)
-+ (*n_not_merged)++;
- return -1;
- }
- return 0;
-@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
- }
-
- static int delete_branches(int argc, const char **argv, int force, int kinds,
-- int quiet)
-+ int quiet, int warn_only, int *n_not_merged)
- {
- struct commit *head_rev = NULL;
- struct object_id oid;
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
-
- if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
- check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
-- force)) {
-- ret = 1;
-+ force, warn_only, n_not_merged)) {
-+ if (!warn_only)
-+ ret = 1;
- goto next;
- }
-
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
free_worktrees(worktrees);
}
++struct upstream_pattern {
++ char *name;
++ int is_wildcard;
++};
++
++static void upstream_pattern_list_clear(struct upstream_pattern *items,
++ size_t nr)
++{
++ size_t i;
++ for (i = 0; i < nr; i++)
++ free(items[i].name);
++ free(items);
++}
++
++static const char *short_upstream_name(const char *full_ref)
++{
++ const char *short_name = full_ref;
++ (void)(skip_prefix(short_name, "refs/heads/", &short_name) ||
++ skip_prefix(short_name, "refs/remotes/", &short_name));
++ return short_name;
++}
++
++static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)
++{
++ struct ref_store *refs = get_main_ref_store(the_repository);
++ struct remote *remote;
++ struct object_id oid;
++ char *full_ref = NULL;
++ struct strbuf head_ref = STRBUF_INIT;
++ const char *resolved;
++
++ if (has_glob_specials(arg)) {
++ out->name = xstrdup(arg);
++ out->is_wildcard = 1;
++ return 0;
++ }
++
++ remote = remote_get(arg);
++ if (remote && remote_is_configured(remote, 0)) {
++ strbuf_addf(&head_ref, "refs/remotes/%s/HEAD", remote->name);
++ resolved = refs_resolve_ref_unsafe(refs, head_ref.buf,
++ RESOLVE_REF_NO_RECURSE,
++ NULL, NULL);
++ if (resolved && starts_with(resolved, "refs/remotes/")) {
++ out->name = xstrdup(short_upstream_name(resolved));
++ out->is_wildcard = 0;
++ strbuf_release(&head_ref);
++ return 0;
++ }
++ strbuf_release(&head_ref);
++ }
++
++ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
++ &full_ref, 0) == 1 &&
++ (starts_with(full_ref, "refs/heads/") ||
++ starts_with(full_ref, "refs/remotes/"))) {
++ out->name = xstrdup(short_upstream_name(full_ref));
++ out->is_wildcard = 0;
++ free(full_ref);
++ return 0;
++ }
++ free(full_ref);
++ return -1;
++}
++
+static void parse_forked_args(int argc, const char **argv,
-+ struct string_list *upstream_patterns)
++ struct upstream_pattern **patterns_out,
++ size_t *nr_out)
+{
++ struct upstream_pattern *patterns;
+ int i;
+
++ ALLOC_ARRAY(patterns, argc);
+ for (i = 0; i < argc; i++) {
-+ const char *arg = argv[i];
-+ struct object_id oid;
-+ char *full_ref = NULL;
-+ const char *short_ref;
-+
-+ if (has_glob_specials(arg)) {
-+ string_list_insert(upstream_patterns, arg);
-+ continue;
++ if (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {
++ upstream_pattern_list_clear(patterns, i);
++ die(_("'%s' is not a valid branch or pattern"),
++ argv[i]);
+ }
++ }
++ *patterns_out = patterns;
++ *nr_out = argc;
++}
+
-+ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
-+ &full_ref, 0) == 1 &&
-+ (skip_prefix(full_ref, "refs/heads/", &short_ref) ||
-+ skip_prefix(full_ref, "refs/remotes/", &short_ref))) {
-+ string_list_insert(upstream_patterns, short_ref);
-+ free(full_ref);
-+ continue;
-+ }
-+ free(full_ref);
++static int upstream_matches(const char *short_upstream,
++ const struct upstream_pattern *patterns,
++ size_t nr)
++{
++ size_t i;
+
-+ die(_("'%s' is not a valid branch or pattern"), arg);
++ for (i = 0; i < nr; i++) {
++ const struct upstream_pattern *p = &patterns[i];
++ if (p->is_wildcard) {
++ if (!wildmatch(p->name, short_upstream, WM_PATHNAME))
++ return 1;
++ } else if (!strcmp(p->name, short_upstream)) {
++ return 1;
++ }
+ }
++ return 0;
+}
+
+struct forked_cb {
-+ const struct string_list *upstream_patterns;
++ const struct upstream_pattern *patterns;
++ size_t nr_patterns;
+ struct string_list *out;
+};
+
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+{
+ struct forked_cb *cb = cb_data;
+ struct branch *branch;
-+ const char *upstream, *short_upstream;
-+ const struct string_list_item *item;
++ const char *upstream;
+
+ if (ref->flags & REF_ISSYMREF)
+ return 0;
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream)
+ return 0;
-+ short_upstream = upstream;
-+ (void)(skip_prefix(short_upstream, "refs/heads/", &short_upstream) ||
-+ skip_prefix(short_upstream, "refs/remotes/", &short_upstream));
-+
-+ for_each_string_list_item(item, cb->upstream_patterns)
-+ if (!wildmatch(item->string, short_upstream, WM_PATHNAME)) {
-+ string_list_append(cb->out, ref->name)->util =
-+ xstrdup(upstream);
-+ return 0;
-+ }
++ if (upstream_matches(short_upstream_name(upstream),
++ cb->patterns, cb->nr_patterns))
++ string_list_append(cb->out, ref->name);
+ return 0;
+}
+
-+static void collect_forked_set(int argc, const char **argv,
-+ struct string_list *out)
-+{
-+ struct string_list upstream_patterns = STRING_LIST_INIT_DUP;
-+ struct forked_cb cb = {
-+ .upstream_patterns = &upstream_patterns,
-+ .out = out,
-+ };
-+
-+ parse_forked_args(argc, argv, &upstream_patterns);
-+
-+ refs_for_each_branch_ref(get_main_ref_store(the_repository),
-+ collect_forked_branch, &cb);
-+
-+ string_list_clear(&upstream_patterns, 0);
-+}
-+
+static int list_forked_branches(int argc, const char **argv)
+{
++ struct upstream_pattern *patterns = NULL;
++ size_t nr_patterns = 0;
+ struct string_list out = STRING_LIST_INIT_DUP;
+ struct string_list_item *item;
++ struct forked_cb cb;
+
+ if (!argc)
+ die(_("--forked requires at least one <branch>"));
+
-+ collect_forked_set(argc, argv, &out);
++ parse_forked_args(argc, argv, &patterns, &nr_patterns);
++ cb.patterns = patterns;
++ cb.nr_patterns = nr_patterns;
++ cb.out = &out;
++
++ refs_for_each_branch_ref(get_main_ref_store(the_repository),
++ collect_forked_branch, &cb);
++
++ string_list_sort(&out);
+ for_each_string_list_item(item, &out)
+ puts(item->string);
+
-+ string_list_clear(&out, 1);
++ upstream_pattern_list_clear(patterns, nr_patterns);
++ string_list_clear(&out, 0);
+ return 0;
+}
+
@@ builtin/branch.c: int cmd_branch(int argc,
usage_with_options(builtin_branch_usage, options);
@@ builtin/branch.c: int cmd_branch(int argc,
- if (delete) {
- if (!argc)
die(_("branch name required"));
-- ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
-+ ret = delete_branches(argc, argv, delete > 1, filter.kind,
-+ quiet, 0, NULL);
-+ goto out;
+ ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
+ goto out;
+ } else if (forked) {
+ ret = list_forked_branches(argc, argv);
- goto out;
++ goto out;
} else if (show_current) {
print_current_branch_name();
+ ret = 0;
## t/t3200-branch.sh ##
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ 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 topic-on-main main
++ git -C forked branch --track local-trunk local-base
+'
+
-+test_expect_success '--forked <remote-tracking-branch> lists matching branches' '
++test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '
+ git -C forked branch --forked origin/one >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
-+test_expect_success '--forked <local-branch> lists branches tracking that local branch' '
-+ git -C forked branch --forked main >actual &&
-+ echo topic-on-main >expect &&
-+ test_cmp expect actual
-+'
-+
-+test_expect_success '--forked <glob> matches every upstream under the pattern' '
++test_expect_success '--forked <glob> matches by wildmatch' '
+ git -C forked branch --forked "origin/*" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ test_cmp expect actual
+'
+
++test_expect_success '--forked <local-branch> matches branches with local upstream' '
++ git -C forked branch --forked local-base >actual &&
++ echo local-trunk >expect &&
++ test_cmp expect actual
++'
++
++test_expect_success '--forked <remote> resolves via refs/remotes/<remote>/HEAD' '
++ test_when_finished "git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main" &&
++ git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/one &&
++ git -C forked branch --forked origin >actual &&
++ echo local-one >expect &&
++ test_cmp expect actual
++'
++
+test_expect_success '--forked unions multiple <branch> arguments' '
+ git -C forked branch --forked origin/one other/foreign >actual &&
+ cat >expect <<-\EOF &&
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
-+ git -C forked branch --forked main "other/*" >actual &&
++ git -C forked branch --forked local-base "other/*" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
-+ topic-on-main
++ local-trunk
+ EOF
+ test_cmp expect actual
+'
-: ---------- > 2: b666d09bf5 branch: let delete_branches warn instead of error on bulk refusal
-: ---------- > 3: 6e6580270e branch: prepare delete_branches for a bulk caller
2: 718e28c7e0 ! 4: e7e03c1338 branch: add --prune-merged <branch>
@@ Commit message
git branch --prune-merged <branch>...
- deletes the local branches that --forked <branch> would list,
- but only those whose tip is reachable from their configured
- upstream: the work has already landed on the upstream the
- branch tracks, so the local copy is no longer needed.
+ deletes the local branches that "--forked <branch>" would list,
+ restricted to 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.
- The following branches are always preserved:
+ Reachability is read from the local refs only -- nothing is
+ fetched. Users who want fresh upstream refs run "git fetch" first;
+ the deletion path stays a separate, idempotent step that also
+ works offline.
- * the currently checked-out branch in any worktree;
- * any local branch whose name matches the default branch of
- any configured remote (the target of
- refs/remotes/<remote>/HEAD) -- typically 'main' or
- 'master';
- * any branch whose upstream no longer resolves locally.
+ Three classes of branches are spared:
- Reachability is read from whatever branch.<name>.merge
- resolves to locally, which is usually a remote-tracking ref
- but may also be a local branch. When the upstream is a
- remote-tracking ref, the natural workflow is
+ * any branch checked out in any worktree;
+ * any branch whose upstream no longer resolves locally (its
+ disappearance is not, on its own, evidence of integration);
+ * any branch whose push destination equals its upstream
+ (<branch>@{push} == <branch>@{upstream}). Such a branch
+ cannot be distinguished from a freshly pulled trunk that
+ just looks "fully merged" -- e.g. local "main" tracking and
+ pushing to "origin/main" right after a pull. Only branches
+ that push somewhere other than their upstream (typically
+ topics in a fork-based workflow) are treated as candidates.
- git fetch <remote>
- git branch --prune-merged <upstream-pattern>
-
- so the upstream reflects the current state before pruning.
+ Deletion goes through the existing delete_branches() in warn-only
+ mode and with the HEAD-fallback disabled: a branch that is not
+ yet fully merged to its upstream is reported as a one-line warning
+ and skipped, so a single un-mergeable topic does not abort the
+ whole sweep, and there is no fallback to "merged into the
+ currently checked out branch" -- we only act on upstream-merged
+ status.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.
- a ref (e.g. `origin/master`, `master`) or a shell-style
- glob (e.g. `'origin/*'`). Multiple arguments are unioned.
+@@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`. A bare configured-remote name
+ to match the way `git checkout -b topic origin` picks a starting
+ point. Multiple _<branch>_ arguments are unioned.
+`--prune-merged`::
-+ Delete the local branches that `--forked` would list for
-+ the same _<branch>_ arguments, but only those whose tip is
-+ reachable from their configured upstream.
++ Delete the local branches that `--forked` would list for the
++ same _<branch>_ 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.
++
-+For arguments that refer to remote-tracking branches, run
-+`git fetch` first so reachability is checked against the
-+current upstream state; refs are read locally.
++Reachability is checked against whatever the upstream refs say
++locally; nothing is fetched. Run `git fetch` first if you want
++the upstream refs refreshed.
++
-+The following branches are always preserved:
++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 (`<branch>@{push}`) equals its
++upstream (`<branch>@{upstream}`), so it cannot be distinguished
++from a freshly pulled trunk that just looks "fully merged".
++
-+--
-+* the currently checked-out branch in any worktree;
-+* any local branch whose name matches the default branch of
-+ any configured remote (the target of
-+ `refs/remotes/<remote>/HEAD`) -- typically `main` or
-+ `master`;
-+* any branch whose upstream no longer resolves locally.
-+--
++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`::
## builtin/branch.c ##
-@@
- #include "branch.h"
- #include "path.h"
- #include "string-list.h"
-+#include "strvec.h"
- #include "column.h"
- #include "utf8.h"
- #include "ref-filter.h"
-@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
- N_("git branch [<options>] [-r | -a] [--points-at]"),
- N_("git branch [<options>] [-r | -a] [--format]"),
- N_("git branch [<options>] --forked <branch>..."),
-+ N_("git branch [<options>] --prune-merged <branch>..."),
- NULL
- };
-
-@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
- * any of the following code, but during the transition period,
- * a gentle reminder is in order.
- */
-- 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)
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)
return 0;
}
-+static int collect_default_branch_name(struct remote *remote, void *cb_data)
-+{
-+ struct string_list *protected = cb_data;
-+ struct ref_store *refs = get_main_ref_store(the_repository);
-+ struct strbuf head = STRBUF_INIT;
-+ const char *target;
-+
-+ strbuf_addf(&head, "refs/remotes/%s/HEAD", remote->name);
-+ target = refs_resolve_ref_unsafe(refs, head.buf,
-+ RESOLVE_REF_NO_RECURSE, NULL, NULL);
-+ if (target) {
-+ const char *leaf = strrchr(target, '/');
-+ if (leaf)
-+ string_list_insert(protected, leaf + 1);
-+ }
-+ strbuf_release(&head);
-+ return 0;
+-static int list_forked_branches(int argc, const char **argv)
++static void collect_forked_set(int argc, const char **argv,
++ struct string_list *out)
+ {
+ struct upstream_pattern *patterns = NULL;
+ size_t nr_patterns = 0;
+- struct string_list out = STRING_LIST_INIT_DUP;
+- struct string_list_item *item;
+ struct forked_cb cb;
+
+- if (!argc)
+- die(_("--forked requires at least one <branch>"));
+-
+ parse_forked_args(argc, argv, &patterns, &nr_patterns);
+ cb.patterns = patterns;
+ cb.nr_patterns = nr_patterns;
+- cb.out = &out;
++ cb.out = out;
+
+ refs_for_each_branch_ref(get_main_ref_store(the_repository),
+ collect_forked_branch, &cb);
+
+- string_list_sort(&out);
++ string_list_sort(out);
++
++ upstream_pattern_list_clear(patterns, nr_patterns);
+}
+
- static void collect_forked_set(int argc, const char **argv,
- struct string_list *out)
- {
-@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
++static int list_forked_branches(int argc, const char **argv)
++{
++ struct string_list out = STRING_LIST_INIT_DUP;
++ struct string_list_item *item;
++
++ if (!argc)
++ die(_("--forked requires at least one <branch>"));
++
++ collect_forked_set(argc, argv, &out);
+ for_each_string_list_item(item, &out)
+ puts(item->string);
+
+- upstream_pattern_list_clear(patterns, nr_patterns);
+ string_list_clear(&out, 0);
return 0;
}
@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct string_list candidates = STRING_LIST_INIT_DUP;
-+ struct string_list protected_default_names = STRING_LIST_INIT_DUP;
+ struct strvec deletable = STRVEC_INIT;
-+ struct strbuf buf = STRBUF_INIT;
+ struct string_list_item *item;
-+ int n_not_merged = 0;
+ int ret = 0;
+
+ if (!argc)
+ die(_("--prune-merged requires at least one <branch>"));
+
+ collect_forked_set(argc, argv, &candidates);
-+ for_each_remote(collect_default_branch_name, &protected_default_names);
+
+ for_each_string_list_item(item, &candidates) {
+ const char *short_name = item->string;
-+ const char *upstream = item->util;
-+
-+ strbuf_reset(&buf);
-+ strbuf_addf(&buf, "refs/heads/%s", short_name);
-+ if (branch_checked_out(buf.buf))
++ struct branch *branch = branch_get(short_name);
++ const char *upstream, *push;
++ struct strbuf full = STRBUF_INIT;
++ int skip;
++
++ strbuf_addf(&full, "refs/heads/%s", short_name);
++ skip = !!branch_checked_out(full.buf);
++ strbuf_release(&full);
++ if (skip)
+ continue;
+
-+ if (string_list_has_string(&protected_default_names,
-+ short_name))
++ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
++ if (!upstream || !refs_ref_exists(refs, upstream))
+ continue;
-+
-+ if (!refs_ref_exists(refs, upstream))
++ push = branch ? branch_get_push(branch, NULL) : NULL;
++ if (!push || !strcmp(push, upstream))
+ continue;
+
+ strvec_push(&deletable, short_name);
+ }
-+ strbuf_release(&buf);
+
+ if (deletable.nr)
+ ret = delete_branches(deletable.nr, deletable.v,
-+ 0, FILTER_REFS_BRANCHES, quiet,
-+ 1, &n_not_merged);
-+
-+ if (n_not_merged && !quiet)
-+ fprintf(stderr,
-+ Q_("Skipped %d branch that is not fully merged; "
-+ "delete it with 'git branch -D' if you are sure.\n",
-+ "Skipped %d branches that are not fully merged; "
-+ "delete them with 'git branch -D' if you are sure.\n",
-+ n_not_merged),
-+ n_not_merged);
++ 0, /* force */
++ FILTER_REFS_BRANCHES,
++ quiet,
++ 1, /* warn_only */
++ 1, /* no_head_fallback */
++ 0 /* dry_run */);
+
+ strvec_clear(&deletable);
-+ string_list_clear(&candidates, 1);
-+ string_list_clear(&protected_default_names, 0);
++ string_list_clear(&candidates, 0);
+ return ret;
+}
+
@@ builtin/branch.c: int cmd_branch(int argc,
OPT_BOOL(0, "forked", &forked,
N_("list local branches whose upstream matches the given <branch>...")),
+ OPT_BOOL(0, "prune-merged", &prune_merged,
-+ N_("delete local branches whose upstream matches the given <branch>... and that are merged into it")),
++ N_("delete local branches whose upstream matches the given <branch>... 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")),
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
+ 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
++ 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 &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
+'
+
-+test_expect_success '--prune-merged with a literal upstream argument' '
++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 keepme one-commit &&
-+ git -C pm-literal branch --set-upstream-to=origin/main keepme &&
+
+ git -C pm-literal branch --prune-merged origin/next &&
+
-+ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one &&
-+ git -C pm-literal rev-parse --verify refs/heads/keepme
++ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged unions multiple <branch> arguments' '
+ test_when_finished "rm -rf pm-union" &&
+ git clone pm-upstream pm-union &&
++ git -C pm-union remote add fork ../pm-fork &&
++ test_config -C pm-union remote.pushDefault fork &&
++ test_config -C pm-union push.default current &&
+ git -C pm-union branch one one-commit &&
+ git -C pm-union branch --set-upstream-to=origin/next one &&
+ git -C pm-union branch two base &&
+ git -C pm-union branch --set-upstream-to=origin/main two &&
++ git -C pm-union checkout --detach &&
+
+ git -C pm-union branch --prune-merged origin/next origin/main &&
+
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
+'
+
-+test_expect_success '--prune-merged with a local-branch argument' '
-+ test_create_repo pm-local &&
++test_expect_success '--prune-merged accepts a local upstream' '
+ test_when_finished "rm -rf pm-local" &&
-+ test_commit -C pm-local base &&
-+ git -C pm-local branch topic base &&
-+ git -C pm-local config branch.topic.remote . &&
-+ git -C pm-local config branch.topic.merge refs/heads/main &&
-+ git -C pm-local checkout --detach &&
-+
-+ git -C pm-local branch --prune-merged main &&
-+
-+ test_must_fail git -C pm-local rev-parse --verify refs/heads/topic &&
-+ git -C pm-local rev-parse --verify refs/heads/main
++ 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 spares branches with un-integrated commits' '
++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 &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
+
+ git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
+ test_grep "not fully merged" err &&
-+ test_grep "Skipped 1 branch" err &&
-+ test_grep "git branch -D" 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 &&
+
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
+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 &&
+
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
+ git -C pm-head rev-parse --verify refs/heads/one
+'
+
-+test_expect_success '--prune-merged spares the local default branch' '
-+ test_when_finished "rm -rf pm-default" &&
-+ git clone pm-upstream pm-default &&
-+ git -C pm-default checkout --detach &&
-+ git -C pm-default branch --prune-merged "origin/*" &&
-+ git -C pm-default rev-parse --verify refs/heads/main
++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 protects the default branch by name only' '
-+ test_when_finished "rm -rf pm-default-alias" &&
-+ git clone pm-upstream pm-default-alias &&
-+ git -C pm-default-alias branch --track trunk origin/main &&
-+ git -C pm-default-alias checkout --detach &&
-+ git -C pm-default-alias branch --prune-merged "origin/*" &&
-+ git -C pm-default-alias rev-parse --verify refs/heads/main &&
-+ test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
++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 with literal arg also protects default-name' '
-+ test_when_finished "rm -rf pm-literal-default" &&
-+ git clone pm-upstream pm-literal-default &&
-+ git -C pm-literal-default checkout --detach &&
-+ git -C pm-literal-default branch --prune-merged origin/main &&
-+ git -C pm-literal-default 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 <branch>' '
-+ test_must_fail git -C pm-upstream branch --prune-merged 2>err &&
++ test_must_fail git -C forked branch --prune-merged 2>err &&
+ test_grep "at least one <branch>" err
+'
+
3: 6e38d7af3a ! 5: 75b6d2366a branch: add branch.<name>.pruneMerged opt-out
@@ Metadata
## Commit message ##
branch: add branch.<name>.pruneMerged opt-out
- Setting branch.<name>.pruneMerged=false exempts that branch
- from --prune-merged. Useful for topic branches you intend to
- develop further after an initial round has been merged
+ Setting branch.<name>.pruneMerged=false exempts that branch from
+ "git branch --prune-merged". Useful for a topic branch you want
+ to develop further after an initial round has been merged
upstream.
- Explicit deletion via 'git branch -d' is unaffected.
+ Unless --quiet is given, the skip is reported per branch so the
+ user knows why their topic was preserved.
+
+ Explicit deletion via "git branch -d" continues to consult the
+ normal merge check and is not affected by this setting.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/config/branch.adoc: for details).
+
+`branch.<name>.pruneMerged`::
+ If set to `false`, branch _<name>_ is exempt from
-+ `git branch --prune-merged`. Defaults to true. Explicit
-+ deletion via `git branch -d` is unaffected.
++ `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.
## Documentation/git-branch.adoc ##
-@@ Documentation/git-branch.adoc: The following branches are always preserved:
- any configured remote (the target of
- `refs/remotes/<remote>/HEAD`) -- typically `main` or
- `master`;
-+* any branch with `branch.<name>.pruneMerged` set to `false`;
- * any branch whose upstream no longer resolves locally.
- --
-
+@@ Documentation/git-branch.adoc: 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 (`<branch>@{push}`) equals its
++worktree; its push destination (`<branch>@{push}`) equals its
+ upstream (`<branch>@{upstream}`), so it cannot be distinguished
+-from a freshly pulled trunk that just looks "fully merged".
++from a freshly pulled trunk that just looks "fully merged"; or
++`branch.<name>.pruneMerged` is set to `false`.
+ +
+ Branches refused by the "fully merged" safety check are listed as
+ warnings and skipped; pass them to `git branch -D` explicitly if
## builtin/branch.c ##
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
- for_each_string_list_item(item, &candidates) {
- const char *short_name = item->string;
- const char *upstream = item->util;
-+ int prune_allowed = 1;
+ struct branch *branch = branch_get(short_name);
+ const char *upstream, *push;
+ struct strbuf full = STRBUF_INIT;
++ struct strbuf key = STRBUF_INIT;
+ int skip;
++ int opt_out;
- strbuf_reset(&buf);
- strbuf_addf(&buf, "refs/heads/%s", short_name);
+ strbuf_addf(&full, "refs/heads/%s", short_name);
+ skip = !!branch_checked_out(full.buf);
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
- if (!refs_ref_exists(refs, upstream))
+ if (!push || !strcmp(push, upstream))
continue;
-+ strbuf_reset(&buf);
-+ strbuf_addf(&buf, "branch.%s.prunemerged", short_name);
-+ if (!repo_config_get_bool(the_repository, buf.buf,
-+ &prune_allowed) &&
-+ !prune_allowed) {
++ 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"),
++ 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);
}
- strbuf_release(&buf);
+
## t/t3200-branch.sh ##
@@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <branch>' '
@@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <br
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
+ test_when_finished "rm -rf pm-optout" &&
+ git clone pm-upstream pm-optout &&
++ git -C pm-optout remote add fork ../pm-fork &&
++ test_config -C pm-optout remote.pushDefault fork &&
++ test_config -C pm-optout push.default current &&
+ git -C pm-optout branch one one-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next one &&
+ git -C pm-optout branch two two-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next two &&
-+ git -C pm-optout config branch.one.pruneMerged false &&
++ test_config -C pm-optout branch.one.pruneMerged false &&
+
+ git -C pm-optout branch --prune-merged "origin/*" 2>err &&
+
@@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <br
+ 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 &&
-+ git -C pm-optout-d config branch.one.pruneMerged false &&
++ 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
4: c68d162e22 ! 6: a1a42a6b19 branch: add --dry-run for --prune-merged
@@ Metadata
## Commit message ##
branch: add --dry-run for --prune-merged
- With --dry-run, --prune-merged prints the branches it would
- delete and exits without touching any ref. Useful for
- sanity-checking a glob like 'origin/*' before letting it run.
+ With --dry-run, --prune-merged prints the local branches it would
+ delete -- one "Would delete branch <name>" line per candidate --
+ and exits without touching any ref.
+
+ This is the natural sanity check before letting a broad pattern
+ like 'origin/*' run for real: the @{push}-vs-@{upstream} and
+ unmerged filtering still applies, so the dry-run output is
+ exactly the set that the live run would delete.
+
+ --dry-run is only meaningful in combination with --prune-merged
+ and is rejected otherwise.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: The following branches are always preserved:
- * any branch whose upstream no longer resolves locally.
- --
+@@ Documentation/git-branch.adoc: 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 the branches that would be
-+ deleted instead of deleting them.
++ 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`::
## builtin/branch.c ##
-@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
- N_("git branch [<options>] [-r | -a] [--points-at]"),
- N_("git branch [<options>] [-r | -a] [--format]"),
- N_("git branch [<options>] --forked <branch>..."),
-- N_("git branch [<options>] --prune-merged <branch>..."),
-+ N_("git branch [<options>] --prune-merged [--dry-run] <branch>..."),
- NULL
- };
-
-@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
- }
-
- static int delete_branches(int argc, const char **argv, int force, int kinds,
-- int quiet, int warn_only, int *n_not_merged)
-+ int quiet, int warn_only, int dry_run,
-+ int *n_not_merged)
- {
- struct commit *head_rev = NULL;
- struct object_id oid;
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
- goto next;
- }
-
-+ if (dry_run) {
-+ printf(_("Would delete branch '%s'\n"),
-+ name + branch_name_pos);
-+ goto next;
-+ }
-+
- item = string_list_append(&refs_to_delete, name);
- item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
- : (flags & REF_ISSYMREF) ? target
@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
return 0;
}
-static int prune_merged_branches(int argc, const char **argv, int quiet)
-+static int prune_merged_branches(int argc, const char **argv,
-+ int dry_run, int quiet)
++static int prune_merged_branches(int argc, const char **argv, int quiet,
++ int dry_run)
{
struct ref_store *refs = get_main_ref_store(the_repository);
struct string_list candidates = STRING_LIST_INIT_DUP;
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
- if (deletable.nr)
- ret = delete_branches(deletable.nr, deletable.v,
- 0, FILTER_REFS_BRANCHES, quiet,
-- 1, &n_not_merged);
-+ 1, dry_run, &n_not_merged);
+ quiet,
+ 1, /* warn_only */
+ 1, /* no_head_fallback */
+- 0 /* dry_run */);
++ dry_run);
- if (n_not_merged && !quiet)
- fprintf(stderr,
+ strvec_clear(&deletable);
+ string_list_clear(&candidates, 0);
@@ builtin/branch.c: int cmd_branch(int argc,
unset_upstream = 0, show_current = 0, edit_description = 0;
int forked = 0;
@@ builtin/branch.c: int cmd_branch(int argc,
@@ builtin/branch.c: int cmd_branch(int argc,
N_("list local branches whose upstream matches the given <branch>...")),
OPT_BOOL(0, "prune-merged", &prune_merged,
- N_("delete local branches whose upstream matches the given <branch>... and that are merged into it")),
+ N_("delete local branches whose upstream matches the given <branch>... and is merged")),
+ OPT_BOOL(0, "dry-run", &dry_run,
-+ N_("with --prune-merged, only print what would be deleted")),
++ 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")),
@@ builtin/branch.c: int cmd_branch(int argc,
- argc = parse_options(argc, argv, prefix, options, builtin_branch_usage,
- 0);
+ if (noncreate_actions > 1)
+ usage_with_options(builtin_branch_usage, options);
+ if (dry_run && !prune_merged)
+ die(_("--dry-run requires --prune-merged"));
+
- if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && !forked && !prune_merged &&
- argc == 0)
+ if (recurse_submodules_explicit) {
+ if (!submodule_propagate_branches)
+ die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
@@ builtin/branch.c: int cmd_branch(int argc,
- if (!argc)
- die(_("branch name required"));
- ret = delete_branches(argc, argv, delete > 1, filter.kind,
-- quiet, 0, NULL);
-+ quiet, 0, 0, NULL);
- goto out;
- } else if (forked) {
ret = list_forked_branches(argc, argv);
goto out;
} else if (prune_merged) {
- ret = prune_merged_branches(argc, argv, quiet);
-+ ret = prune_merged_branches(argc, argv, dry_run, quiet);
++ ret = prune_merged_branches(argc, argv, quiet, dry_run);
goto out;
} else if (show_current) {
print_current_branch_name();
@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
'
-+test_expect_success '--prune-merged --dry-run prints but does not delete' '
-+ test_when_finished "rm -rf pm-dryrun" &&
-+ git clone pm-upstream pm-dryrun &&
-+ git -C pm-dryrun branch one one-commit &&
-+ git -C pm-dryrun branch --set-upstream-to=origin/next 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 --prune-merged --dry-run "origin/*" >actual &&
++ test_grep "Would delete branch one " actual &&
++ test_grep "Would delete branch two " actual &&
+
-+ git -C pm-dryrun branch --prune-merged --dry-run "origin/*" >out &&
-+ test_grep "Would delete branch .one." out &&
-+ git -C pm-dryrun rev-parse --verify refs/heads/one
++ 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 skips un-integrated branches' '
-+ test_when_finished "rm -rf pm-dryrun-unmerged" &&
-+ git clone pm-upstream pm-dryrun-unmerged &&
-+ git -C pm-dryrun-unmerged checkout -b wip origin/next &&
-+ git -C pm-dryrun-unmerged branch --set-upstream-to=origin/next wip &&
-+ test_commit -C pm-dryrun-unmerged local-only &&
-+ git -C pm-dryrun-unmerged checkout - &&
-+ git -C pm-dryrun-unmerged branch merged one-commit &&
-+ git -C pm-dryrun-unmerged branch --set-upstream-to=origin/next merged &&
++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-dryrun-unmerged branch --prune-merged --dry-run "origin/*" \
-+ >out 2>err &&
-+ test_grep "Would delete branch .merged." out &&
-+ test_grep ! "Would delete branch .wip." out &&
-+ test_grep "not fully merged" err &&
-+ git -C pm-dryrun-unmerged rev-parse --verify refs/heads/wip &&
-+ git -C pm-dryrun-unmerged rev-parse --verify refs/heads/merged
++ git -C pm-dry-mixed branch --prune-merged --dry-run "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 requires --prune-merged' '
-+ test_must_fail git -C pm-upstream branch --dry-run 2>err &&
++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
+'
+
--
gitgitgadget
next prev parent reply other threads:[~2026-05-22 11:31 UTC|newest]
Thread overview: 102+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-05-01 21:35 [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren via GitGitGadget
2026-05-03 22:39 ` Junio C Hamano
2026-05-04 18:28 ` [PATCH] checkout: add --autostash option for branch switching Harald Nordgren
2026-05-10 1:01 ` Junio C Hamano
2026-05-05 7:14 ` [PATCH] fetch: add fetch.pruneLocalBranches config Johannes Sixt
2026-05-04 18:27 ` [PATCH v2 0/6] fetch: add fetch.pruneBranches config Harald Nordgren via GitGitGadget
2026-05-04 18:27 ` [PATCH v2 1/6] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-04 23:25 ` Kristoffer Haugsbakk
2026-05-04 18:27 ` [PATCH v2 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-04 18:27 ` [PATCH v2 3/6] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-04 18:27 ` [PATCH v2 4/6] fetch: add --prune-merged Harald Nordgren via GitGitGadget
2026-05-04 18:27 ` [PATCH v2 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-04 18:27 ` [PATCH v2 6/6] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 0/6] fetch: add fetch.pruneBranches config Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 1/6] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 3/6] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 4/6] fetch: add --prune-merged Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 6/6] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-05 19:23 ` [PATCH v4 0/6] fetch: add fetch.pruneBranches config Harald Nordgren via GitGitGadget
2026-05-05 19:23 ` [PATCH v4 1/6] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-05 19:23 ` [PATCH v4 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-05 19:23 ` [PATCH v4 3/6] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-05 19:23 ` [PATCH v4 4/6] fetch: add --prune-merged Harald Nordgren via GitGitGadget
2026-05-05 20:48 ` Johannes Sixt
2026-05-05 22:07 ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-11 2:59 ` Junio C Hamano
2026-05-11 6:56 ` Harald Nordgren
2026-05-05 19:23 ` [PATCH v4 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-05 19:23 ` [PATCH v4 6/6] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-07 20:14 ` [PATCH v4 0/6] fetch: add fetch.pruneBranches config Harald Nordgren
2026-05-11 6:58 ` [PATCH v5 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-11 6:58 ` [PATCH v5 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-11 6:58 ` [PATCH v5 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-11 8:18 ` Junio C Hamano
2026-05-11 8:44 ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-11 6:58 ` [PATCH v5 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-11 6:58 ` [PATCH v5 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-11 6:58 ` [PATCH v5 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-11 9:44 ` [PATCH v6 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-11 9:44 ` [PATCH v6 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-11 9:44 ` [PATCH v6 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-11 9:44 ` [PATCH v6 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-11 9:44 ` [PATCH v6 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-11 9:44 ` [PATCH v6 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-11 23:20 ` [PATCH v6 0/5] branch: prune-merged Junio C Hamano
2026-05-12 7:35 ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-12 8:23 ` [PATCH v7 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-12 8:23 ` [PATCH v7 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-12 8:23 ` [PATCH v7 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-12 8:23 ` [PATCH v7 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-12 13:53 ` Junio C Hamano
2026-05-12 17:00 ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-12 8:23 ` [PATCH v7 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-12 8:23 ` [PATCH v7 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-12 17:07 ` [PATCH v8 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-12 17:07 ` [PATCH v8 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-12 17:07 ` [PATCH v8 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-12 17:07 ` [PATCH v8 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-12 17:07 ` [PATCH v8 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-12 17:07 ` [PATCH v8 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-13 13:46 ` [PATCH v8 0/5] branch: prune-merged Junio C Hamano
2026-05-13 18:57 ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-13 19:34 ` [PATCH v9 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-13 19:34 ` [PATCH v9 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-13 19:34 ` [PATCH v9 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-13 19:34 ` [PATCH v9 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-18 15:27 ` Phillip Wood
2026-05-21 9:46 ` Phillip Wood
2026-05-21 19:16 ` Harald Nordgren
2026-05-22 9:47 ` Phillip Wood
2026-05-22 10:51 ` Harald Nordgren
2026-05-21 12:37 ` Harald Nordgren
2026-05-21 13:29 ` Junio C Hamano
2026-05-13 19:34 ` [PATCH v9 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-13 19:34 ` [PATCH v9 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-18 15:27 ` Phillip Wood
2026-05-18 8:14 ` [PATCH v9 0/5] branch: prune-merged Harald Nordgren
2026-05-21 22:40 ` [PATCH v10 0/4] " Harald Nordgren via GitGitGadget
2026-05-21 22:40 ` [PATCH v10 1/4] branch: add --forked <branch> Harald Nordgren via GitGitGadget
2026-05-22 1:52 ` Junio C Hamano
2026-05-22 6:18 ` Johannes Sixt
2026-05-22 6:36 ` Junio C Hamano
2026-05-22 10:49 ` Harald Nordgren
2026-05-22 11:25 ` Johannes Sixt
2026-05-21 22:40 ` [PATCH v10 2/4] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
2026-05-22 1:17 ` Junio C Hamano
2026-05-22 2:51 ` Junio C Hamano
2026-05-22 2:53 ` Junio C Hamano
2026-05-22 7:59 ` Harald Nordgren
2026-05-22 11:58 ` Junio C Hamano
2026-05-22 2:52 ` Junio C Hamano
2026-05-21 22:40 ` [PATCH v10 3/4] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-21 22:40 ` [PATCH v10 4/4] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` Harald Nordgren via GitGitGadget [this message]
2026-05-22 11:31 ` [PATCH v11 1/6] branch: add --forked <branch> Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 3/6] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 6/6] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com \
--to=gitgitgadget@gmail.com \
--cc=git@vger.kernel.org \
--cc=haraldnordgren@gmail.com \
--cc=j6t@kdbg.org \
--cc=kristofferhaugsbakk@fastmail.com \
--cc=phillip.wood123@gmail.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox