Subject: [PATCH v10 4/4] branch: add --dry-run for --prune-merged

From: "Harald Nordgren via GitGitGadget" <[email-masked]>
Date: Thu, 21 May 2026 22:40:04 +0000

From: Harald Nordgren <[email-masked]>

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.

Signed-off-by: Harald Nordgren <[email-masked]>
---
 Documentation/git-branch.adoc |  6 +++++-
 builtin/branch.c              | 26 ++++++++++++++++++++------
 t/t3200-branch.sh             | 35 +++++++++++++++++++++++++++++++++++
 3 files changed, 60 insertions(+), 7 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 247e4daeb8..349fbfc420 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>
 git branch (-d|-D) [-r] <branch-name>...
 git branch --edit-description [<branch-name>]
 git branch --forked <branch>...
-git branch --prune-merged <branch>...
+git branch --prune-merged [--dry-run] <branch>...
 
 DESCRIPTION
 -----------
@@ -228,6 +228,10 @@ The following branches are always preserved:
 * any branch whose upstream no longer resolves locally.
 --
 
+`--dry-run`::
+	With `--prune-merged`, print the branches that would be
+	deleted instead of deleting them.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index f995f257f0..b89fd56112 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -41,7 +41,7 @@ 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
 };
 
@@ -231,7 +231,8 @@ static void delete_branch_config(const char *branchname)
 }
 
 static int delete_branches(int argc, const char **argv, int force, int kinds,
-			   int quiet, int 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;
@@ -327,6 +328,12 @@ 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
@@ -802,7 +809,8 @@ 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)
 {
 	struct ref_store *refs = get_main_ref_store(the_repository);
 	struct string_list candidates = STRING_LIST_INIT_DUP;
@@ -855,7 +863,7 @@ 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);
 
 	if (n_not_merged && !quiet)
 		fprintf(stderr,
@@ -915,6 +923,7 @@ int cmd_branch(int argc,
 	    unset_upstream = 0, show_current = 0, edit_description = 0;
 	int forked = 0;
 	int prune_merged = 0;
+	int dry_run = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -972,6 +981,8 @@ 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")),
+		OPT_BOOL(0, "dry-run", &dry_run,
+			N_("with --prune-merged, only print what 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")),
@@ -1015,6 +1026,9 @@ int cmd_branch(int argc,
 	argc = parse_options(argc, argv, prefix, options, builtin_branch_usage,
 			     0);
 
+	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)
@@ -1065,13 +1079,13 @@ 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);
 		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index b35189ce84..908b184e81 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1964,4 +1964,39 @@ 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 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 &&
+
+	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
+'
+
+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 &&
+
+	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
+'
+
+test_expect_success '--dry-run requires --prune-merged' '
+	test_must_fail git -C pm-upstream branch --dry-run 2>err &&
+	test_grep "requires --prune-merged" err
+'
+
 test_done
-- 
gitgitgadget