fetch: add fetch.pruneLocalBranches config
v18 cover branch: delete-merged
In reply to Harald Nordgren via GitGitGadgetDelete branches that have already been merged on upstream.
Changes in v18:
* Instead of keeping the whole chain of upstream branches, keep only the ones an unmerged branch still needs. When a kept (merged) branch in turn tracks a branch that is being deleted, clear its now-stale upstream config.
* Rework spare_stacked_bases() to record the kept bases and, in a second pass, clear the upstream of any whose own base is going away. Build the to-delete list with strset_for_each_entry() instead of re-walking the candidate array.
Changes in v17:
* Keep a merged branch when another surviving branch still tracks it as its upstream, so --delete-merged no longer deletes a branch out from under one stacked on top of it.
* Move the --dry-run and branch.<name>.deleteMerged opt-out fully into their own commits.
Changes in v16:
* Convert delete_merged_branches() to take an unsigned int flags argument instead of separate quiet/dry_run booleans, matching delete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_reset per iteration, single strbuf_release after) instead of allocating and freeing it each time
* Rewrite the --delete-merged tests as integration tests: branches that land commits upstream, with deletion and the checked-out, upstream-gone, and push-equals-upstream safety cases exercised together in one run and output asserted via test_cmp
* Collapse the many per-aspect test repos into a single reused repo set up by a setup_repo_for_delete_merged helper, and rename helpers off the old pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of prefixing every command with -C
Changes in v15:
* Renamed --prune-merged to --delete-merged throughout. Not necessarily final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of warning.
* Initialized the delete_branches() flag locals where declared. Only force stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f, --forked coverage), renamed the misleading trunk fixture, and replaced the misnamed detached branch with git checkout --detach.
Changes in v14:
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the remotes path set a local force but not the DELETE_BRANCH_FORCE bit that check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other branches too, hopefully not related
(https://github.com/git/git/pull/2285).
Changes in v13:
* Reworked --forked into a real ref-filter applied in apply_ref_filter() instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the abbreviated upstream), and dropped the old helper machinery, forward declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git branch --prune-merged origin/main 'feature*') instead of repeating the option.
Changes in v12:
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST options.
* Dropped the bare-remote-name resolution for --forked, the argument is now a ref or a glob.
Changes in v11:
* 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 /HEAD by name guard that the reviewer rejected is gone.
* New --dry-run for --prune-merged.
Changes in v10:
* --forked / --prune-merged now take a branch glob instead of a remote name — origin, origin/*, origin/release-- all work. This replaces the remote-only form and subsumes the old --all-remotes flag, which has been dropped.
* New --dry-run for --prune-merged.
Changes in v9:
* --force no longer has special meaning with --prune-merged; reachability is always enforced. Use git branch -D to delete an unmerged branch. Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.
Changes in v8:
* Delete only when the branch's work is actually reachable from its upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place
Changes in v7:
* --prune-merged now checks if a branch is merged into its own upstream first. If the upstream is gone, it checks against the remote's default branch instead. If neither exists, the branch is refused (use --force to delete anyway).
Changes in v6:
* --prune-merged now measures merged-ness against the remote's default branch instead of the candidate's upstream — so the decision no longer depends on which branch happens to be checked out locally.
* delete_branches() / check_branch_commit() gained a per-candidate override that lets a caller substitute a different "what counts as merged" reference (or skip the check). branch -d callers pass NULL and keep their existing semantics.
* prune_merged_branches() resolves each candidate's push-remote HEAD and threads it through, so --prune-merged --all-remotes measures each candidate against its own remote rather than a single global reference.
Changes in v5:
* Drop commit 'fetch: add --prune-merged'
Changes in v4:
* Resolve each remote's HEAD and collect the targets into a protected_default_refs set in collect_forked_set.
* In prune_merged_branches, skip a candidate when its upstream is a protected default ref and the local branch name matches the default branch's leaf name (so a local main tracking origin/main is spared, but a renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name (not by upstream alone); spare a branch whose push ref is the remote default.
Changes in v3:
* s/remote-tracking refs/remote-tracking branches/g
Changes in v2:
* The whole feature moved out of git fetch and into git branch. git fetch --prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their tip is reachable from the upstream tracking ref; --force skips that safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running topic branch) even with --force; doesn't affect explicit git branch -d.
* delete_branches() got a warn_only mode so bulk deletion prints a one-line warning per skipped branch instead of the noisy four-line hint that git branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention --prune-merged.
* New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh shrunk since most logic moved.
Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <branch>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged
Documentation/config/branch.adoc | 7 + Documentation/git-branch.adoc | 48 ++++- builtin/branch.c | 266 +++++++++++++++++++++--- ref-filter.c | 70 +++++++ ref-filter.h | 10 + t/t3200-branch.sh | 342 +++++++++++++++++++++++++++++++ 6 files changed, 715 insertions(+), 28 deletions(-)
base-commit: ab776a62a78576513ee121424adb19597fbb7613
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v18
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v18
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v17:
1: d8cc17bd7f = 1: 3e29ff17bd branch: add --forked filter for --list mode
2: d14b0403f0 = 2: cdd4fea4a7 branch: convert delete_branches() to a flags argument
3: ef2719dac3 = 3: a0fd5b4a6c branch: let delete_branches skip unmerged branches on bulk refusal
4: 80518f5d11 = 4: a56d8fe93e branch: prepare delete_branches for a bulk caller
5: 46da7c8140 ! 5: a84c555d99 branch: add --delete-merged <branch>
@@ Commit message
upstream. The work has already landed on the upstream they track,
so the local copy is no longer needed.
- Three kinds of branches are not deleted:
+ A branch is not deleted when:
- * any branch checked out in any worktree
- * any branch whose upstream remote-tracking branch no longer
- exists, since a missing upstream is not by itself a sign of
- integration
- * any branch whose push destination equals its upstream
- (<branch>@{push} is the same as <branch>@{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 kept. Only
- branches that push somewhere other than their upstream,
- typically topics in a fork workflow, are candidates.
+ * it is checked out in any worktree
+ * its upstream remote-tracking branch no longer exists, since a
+ missing upstream is not by itself a sign of integration
+ * its push destination equals its upstream (<branch>@{push} is
+ the same as <branch>@{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 kept. Only branches that push
+ somewhere other than their upstream, typically topics in a fork
+ workflow, are candidates.
A branch whose work is not yet merged into its upstream is silently
skipped, so one unmerged topic does not abort the whole sweep.
A branch that another, surviving branch tracks as its upstream is
also kept, so a branch is never deleted out from under one stacked
- on top of it. Sparing such a base can in turn protect its own
- upstream, so the check repeats until the set stops changing.
+ on top of it. Such a kept branch is itself merged, so when its own
+ upstream is being deleted, clear its now-stale upstream config.
Signed-off-by: Harald Nordgren <[email protected]>
@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
+silently skipped. Delete it with `git branch -D` if you want to
+remove it anyway.
++
-+A branch that another, surviving branch still tracks as its upstream
-+is kept, so a branch is never deleted out from under one stacked on
-+top of it.
++A branch that another, surviving branch tracks as its upstream is
++kept, so a branch is never deleted out from under one stacked on top
++of it. If that kept branch in turn tracks a branch that is being
++deleted, its now-stale upstream configuration is cleared.
+
`-v`::
`-vv`::
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
return 0;
}
-+static int collect_upstream(const struct reference *ref, void *cb_data)
-+{
-+ struct string_list *upstreams = cb_data;
-+ struct branch *branch = branch_get(ref->name);
-+ const char *upstream = branch_get_upstream(branch, NULL);
++struct spare_data {
++ struct strset *deletable;
++ struct strset *spared;
++};
+
-+ string_list_append(upstreams, ref->name)->util =
-+ xstrdup_or_null(upstream);
++/*
++ * A surviving branch stacked on a deletion candidate would lose its
++ * upstream, so drop that candidate from the delete set and remember it
++ * in "spared" so its own upstream can be tidied up afterwards.
++ */
++static int spare_stacked_base(const struct reference *ref, void *cb_data)
++{
++ struct spare_data *data = cb_data;
++ struct branch *branch;
++ const char *upstream, *up_short;
++
++ if (strset_contains(data->deletable, ref->name))
++ return 0;
++ branch = branch_get(ref->name);
++ upstream = branch_get_upstream(branch, NULL);
++ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
++ !strset_contains(data->deletable, up_short))
++ return 0;
++
++ strset_remove(data->deletable, up_short);
++ strset_add(data->spared, up_short);
+ return 0;
+}
+
+/*
-+ * Keep any branch that another, surviving branch tracks as its
-+ * upstream, so we never delete a branch out from under one stacked on
-+ * top of it. Sparing a branch makes it a survivor whose own upstream
-+ * then needs the same protection, so repeat until nothing changes.
++ * Keep any branch that a surviving branch tracks as its upstream, so we
++ * never delete a branch out from under one stacked on top of it. Such a
++ * base is itself merged, so when its own upstream is also going away
++ * (no surviving branch tracks it), clear the base's now-stale upstream.
+ */
+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
+{
-+ struct string_list upstreams = STRING_LIST_INIT_DUP;
-+ struct string_list_item *item;
-+ bool spared;
-+
-+ refs_for_each_branch_ref(refs, collect_upstream, &upstreams);
-+ do {
-+ spared = false;
-+ for_each_string_list_item(item, &upstreams) {
-+ const char *up = item->util, *up_short;
-+
-+ if (!up || strset_contains(deletable, item->string))
-+ continue;
-+ if (!skip_prefix(up, "refs/heads/", &up_short) ||
-+ !strset_contains(deletable, up_short))
-+ continue;
-+
-+ strset_remove(deletable, up_short);
-+ spared = true;
-+ }
-+ } while (spared);
-+
-+ string_list_clear(&upstreams, 1);
++ struct strset spared = STRSET_INIT;
++ struct spare_data data = { .deletable = deletable, .spared = &spared };
++ struct strbuf key = STRBUF_INIT;
++ struct hashmap_iter iter;
++ struct strmap_entry *entry;
++
++ refs_for_each_branch_ref(refs, spare_stacked_base, &data);
++
++ strset_for_each_entry(&spared, &iter, entry) {
++ struct branch *branch = branch_get(entry->key);
++ const char *upstream = branch_get_upstream(branch, NULL);
++ const char *up_short;
++
++ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
++ !strset_contains(deletable, up_short))
++ continue;
++
++ strbuf_reset(&key);
++ strbuf_addf(&key, "branch.%s.merge", branch->name);
++ repo_config_set_gently(the_repository, key.buf, NULL);
++ strbuf_reset(&key);
++ strbuf_addf(&key, "branch.%s.remote", branch->name);
++ repo_config_set_gently(the_repository, key.buf, NULL);
++ }
++
++ strbuf_release(&key);
++ strset_clear(&spared);
+}
+
+static int delete_merged_branches(int argc, const char **argv,
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ struct ref_array candidates = { 0 };
+ struct strset deletable = STRSET_INIT;
+ struct strvec to_delete = STRVEC_INIT;
++ struct hashmap_iter iter;
++ struct strmap_entry *entry;
+ int i, ret = 0;
+
+ if (!argc)
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+
+ spare_stacked_bases(refs, &deletable);
+
-+ for (i = 0; i < candidates.nr; i++) {
-+ const char *short_name;
-+
-+ if (skip_prefix(candidates.items[i]->refname, "refs/heads/",
-+ &short_name) &&
-+ strset_contains(&deletable, short_name))
-+ strvec_push(&to_delete, short_name);
-+ }
++ strset_for_each_entry(&deletable, &iter, entry)
++ strvec_push(&to_delete, entry->key);
+
+ if (to_delete.nr)
+ ret = delete_branches(to_delete.nr, to_delete.v,
@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
+ git checkout --detach
+ ) &&
+
++ git -C repo branch --dry-run --delete-merged origin/next >out &&
++ test_grep ! "feature" out &&
++
+ git -C repo branch --delete-merged origin/next 2>err &&
+
+ test_must_be_empty err &&
+ git -C repo rev-parse --verify refs/heads/feature &&
-+ git -C repo rev-parse --verify refs/heads/topic
++ git -C repo rev-parse --verify refs/heads/topic &&
++ echo origin/next >expect &&
++ git -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&
++ test_cmp expect actual &&
++ echo feature >expect &&
++ git -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&
++ test_cmp expect actual
+'
+
+test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '
@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
+ EOF
+ test_cmp expect actual
+'
++
++test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '
++ test_when_finished "rm -rf repo" &&
++ setup_repo_for_delete_merged &&
++ (
++ cd repo &&
++ git branch lower origin/next &&
++ git branch --set-upstream-to=origin/next lower &&
++ git branch mid origin/next &&
++ git branch --set-upstream-to=lower mid &&
++ git checkout -b tip mid &&
++ git commit --allow-empty -m "tip work" &&
++ git branch --set-upstream-to=mid tip &&
++ git checkout --detach
++ ) &&
++
++ git -C repo branch --delete-merged origin/next lower &&
++
++ test_must_fail git -C repo rev-parse --verify refs/heads/lower &&
++ git -C repo rev-parse --verify refs/heads/mid &&
++ test_must_fail git -C repo rev-parse mid@{upstream} &&
++ echo mid >expect &&
++ git -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&
++ test_cmp expect actual
++'
+
test_done
6: 27903fbb1d ! 6: d52d717b70 branch: add branch.<name>.deleteMerged opt-out
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
struct strset deletable = STRSET_INIT;
struct strvec to_delete = STRVEC_INIT;
+ struct strbuf key = STRBUF_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+ bool quiet = flags & DELETE_BRANCH_QUIET;
int i, ret = 0;
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
ref_array_clear(&candidates);
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the upstream of a kept base whose ow
test_cmp expect actual
' 7: 49c1bcf1fb ! 7: 8d0323f4b3 branch: add --dry-run for --delete-merged
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: A branch that another, surviving branch still tracks as its upstream
- is kept, so a branch is never deleted out from under one stacked on
- top of it.
+@@ Documentation/git-branch.adoc: kept, so a branch is never deleted out from under one stacked on top
+ of it. If that kept branch in turn tracks a branch that is being
+ deleted, its now-stale upstream configuration is cleared.
+--dry-run::
+ With --delete-merged, print which branches would be
Signature · 1 line
gitgitgadget
PATCH v18 1/7 branch: add --forked filter for --list mode
From: Harald Nordgren <[email protected]>
Add a --forked option to "git branch" list mode that lists only branches whose configured upstream matches <branch>. The argument can be a ref (e.g. "origin/main", "master"), a remote name like "origin" for the branch its origin/HEAD points at, or a shell 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 --delete-merged, which deletes the listed branches once they have landed on their upstream.
--- Documentation/git-branch.adoc | 12 +++- builtin/branch.c | 18 ++++- ref-filter.c | 70 +++++++++++++++++++ ref-filter.h | 10 +++ t/t3200-branch.sh | 122 ++++++++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 3 deletions(-)
Documentation/git-branch.adoc +11 -1
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current] [--column[=<options>] | --no-column] [--sort=<key>] [--merged [<commit>]] [--no-merged [<commit>]] [--contains [<commit>]] [--no-contains [<commit>]] + [(--forked <branch>)...] [--points-at <object>] [--format=<format>] [(-r|--remotes) | (-a|--all)] [--list] [<pattern>...]
@@ -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 _<commit>_ argument is missing it defaults to `HEAD` (i.e. the tip of the current -branch). +branch). With `--forked`, only branches whose configured upstream matches +the given branch or pattern will be listed. The command's second form creates a new branch head named _<branch-name>_ which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main". Only list branches whose tips are not reachable from _<commit>_ (`HEAD` if not specified). Implies `--list`. +`--forked <branch>`:: + Only list branches whose configured upstream matches + _<branch>_. The argument can be a ref (e.g. `origin/main`, + `master`), a remote name like `origin` for the branch its + `origin/HEAD` points at, or a shell-style glob (e.g. + `'origin/*'`). The option can be repeated to widen the + filter. Implies `--list`. + `--points-at <object>`:: Only list branches of _<object>_.
builtin/branch.c +16 -2
@@ -30,7 +30,7 @@#include "commit-reach.h" static const char *const builtin_branch_usage[] = { -N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged]" ), +N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]" ),N_ ("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]" ),N_ ("git branch [<options>] [-l] [<pattern>...]" ),N_ ("git branch [<options>] [-r] (-d | -D) <branch-name>..." ),
@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, intfree_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 <branch> (repeatable)" ), +PARSE_OPT_NONEG , parse_opt_forked),OPT_COLUMN (0 ,"column" , &colopts,N_ ("list branches in columns" )),OPT_REF_SORT (&sorting_options), OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -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 +
ref-filter.c +70 -0
88 lines not on this page · raw message
ref-filter.h +10 -0
@@ -67,6 +67,7 @@ struct ref_filter {const char **name_patterns;const char *start_after;struct strvec exclude; +struct strvec forked;struct oid_array points_at;struct commit_list *with_commit;struct commit_list *no_commit;
@@ -110,6 +111,7 @@ struct ref_format {#define REF_FILTER_INIT { \ .points_at = OID_ARRAY_INIT, \ .exclude = STRVEC_INIT, \ + .forked = STRVEC_INIT, \ }#define REF_FORMAT_INIT { \ .use_color = GIT_COLOR_UNKNOWN, \
@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);struct ref_sorting *ref_sorting_options (struct string_list *);/* Function to parse --merged and --no-merged options */ int parse_opt_merge_filter (const struct option *opt,const char *arg,int unset); +/* +* Register a --forked <branch> pattern on the filter. The argument is +* either a ref, which is resolved to its full refname, or a shell-style +* glob. Branches are kept only when their configured upstream matches +* one of the registered patterns. Returns -1 if the argument is not a +* valid ref or pattern. +*/ +int ref_filter_forked_add (struct ref_filter *filter,const char *arg);/* Get the current HEAD's description */ char *get_head_description (void );/* Set up translated strings in the output. */
t/t3200-branch.sh +122 -0
126 lines not on this page · raw message
Signature · 1 line
gitgitgadget
Re: [PATCH v18 1/7] branch: add --forked filter for --list mode
Hi Harald
Quoted 10 lines · Harald Nordgren via GitGitGadget
From: Harald Nordgren <[email protected]>
Add a --forked option to "git branch" list mode that lists only branches whose configured upstream matches <branch>. The argument can be a ref (e.g. "origin/main", "master"), a remote name like "origin" for the branch its origin/HEAD points at, or a shell glob (e.g. "origin/*"), and may be repeated to widen the filter.
It is an ordinary list filter, so it combines with the others:
Quoted 7 lines
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 --delete-merged, which deletes the listed branches once they have landed on their upstream.
The implementation looks good, I've left a couple of small comments on the tests. One thought I had was whether we want a mode which recurses so that if the upstream of topic2 is topic1 which has an upstream of origin/main --forked=recurse origin/main would list topic1 and topic2. So long as we don't think that is a sensible default we can add it in the future if we want.
Quoted 19 lines
t/t3200-branch.sh +11
-0@@ -1717,4 +1717,126 @@ test_expect_success 'errors if given a bad branch name' ' test_cmp expect actual ' +test_expect_success '--forked: setup' ' + test_create_repo forked-upstream && + ( + cd forked-upstream && + test_commit base && + git branch one base && + git branch two base + ) && + + test_create_repo forked-other && + (
t/t3200-branch.sh test_expect_success 'errors if given a bad branch name' ' · line 1740
Quoted 10 lines
t/t3200-branch.sh +10
-0+ cd forked-other && + test_commit other-base && + git branch foreign other-base + ) && + + git clone forked-upstream forked && + ( + cd forked && + git remote add -f other ../forked-other && + git remote set-head origin one &&
This is a bit strange because it does not match HEAD in the remote repository but it is necessary for '--forked <remote> uses the branch <remote>/HEAD'. I wonder if that test could be written to use main instead but I guess this doesn't do any harm.
t/t3200-branch.sh test_expect_success 'errors if given a bad branch name' ' · line 1751
Quoted 11 lines
t/t3200-branch.sh +11
-0+ git branch local-base && + git branch --track local-one origin/one && + git branch --track local-two origin/two && + git branch --track local-foreign other/foreign && + git branch --track local-onbase local-base && + + git checkout local-one && + test_commit --no-tag local-one-work local-one.t && + git checkout local-foreign && + test_commit --no-tag local-foreign-work local-foreign.t && + git checkout --detach
Why do we need a detached HEAD?
t/t3200-branch.sh test_expect_success 'errors if given a bad branch name' ' · line 1809
Quoted 5 lines
[...]
t/t3200-branch.sh +4
-0+test_expect_success '--forked composes with --no-merged' ' + test_when_finished "git -C forked checkout --detach" && + git -C forked checkout local-one && + test_commit -C forked local-only &&
The branch "local-one" already has a local commit so why do we need this?
Quoted 5 lines
t/t3200-branch.sh +5
-0+ git -C forked branch --forked "origin/*" --no-merged origin/one \ + --format="%(refname:short)" >actual && + echo local-one >expect && + test_cmp expect actual +'
t/t3200-branch.sh test_expect_success 'errors if given a bad branch name' ' · line 1824
Quoted 10 lines
t/t3200-branch.sh +10
-0+ +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 +'
It is a bit odd to have these two tests in the middle of the ones that check the functionality works.
Quoted 5 lines
t/t3200-branch.sh +5
-0+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' ' + git -C forked branch --forked origin --format="%(refname:short)" >actual && + echo local-one >expect && + test_cmp expect actual +'
t/t3200-branch.sh test_expect_success 'errors if given a bad branch name' ' · line 1840
Quoted 10 lines
t/t3200-branch.sh +10
-0+ +test_expect_success '--forked narrows a <pattern> argument' ' + git -C forked branch --forked "origin/*" "local-*" \ + --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-one + local-two + EOF + test_cmp expect actual +'
This is looking good
Thanks
Phillip
Re: [PATCH v18 1/7] branch: add --forked filter for --list mode
Quoted 2 lines
The implementation looks good, I've left a couple of small comments on the tests.
Thanks for all the help so far! Much appreciated!
Quoted 5 lines
One thought I had was whether we want a mode which recurses so that if the upstream of topic2 is topic1 which has an upstream of origin/main --forked=recurse origin/main would list topic1 and topic2. So long as we don't think that is a sensible default we can add it in the future if we want.
I don't understand this one, but if we safely do it later then that sounds good considering we will soon be on v19.
Quoted 12 lines
t/t3200-branch.sh test_expect_success 'errors if given a bad branch name' ' · line 1751
Quoted 11 lines
t/t3200-branch.sh +11
-0+ git branch local-base && + git branch --track local-one origin/one && + git branch --track local-two origin/two && + git branch --track local-foreign other/foreign && + git branch --track local-onbase local-base && + + git checkout local-one && + test_commit --no-tag local-one-work local-one.t && + git checkout local-foreign && + test_commit --no-tag local-foreign-work local-foreign.t && + git checkout --detach
Quoted 1 line
Why do we need a detached HEAD?
The '--delete-merged honours branch.<name>.deleteMerged=false' ' and "branch -d still deletes a deleteMerged=false branch" ' tests need it, but it's not necessary here, deleting.
Quoted 10 lines
Quoted 10 lines
t/t3200-branch.sh +10
-0+ 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 +'
Quoted 8 lines
t/t3200-branch.sh test_expect_success 'errors if given a bad branch name' ' · line 1824
Quoted 5 lines
t/t3200-branch.sh +5
-0+ +test_expect_success '--forked requires a value' ' + test_must_fail git -C forked branch --forked 2>err && + test_grep "requires a value" err +'It is a bit odd to have these two tests in the middle of the ones that check the functionality works.
Good point, reordering.
Harald
PATCH v18 2/7 branch: convert delete_branches() to a flags argument
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
delete_branches() and check_branch_commit() take a pair of int booleans (force and quiet) that the next commits would grow further. Replace them with a single "unsigned int flags" argument and an enum, splitting the bits back into named bool locals so the body keeps reading the same named values.
No change in behavior.
--- builtin/branch.c | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-)
builtin/branch.c +24 -12
@@ -189,10 +189,16 @@ 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 ), +}; +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) { +bool 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);
@@ -217,8 +223,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 +233,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,int i;int ret =0 ;int remote_branch =0 ; +bool force; +bool quiet = flags &DELETE_BRANCH_QUIET ;struct strbuf bname =STRBUF_INIT ;enum interpret_branch_kind allowed_interpret;struct string_list refs_to_delete =STRING_LIST_INIT_DUP ;
@@ -241,7 +249,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 +260,14 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, } branch_name_pos =strcspn (fmt,"%" ); + force = flags &DELETE_BRANCH_FORCE ; +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 +289,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 +301,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 +316,16 @@ 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)) { + flags)) { 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 +882,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 ();
Signature · 1 line
gitgitgadget
Re: [PATCH v18 2/7] branch: convert delete_branches() to a flags argument
Hi Harald
Quoted 9 lines · Harald Nordgren via GitGitGadget
builtin/branch.c +2
-2-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;
builtin/branch.c static int delete_branches(int argc, const char **argv, int force, int kinds, · line 237
Quoted 5 lines
builtin/branch.c +2
-0@@ -227,6 +233,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,int i;int ret =0 ;int remote_branch =0 ; +bool force; +bool quiet = flags &DELETE_BRANCH_QUIET ;
This means we have two sources of truth because we modify "flags" later. The idea of replacing the old function parameters with local variables only works if we're not passing the flags variable on to another function so I think we should replace all instances of "force" and "quiet" with flags & DELETE_BRANCH_FORCE/QUIET. That way we have a single source of truth and should avoid any future regressions like the one we saw in an earlier iteration.
Thanks
Phillip
Quoted 78 lines
builtin/branch.c +13
-9struct strbuf bname =STRBUF_INIT ;enum interpret_branch_kind allowed_interpret;struct string_list refs_to_delete =STRING_LIST_INIT_DUP ;@@ -241,7 +249,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 +260,14 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, } branch_name_pos =strcspn (fmt,"%" ); + force = flags &DELETE_BRANCH_FORCE ; +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 +289,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 +301,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 +316,16 @@ 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)) { + flags)) { 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 +882,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 ();
Re: [PATCH v18 2/7] branch: convert delete_branches() to a flags argument
Quoted 7 lines
This means we have two sources of truth because we modify "flags" later. The idea of replacing the old function parameters with local variables only works if we're not passing the flags variable on to another function so I think we should replace all instances of "force" and "quiet" with flags & DELETE_BRANCH_FORCE/QUIET. That way we have a single source of truth and should avoid any future regressions like the one we saw in an earlier iteration.
Yes, it makes sense.
I just hope we remember this discussion, so another reviewer doesn't push in the other direction later because it seems like low-hanging fruit.
Harald
Re: [PATCH v18 2/7] branch: convert delete_branches() to a flags argument
Hi Harald
Quoted 8 lines · Harald Nordgren
Quoted 7 lines
This means we have two sources of truth because we modify "flags" later. The idea of replacing the old function parameters with local variables only works if we're not passing the flags variable on to another function so I think we should replace all instances of "force" and "quiet" with flags & DELETE_BRANCH_FORCE/QUIET. That way we have a single source of truth and should avoid any future regressions like the one we saw in an earlier iteration.
Quoted 5 lines
Yes, it makes sense.
I just hope we remember this discussion, so another reviewer doesn't push in the other direction later because it seems like low-hanging fruit.
If you explain why we're using flags as the source of truth, rather than replacing the function parameters with local variables in this function that should not be a problem.
Thanks
Phillip
PATCH v18 3/7 branch: let delete_branches skip unmerged branches on bulk refusal
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Add a skip-unmerged mode to delete_branches() and check_branch_commit() so a bulk caller can silently skip branches that are not fully merged and carry on, rather than erroring with the "use 'git branch -D'" advice that the plain "git branch -d" path emits. Existing callers are unaffected.
--- builtin/branch.c | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-)
builtin/branch.c +12 -5
@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ), +DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -199,16 +200,20 @@ static int check_branch_commit(const char *branchname, const char *refname, int kinds, unsigned int flags) { bool force = flags & DELETE_BRANCH_FORCE; +bool skip_unmerged = flags &DELETE_BRANCH_SKIP_UNMERGED ;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 (!skip_unmerged) { +error (_ ("the branch '%s' is not fully merged" ), + branchname); +advise_if_enabled (ADVICE_FORCE_DELETE_BRANCH , +_ ("If you are sure you want to delete it, " +"run 'git branch -D %s'" ), branchname); + }return -1 ; }return 0 ;
@@ -235,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int kinds,int remote_branch =0 ;bool force;bool quiet = flags &DELETE_BRANCH_QUIET ; +bool skip_unmerged = flags &DELETE_BRANCH_SKIP_UNMERGED ;struct strbuf bname =STRBUF_INIT ;enum interpret_branch_kind allowed_interpret;struct string_list refs_to_delete =STRING_LIST_INIT_DUP ;
@@ -319,7 +325,8 @@ static int delete_branches(int argc, const char **argv, int kinds,if (!(ref_flags & (REF_ISSYMREF |REF_ISBROKEN )) &&check_branch_commit (bname.buf, name, &oid, head_rev, kinds, flags)) { - ret =1 ; +if (!skip_unmerged) + ret =1 ; goto next; }
Signature · 1 line
gitgitgadget
Re: [PATCH v18 3/7] branch: let delete_branches skip unmerged branches on bulk refusal
Hi Harald
builtin/branch.c static int delete_branches(int argc, const char **argv, int kinds, · line 243
Quoted 7 lines · Harald Nordgren via GitGitGadget
From: Harald Nordgren <[email protected]>
builtin/branch.c +1
-0@@ -235,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int kinds,int remote_branch =0 ;bool force;bool quiet = flags &DELETE_BRANCH_QUIET ; +bool skip_unmerged = flags &DELETE_BRANCH_SKIP_UNMERGED ;
The same as the last patch and for the next patch - as we're modifying flags lets keep it as the single source of truth.
Thanks
Phillip
Quoted 13 lines
builtin/branch.c +2
-1struct strbuf bname =STRBUF_INIT ;enum interpret_branch_kind allowed_interpret;struct string_list refs_to_delete =STRING_LIST_INIT_DUP ;@@ -319,7 +325,8 @@ static int delete_branches(int argc, const char **argv, int kinds,if (!(ref_flags & (REF_ISSYMREF |REF_ISBROKEN )) &&check_branch_commit (bname.buf, name, &oid, head_rev, kinds, flags)) { - ret =1 ; +if (!skip_unmerged) + ret =1 ; goto next; }
PATCH v18 4/7 branch: prepare delete_branches for a bulk caller
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Teach delete_branches() two new modes for the upcoming --delete-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.
--- builtin/branch.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-)
builtin/branch.c +9 -4
@@ -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,7 @@ enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), + DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -241,6 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds,bool force;bool quiet = flags &DELETE_BRANCH_QUIET ;bool skip_unmerged = flags &DELETE_BRANCH_SKIP_UNMERGED ; +bool no_head_fallback = flags &DELETE_BRANCH_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,7 +273,7 @@ static int delete_branches(int argc, const char **argv, int kinds, force = flags &DELETE_BRANCH_FORCE ; -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)) {
Signature · 1 line
gitgitgadget
PATCH v18 5/7 branch: add --delete-merged <branch>
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
git branch --delete-merged <branch>...
deletes the local branches that "--forked <branch>" 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.
A branch is not deleted when:
* it is checked out in any worktree
* its upstream remote-tracking branch no longer exists, since a missing upstream is not by itself a sign of integration
* its push destination equals its upstream (<branch>@{push} is the same as <branch>@{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 kept. Only branches that push somewhere other than their upstream, typically topics in a fork workflow, are candidates.
A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep.
A branch that another, surviving branch tracks as its upstream is also kept, so a branch is never deleted out from under one stacked on top of it. Such a kept branch is itself merged, so when its own upstream is being deleted, clear its now-stale upstream config.
--- Documentation/git-branch.adoc | 29 ++++++ builtin/branch.c | 147 ++++++++++++++++++++++++++- t/t3200-branch.sh | 185 ++++++++++++++++++++++++++++++++++ 3 files changed, 359 insertions(+), 2 deletions(-)
Documentation/git-branch.adoc +29 -0
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] +git branch --delete-merged <branch>... DESCRIPTION -----------
@@ -201,6 +202,34 @@ This option is only applicable in non-verbose mode. Print the name of the current branch. In detached `HEAD` state, nothing is printed. +`--delete-merged <branch>...`:: + Delete the local branches that `--forked` would list for the + given _<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. Several + _<branch>_ patterns may be given, e.g. `git branch + --delete-merged origin/main 'feature*'`. ++ +A branch is not deleted when: ++ +-- +* its upstream remote-tracking branch no longer exists, +* 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 + branch that just looks "fully merged" right after a pull. +-- ++ +A branch whose work has not yet been merged into its upstream is +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. ++ +A branch that another, surviving branch tracks as its upstream is +kept, so a branch is never deleted out from under one stacked on top +of it. If that kept branch in turn tracks a branch that is being +deleted, its now-stale upstream configuration is cleared. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +145 -2
195 lines not on this page · raw message
t/t3200-branch.sh +185 -0
189 lines not on this page · raw message
Signature · 1 line
gitgitgadget
Re: [PATCH v18 5/7] branch: add --delete-merged <branch>
Hi Harald
Quoted 3 lines · Harald Nordgren via GitGitGadget
From: Harald Nordgren <[email protected]>
git branch --delete-merged <branch>...
This design means that unlike --forked there is no way to limit the branches considered for deletion. I wonder if we'd be better to have --delete-merged take an argument like --forked so that the user can limit the branches that might be deleted without resorting to the config setting added in the next patch.
Quoted 17 lines
deletes the local branches that "--forked <branch>" 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.
A branch is not deleted when:
* it is checked out in any worktree
* its upstream remote-tracking branch no longer exists, since a missing upstream is not by itself a sign of integration
* its push destination equals its upstream (<branch>@{push} is the same as <branch>@{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 kept. Only branches that push somewhere other than their upstream, typically topics in a fork workflow, are candidates.
Quoted 7 lines
A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep.
A branch that another, surviving branch tracks as its upstream is also kept, so a branch is never deleted out from under one stacked on top of it. Such a kept branch is itself merged, so when its own upstream is being deleted, clear its now-stale upstream config.
The commit message explains the new feature really well. The implementation looks good, I've left a few questions and comments on the tests
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 769
Quoted 8 lines
builtin/branch.c +7
-0+static void spare_stacked_bases (struct ref_store *refs,struct strset *deletable) +{ +strbuf_addf (&key,"branch.%s.merge" , branch->name); +repo_config_set_gently (the_repository, key.buf,NULL ); +strbuf_reset (&key); +strbuf_addf (&key,"branch.%s.remote" , branch->name); +repo_config_set_gently (the_repository, key.buf,NULL );
If there are any errors updating the config then the config code will print a message and we continue. As clearing the config is really a convenience feature I think it is fine to ignore errors here.
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 818
Quoted 6 lines
builtin/branch.c +5
-0+static int delete_merged_branches(int argc, const char **argv, + unsigned int flags) + if (check_branch_commit(short_name, short_name, + &candidates.items[i]->objectname, NULL, + FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
This check is performed again when we call delete_branches() but we need to do it here to prune the branch in order to stop delete_branches() printing an error message. The check involves finding a merge-base so it is not necessarily cheap - if that becomes a problem in the future we can add a flag to delete_branches() to skip the check there.
Quoted 26 lines
t/t3200-branch.sh +18
-0@@ -1839,4 +1839,189 @@ test_expect_success '--forked narrows a <pattern> argument' ' test_cmp expect actual ' +test_expect_success '--delete-merged: setup' ' + git init -b main upstream && + ( + cd upstream && + test_commit base && + git checkout -b next && + test_commit next-work && + git checkout main + ) && + git init -b main other && + test_commit -C other other-base && + git init -b main fork +' + +setup_repo_for_delete_merged () { + rm -rf repo && + git clone upstream repo && + (
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1869
Quoted 10 lines
t/t3200-branch.sh +10
-0+ cd repo && + git remote add fork ../fork && + git remote add other ../other && + git config remote.pushDefault fork && + git config push.default current && + git fetch other + ) +} + +merged_branch () {
A comment would have helped to explain what this helper does. Also calling it create_merged_branch() would be clearer too I think.
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1872
Quoted 3 lines
t/t3200-branch.sh +3
-0+ ( + cd repo && + git checkout -b "$1" "$2" &&
If we add '--track' we can avoid having to run "git branch --set-upstream-to" below. The same goes for many if not all of the branches created by "git checkout -b" and "git branch" in these tests.
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1874
Quoted 2 lines
t/t3200-branch.sh +2
-0+ git commit --allow-empty -m "$1 work" && + git push origin "$1:next" &&
We let the caller specify the upstream branch, but then always push to origin/next - should be be using 'git push ${2%%/*} "$1:${2#*/}"', or if we don't need that flexibility hard coding the upstream branch?
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1875
Quoted 1 line
t/t3200-branch.sh +1
-0+ git fetch origin &&
We've just pushed, what are we fetching here?
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1881
Quoted 6 lines
t/t3200-branch.sh +6
-0+ git branch --set-upstream-to="$2" "$1" + ) +} + +test_expect_success '--delete-merged deletes merged branches and spares the rest' ' + test_when_finished "rm -rf repo" &&
The first thing setup_repo_for_delete does is delete repo so do we need this as well?
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1887
Quoted 6 lines
t/t3200-branch.sh +6
-0+ setup_repo_for_delete_merged && + merged_branch merged origin/next && + ( + cd repo && + git checkout -b unmerged origin/next && + git commit --allow-empty -m "unmerged work" &&
good - we have a branch with upstream origin/next that isn't merged and one that is.
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1891
Quoted 4 lines
t/t3200-branch.sh +4
-0+ git branch --set-upstream-to=origin/next unmerged && + git checkout -b tracks-other other/main && + git branch --set-upstream-to=other/main tracks-other && + git checkout --detach
I assume this is to ensure we don't spare a branch because it is checked out?
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1897
Quoted 6 lines
t/t3200-branch.sh +6
-0+ ) && + sha=$(git -C repo rev-parse --short merged) && + + git -C repo branch --delete-merged origin/next >actual 2>&1 && + + echo "Deleted branch merged (was $sha)." >expect &&
There doesn't seem to be any reason for these command or the ones below to be outside the subshell - they're all running commands in "repo". That seems to be a common pattern in these tests.
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1898
Quoted 1 line
t/t3200-branch.sh +1
-0+ test_cmp expect actual &&
This is good we have two branches with an upstream of origin/next, but only one of them is merged. We also check no other branches are deleted.
Quoted 5 lines
t/t3200-branch.sh +5
-0+ git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + main + tracks-other + unmerged
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1913
Quoted 10 lines
t/t3200-branch.sh +10
-0+ EOF + test_cmp expect actual +' + +test_expect_success '--delete-merged deletes merged branches and spares protected ones' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch on-next origin/next && + merged_branch checked-out origin/next && + merged_branch upstream-gone origin/next &&
Right, we create three branches that are all merged into origin/next
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1918
Quoted 5 lines
t/t3200-branch.sh +5
-0+ ( + cd repo && + git checkout -b mainline main && + git checkout -b on-local mainline && + git branch --set-upstream-to=mainline on-local &&
Why do we need on-local to track mainline rather than main? I'm a bit confused what the point of mainline is.
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1921
Quoted 3 lines
t/t3200-branch.sh +3
-0+ git update-ref refs/remotes/origin/topic refs/remotes/origin/next && + git branch --set-upstream-to=origin/topic upstream-gone && + git update-ref -d refs/remotes/origin/topic &&
These three lines can be replaced by
git config branch.gone.merge does-not-exist
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1923
Quoted 2 lines
t/t3200-branch.sh +2
-0+ git branch --set-upstream-to=origin/main main && + git config branch.main.pushRemote origin &&
What does this do? Isn't its pushRemote already origin?
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1929
Quoted 6 lines
t/t3200-branch.sh +6
-0+ git checkout -b tracks-other other/main && + git branch --set-upstream-to=other/main tracks-other && + git checkout checked-out + ) && + + git -C repo branch --delete-merged origin/next mainline &&
Do we want to use "origin/*" here instead so that we check that main is not deleted because its push destination matches its upstream?
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1939
Quoted 10 lines
t/t3200-branch.sh +10
-0+ + git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + checked-out + main + mainline + tracks-other + upstream-gone + EOF + test_cmp expect actual
This checks we delete on-local - good. I wonder if we should add a comment about the expected outcome so it is clear to the casual reader what is happening.
Quoted 10 lines
t/t3200-branch.sh +10
-0+' + +test_expect_success '--delete-merged requires at least one <branch>' ' + test_must_fail git -C forked branch --delete-merged 2>err && + test_grep "requires at least one <branch>" err +' + +test_expect_success '--delete-merged keeps a branch that is an upstream' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged &&
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1959
Quoted 10 lines
t/t3200-branch.sh +10
-0+ merged_branch feature origin/next && + ( + cd repo && + git checkout -b topic feature && + git commit --allow-empty -m "topic work" && + git branch --set-upstream-to=feature topic && + git checkout --detach + ) && + + git -C repo branch --dry-run --delete-merged origin/next >out &&
This belongs in a later patch and shows that the patches in this series have not been individually tested (c.f. my previous mail about running "git rebase --keep-base --exec")
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1964
Quoted 5 lines
t/t3200-branch.sh +5
-0+ test_grep ! "feature" out && + + git -C repo branch --delete-merged origin/next 2>err && + + test_must_be_empty err &&
So we don't delete anything because feature is needed by topic
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1966
Quoted 2 lines
t/t3200-branch.sh +2
-0+ git -C repo rev-parse --verify refs/heads/feature && + git -C repo rev-parse --verify refs/heads/topic &&
I preferred the way this as checked in the previous tests with for-each-ref and test_cmp as that shows everything that was kept.
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1972
Quoted 6 lines
t/t3200-branch.sh +6
-0+ echo origin/next >expect && + git -C repo rev-parse --abbrev-ref feature@{upstream} >actual && + test_cmp expect actual && + echo feature >expect && + git -C repo rev-parse --abbrev-ref topic@{upstream} >actual && + test_cmp expect actual
This is a bit of a faff. Perhaps
git config --local --get-regexp
"branch.(feature|topic).(merge|remote)" >actual
followed by test_cmp would be more concise and more clearly show that we're interested in checking that the config settings still exist.
Quoted 6 lines
t/t3200-branch.sh +6
-0+' + +test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + (
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1988
Quoted 10 lines
t/t3200-branch.sh +10
-0+ cd repo && + git branch b3 origin/next && + git branch --set-upstream-to=origin/next b3 && + git branch b2 origin/next && + git branch --set-upstream-to=b3 b2 && + git checkout -b b1 b2 && + git commit --allow-empty -m "b1 work" && + git branch --set-upstream-to=b2 b1 && + git checkout --detach + ) &&
I'd find this easier to follow if the base branch which is created firest was numbered 1, rather than the tip of the stack.
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1990
Quoted 1 line
t/t3200-branch.sh +1
-0+ git -C repo branch --delete-merged origin/next &&
b3 is merged but cannot be deleted because it is the upstream for b2 which although it is merged into b3 isn't a candidate for deletion because its upstream is b3.
I'm not quite sure what this test demonstrates that the next one does not.
Quoted 17 lines
t/t3200-branch.sh +17
-0+ + git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + b1 + b2 + b3 + main + EOF + test_cmp expect actual +' + +test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + ( + cd repo && + git branch lower origin/next &&
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 2017
Quoted 10 lines
t/t3200-branch.sh +10
-0+ git branch --set-upstream-to=origin/next lower && + git branch mid origin/next && + git branch --set-upstream-to=lower mid && + git checkout -b tip mid && + git commit --allow-empty -m "tip work" && + git branch --set-upstream-to=mid tip && + git checkout --detach + ) && + + git -C repo branch --delete-merged origin/next lower &&
We expect lower to be deleted, but not mid because although it is merged it is the upstream of an unmerged branch. Again it would be nice to check that with for-each-ref (maybe that is a common enough pattern to justify a helper that takes the expected output on stdin
check_branches <<-\EOF
main
mid
tip
EOF
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 2024
Quoted 6 lines
t/t3200-branch.sh +6
-0+ test_must_fail git -C repo rev-parse --verify refs/heads/lower && + git -C repo rev-parse --verify refs/heads/mid && + test_must_fail git -C repo rev-parse mid@{upstream} && + echo mid >expect && + git -C repo rev-parse --abbrev-ref tip@{upstream} >actual && + test_cmp expect actual
I'd check the config settings here as suggested for the test above. The test coverage looks good, there are just a few places where a comment would help explain what's going on and some places where we can save a few commands.
Thanks
Phillip
Quoted 3 lines
t/t3200-branch.sh +2
-0+' + test_done
Re: [PATCH v18 5/7] branch: add --delete-merged <branch>
Quoted 7 lines
Quoted 1 line
git branch --delete-merged <branch>...
This design means that unlike --forked there is no way to limit the branches considered for deletion. I wonder if we'd be better to have --delete-merged take an argument like --forked so that the user can limit the branches that might be deleted without resorting to the config setting added in the next patch.
Makes sense, I updated it now and it seems to work fine.
Quoted 31 lines
Quoted 31 lines
t/t3200-branch.sh +23
-0@@ -1839,4 +1839,189 @@ test_expect_success '--forked narrows a <pattern> argument' ' test_cmp expect actual ' +test_expect_success '--delete-merged: setup' ' + git init -b main upstream && + ( + cd upstream && + test_commit base && + git checkout -b next && + test_commit next-work && + git checkout main + ) && + git init -b main other && + test_commit -C other other-base && + git init -b main fork +' + +setup_repo_for_delete_merged () { + rm -rf repo && + git clone upstream repo && + ( + cd repo && + git remote add fork ../fork && + git remote add other ../other && + git config remote.pushDefault fork && + git config push.default current &&
Quoted 8 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1869
Quoted 5 lines
t/t3200-branch.sh +5
-0+ git fetch other + ) +} + +merged_branch () {A comment would have helped to explain what this helper does. Also calling it
create_merged_branch()would be clearer too I think.
I want to avoid comments because of the maintenance burden they introduce, I think 'create_merged_branch' will be a very clear name. Thanks for that suggestion!
Quoted 7 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1872
Quoted 3 lines
t/t3200-branch.sh +3
-0+ ( + cd repo && + git checkout -b "$1" "$2" &&If we add '--track' we can avoid having to run "git branch --set-upstream-to" below. The same goes for many if not all of the branches created by "git checkout -b" and "git branch" in these tests.
That's a very good point. I wanted to keep it "vanilla" since I worked a lot on upstreams and tracking branches in my other topics and didn't want it to bleed over, but yes it's much nicer.
Quoted 6 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1874
Quoted 2 lines
t/t3200-branch.sh +2
-0+ git commit --allow-empty -m "$1 work" && + git push origin "$1:next" &&We let the caller specify the upstream branch, but then always push to origin/next - should be be using 'git push ${2%%/*} "$1:${2#*/}"', or if we don't need that flexibility hard coding the upstream branch?
I'll hardcode for now.
Quoted 4 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1875
Quoted 1 line
t/t3200-branch.sh +1
-0+ git fetch origin &&We've just pushed, what are we fetching here?
Quoted 9 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1881
Quoted 6 lines
t/t3200-branch.sh +6
-0+ git branch --set-upstream-to="$2" "$1" + ) +} + +test_expect_success '--delete-merged deletes merged branches and spares the rest' ' + test_when_finished "rm -rf repo" &&The first thing
setup_repo_for_deletedoes is delete repo so do we need this as well?
Nope, let's rip it out.
Quoted 7 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1891
Quoted 4 lines
t/t3200-branch.sh +4
-0+ git branch --set-upstream-to=origin/next unmerged && + git checkout -b tracks-other other/main && + git branch --set-upstream-to=other/main tracks-other && + git checkout --detachI assume this is to ensure we don't spare a branch because it is checked out?
Yes, but it's also a pattern that I copy-pasted a few too many times, see my other comment where some of these can be removed.
Quoted 10 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1897
Quoted 6 lines
t/t3200-branch.sh +6
-0+ ) && + sha=$(git -C repo rev-parse --short merged) && + + git -C repo branch --delete-merged origin/next >actual 2>&1 && + + echo "Deleted branch merged (was $sha)." >expect &&There doesn't seem to be any reason for these command or the ones below to be outside the subshell - they're all running commands in "repo". That seems to be a common pattern in these tests.
Agreed, but other reviewers were against subshells before. I updated it now and looks much cleaner.
Quoted 8 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1918
Quoted 5 lines
t/t3200-branch.sh +5
-0+ ( + cd repo && + git checkout -b mainline main && + git checkout -b on-local mainline && + git branch --set-upstream-to=mainline on-local &&Why do we need on-local to track mainline rather than main? I'm a bit confused what the point of mainline is.
It's to have an indirection of a branch that is the same as main but will be protected. I tried to delete it now and replace it with just main, but then main was deleted and subsequent tests failed.
Quoted 4 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1921
Quoted 3 lines
t/t3200-branch.sh +3
-0+ git update-ref refs/remotes/origin/topic refs/remotes/origin/next && + git branch --set-upstream-to=origin/topic upstream-gone && + git update-ref -d refs/remotes/origin/topic &&
Quoted 8 lines
These three lines can be replaced by
git config branch.gone.merge does-not-exist
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1923
Quoted 2 lines
t/t3200-branch.sh +2
-0+ git branch --set-upstream-to=origin/main main && + git config branch.main.pushRemote origin &&What does this do? Isn't its pushRemote already origin?
Deleted.
Quoted 9 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1929
Quoted 6 lines
t/t3200-branch.sh +6
-0+ git checkout -b tracks-other other/main && + git branch --set-upstream-to=other/main tracks-other && + git checkout checked-out + ) && + + git -C repo branch --delete-merged origin/next mainline &&Do we want to use "origin/*" here instead so that we check that main is not deleted because its push destination matches its upstream?
That's a good point and a good regression test later on.
Quoted 11 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1939
Quoted 10 lines
t/t3200-branch.sh +10
-0+ + git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + checked-out + main + mainline + tracks-other + upstream-gone + EOF + test_cmp expect actual
Quoted 3 lines
This checks we delete on-local - good. I wonder if we should add a comment about the expected outcome so it is clear to the casual reader what is happening.
I'll rename to 'local-to-delete' to clarify and to avoid the comment.
Quoted 16 lines
Quoted 16 lines
t/t3200-branch.sh +16
-0+' + +test_expect_success '--delete-merged requires at least one <branch>' ' + test_must_fail git -C forked branch --delete-merged 2>err && + test_grep "requires at least one <branch>" err +' + +test_expect_success '--delete-merged keeps a branch that is an upstream' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch feature origin/next && + ( + cd repo && + git checkout -b topic feature && + git commit --allow-empty -m "topic work" && + git branch --set-upstream-to=feature topic &&
Quoted 8 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1959
Quoted 4 lines
t/t3200-branch.sh +4
-0+ git checkout --detach + ) && + + git -C repo branch --dry-run --delete-merged origin/next >out &&This belongs in a later patch and shows that the patches in this series have not been individually tested (c.f. my previous mail about running "git rebase --keep-base --exec")
Yup, should be fixed now. And I usually run "git rebase --keep-base --exec" a lot!
Quoted 5 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1966
Quoted 2 lines
t/t3200-branch.sh +2
-0+ git -C repo rev-parse --verify refs/heads/feature && + git -C repo rev-parse --verify refs/heads/topic &&I preferred the way this as checked in the previous tests with for-each-ref and
test_cmpas that shows everything that was kept.
Yes, it's better.
Quoted 7 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1972
Quoted 6 lines
t/t3200-branch.sh +6
-0+ echo origin/next >expect && + git -C repo rev-parse --abbrev-ref feature@{upstream} >actual && + test_cmp expect actual && + echo feature >expect && + git -C repo rev-parse --abbrev-ref topic@{upstream} >actual && + test_cmp expect actual
Quoted 7 lines
This is a bit of a faff. Perhaps
git config --local --get-regexp"branch.(feature|topic).(merge|remote)" >actual
followed by
test_cmpwould be more concise and more clearly show that we're interested in checking that the config settings still exist.
Nice.
Quoted 21 lines
Quoted 6 lines
t/t3200-branch.sh +6
-0+' + +test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + (t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1988
Quoted 10 lines
t/t3200-branch.sh +10
-0+ cd repo && + git branch b3 origin/next && + git branch --set-upstream-to=origin/next b3 && + git branch b2 origin/next && + git branch --set-upstream-to=b3 b2 && + git checkout -b b1 b2 && + git commit --allow-empty -m "b1 work" && + git branch --set-upstream-to=b2 b1 && + git checkout --detach + ) &&I'd find this easier to follow if the base branch which is created first was numbered 1, rather than the tip of the stack.
Quoted 7 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 1990
Quoted 1 line
t/t3200-branch.sh +1
-0+ git -C repo branch --delete-merged origin/next &&b3 is merged but cannot be deleted because it is the upstream for b2 which although it is merged into b3 isn't a candidate for deletion because its upstream is b3.
I'm not quite sure what this test demonstrates that the next one does not.
Deleting this one, which also removes the question of b1, b2 and b3 naming.
Quoted 33 lines
Quoted 17 lines
t/t3200-branch.sh +17
-0+ + git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + b1 + b2 + b3 + main + EOF + test_cmp expect actual +' + +test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + ( + cd repo && + git branch lower origin/next &&t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 2017
Quoted 10 lines
t/t3200-branch.sh +10
-0+ git branch --set-upstream-to=origin/next lower && + git branch mid origin/next && + git branch --set-upstream-to=lower mid && + git checkout -b tip mid && + git commit --allow-empty -m "tip work" && + git branch --set-upstream-to=mid tip && + git checkout --detach + ) && + + git -C repo branch --delete-merged origin/next lower &&We expect lower to be deleted, but not mid because although it is merged it is the upstream of an unmerged branch. Again it would be nice to check that with for-each-ref (maybe that is a common enough pattern to justify a helper that takes the expected output on stdin
Quoted 5 lines
check_branches<<-\EOF
main
mid
tip
EOF
Very good idea! Created the helper!
Quoted 11 lines
t/t3200-branch.sh test_expect_success '--forked narrows a <pattern> argument' ' · line 2024
Quoted 6 lines
t/t3200-branch.sh +6
-0+ test_must_fail git -C repo rev-parse --verify refs/heads/lower && + git -C repo rev-parse --verify refs/heads/mid && + test_must_fail git -C repo rev-parse mid@{upstream} && + echo mid >expect && + git -C repo rev-parse --abbrev-ref tip@{upstream} >actual && + test_cmp expect actualI'd check the config settings here as suggested for the test above. The test coverage looks good, there are just a few places where a comment would help explain what's going on and some places where we can save a few commands.
Will update! Thanks again!
Harald
Re: [PATCH v18 5/7] branch: add --delete-merged <branch>
Quoted 6 lines
Quoted 6 lines
Quoted 5 lines
t/t3200-branch.sh +5
-0+ ( + cd repo && + git checkout -b mainline main && + git checkout -b on-local mainline && + git branch --set-upstream-to=mainline on-local &&
Quoted 6 lines
Quoted 2 lines
Why do we need on-local to track mainline rather than main? I'm a bit confused what the point of mainline is.
It's to have an indirection of a branch that is the same as main but will be protected. I tried to delete it now and replace it with just main, but then main was deleted and subsequent tests failed.
Digging more into this, probably the most elegant solution is to replace mainline with main, but then also do this:
git config branch.main.pushRemote origin
This exposes something that I don't love about this feature, which is that when using a pushDefault (like we do in the tests with 'git config remote.pushDefault fork') if not adding a special case for the main/master branch (like 'git config branch.main.pushRemote origin'), then it will get cleaned up as a forked branch.
But there was a lot of discussion about this already, so I won't get into this again.
Harald
Re: [PATCH v18 5/7] branch: add --delete-merged <branch>
Quoted 13 lines · Harald Nordgren
Quoted 6 lines
Quoted 6 lines
Quoted 5 lines
t/t3200-branch.sh +5
-0+ ( + cd repo && + git checkout -b mainline main && + git checkout -b on-local mainline && + git branch --set-upstream-to=mainline on-local &&Quoted 6 lines
Quoted 2 lines
Why do we need on-local to track mainline rather than main? I'm a bit confused what the point of mainline is.
It's to have an indirection of a branch that is the same as main but will be protected. I tried to delete it now and replace it with just main, but then main was deleted and subsequent tests failed.
Quoted 6 lines
Digging more into this, probably the most elegant solution is to replace mainline with main, but then also do this:
git config branch.main.pushRemote origin
This exposes something that I don't love about this feature,
by "this feature" do you mean "git branch --delete-merged"?
Quoted 5 lines
which is
that when using a pushDefault (like we do in the tests with 'git config remote.pushDefault fork') if not adding a special case for the main/master branch (like 'git config branch.main.pushRemote origin'), then it will get cleaned up as a forked branch.
Oh, so because the default push remote is not "origin" we need to override that for the branches that we do push to "origin". That's a pain, but even if we did add a special case for the default branch, it would not protect other branches like "next" and "seen".
We should maybe add a note to the documentation about that to warn users.
Quoted 2 lines
But there was a lot of discussion about this already, so I won't get into this again.
Fair enough
Thanks
Phillip
Re: [PATCH v18 5/7] branch: add --delete-merged <branch>
Hi Harald
Quoted 11 lines · Phillip Wood
Quoted 7 lines · Harald Nordgren
Digging more into this, probably the most elegant solution is to replace mainline with main, but then also do this:
git config branch.main.pushRemote origin
This exposes something that I don't love about this feature,
by "this feature" do you mean "git branch --delete-merged"?
Quoted 7 lines
Quoted 5 lines
which is
that when using a pushDefault (like we do in the tests with 'git config remote.pushDefault fork') if not adding a special case for the main/master branch (like 'git config branch.main.pushRemote origin'), then it will get cleaned up as a forked branch.Oh, so because the default push remote is not "origin" we need to override that for the branches that we do push to "origin". That's a pain, but even if we did add a special case for the default branch, it would not protect other branches like "next" and "seen".
Thinking about this a bit more, rather than protecting branches where $branch@{push} == $branch@{upstream}, perhaps we should be protecting branches that are merged into their upstream but
git push branch.$branch.remote $branch
would update $branch@{upstream}. So we'd apply the push refspec to the branch name, then apply the fetch refspec to that and check the result did not match the name of the upstream branch.
Does that make sense?
Thanks
Phillip
Re: [PATCH v18 5/7] branch: add --delete-merged <branch>
Quoted 19 lines
Quoted 10 lines
Quoted 1 line
This exposes something that I don't love about this feature,
by "this feature" do you mean "git branch --delete-merged"?
Quoted 5 lines
which is
that when using a pushDefault (like we do in the tests with 'git config remote.pushDefault fork') if not adding a special case for the main/master branch (like 'git config branch.main.pushRemote origin'), then it will get cleaned up as a forked branch.Quoted 4 lines
Oh, so because the default push remote is not "origin" we need to override that for the branches that we do push to "origin". That's a pain, but even if we did add a special case for the default branch, it would not protect other branches like "next" and "seen".
Thinking about this a bit more, rather than protecting branches where $branch@{push} == $branch@{upstream}, perhaps we should be protecting branches that are merged into their upstream but
Quoted 7 lines
git push branch.$branch.remote $branch
would update $branch@{upstream}. So we'd apply the push refspec to the branch name, then apply the fetch refspec to that and check the result did not match the name of the upstream branch.
Does that make sense?
This makes a lot of sense and fixes my major gripe. Seems very possible to implement as well, I'll give it a shot.
Are you done with the rest of your review so I can push out the next version?
Harald
Re: [PATCH v18 5/7] branch: add --delete-merged <branch>
Quoted 21 lines · Harald Nordgren
Quoted 21 lines
Quoted 10 lines
Quoted 1 line
This exposes something that I don't love about this feature,
by "this feature" do you mean "git branch --delete-merged"?
Quoted 5 lines
which is
that when using a pushDefault (like we do in the tests with 'git config remote.pushDefault fork') if not adding a special case for the main/master branch (like 'git config branch.main.pushRemote origin'), then it will get cleaned up as a forked branch.Quoted 4 lines
Oh, so because the default push remote is not "origin" we need to override that for the branches that we do push to "origin". That's a pain, but even if we did add a special case for the default branch, it would not protect other branches like "next" and "seen".
Thinking about this a bit more, rather than protecting branches where $branch@{push} == $branch@{upstream}, perhaps we should be protecting branches that are merged into their upstream but
git push branch.$branch.remote $branch
Quoted 8 lines
Quoted 5 lines
would update $branch@{upstream}. So we'd apply the push refspec to the branch name, then apply the fetch refspec to that and check the result did not match the name of the upstream branch.
Does that make sense?
This makes a lot of sense and fixes my major gripe. Seems very possible to implement as well, I'll give it a shot.
That's great
Quoted 1 line
Are you done with the rest of your review so I can push out the next version?
Yes, though I've just remembered that when we were discussing protecting branches that are the upstreams of another branch Junio was keen for us to extend that protection to "git branch -d" as well.
Thanks
Phillip
Re: [PATCH v18 5/7] branch: add --delete-merged <branch>
Quoted 3 lines
Yes, though I've just remembered that when we were discussing protecting branches that are the upstreams of another branch Junio was keen for us to extend that protection to "git branch -d" as well.
Sure, not a bad idea, I'll put it on my list of things to do after this topic has landed.
Harald
PATCH v18 6/7 branch: add branch.<name>.deleteMerged opt-out
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Setting branch.<name>.deleteMerged=false exempts that branch from "git branch --delete-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.
--- Documentation/config/branch.adoc | 7 +++++++ Documentation/git-branch.adoc | 5 +++-- builtin/branch.c | 15 +++++++++++++++ t/t3200-branch.sh | 26 ++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-)
Documentation/config/branch.adoc +7 -0
@@ -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.<name>.deleteMerged`:: + If set to `false`, branch _<name>_ is exempt from + `git branch --delete-merged`. Useful for a topic branch you + intend to develop further after an initial round has been + merged upstream. Defaults to true. Explicit deletion via + `git branch -d` is unaffected.
Documentation/git-branch.adoc +3 -2
@@ -215,10 +215,11 @@ A branch is not deleted when: + -- * its upstream remote-tracking branch no longer exists, -* it is checked out in any worktree, or +* it is checked out in any worktree, * its push destination (`<branch>@{push}`) equals its upstream (`<branch>@{upstream}`), so it cannot be distinguished from a - branch that just looks "fully merged" right after a pull. + branch that just looks "fully merged" right after a pull, or +* `branch.<name>.deleteMerged` is set to `false`. -- + A branch whose work has not yet been merged into its upstream is
builtin/branch.c +15 -0
@@ -781,8 +781,10 @@ static int delete_merged_branches(int argc, const char **argv,struct ref_array candidates = {0 };struct strset deletable =STRSET_INIT ;struct strvec to_delete =STRVEC_INIT ; +struct strbuf key =STRBUF_INIT ;struct hashmap_iter iter;struct strmap_entry *entry; +bool quiet = flags &DELETE_BRANCH_QUIET ;int i, ret =0 ;if (!argc)
@@ -800,6 +802,7 @@ static int delete_merged_branches(int argc, const char **argv,const char *short_name;struct branch *branch;const char *upstream, *push; +int opt_out;if (!skip_prefix (full_name,"refs/heads/" , &short_name))BUG ("filter returned non-branch ref '%s'" , full_name);
@@ -818,6 +821,17 @@ static int delete_merged_branches(int argc, const char **argv, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; +strbuf_reset (&key); +strbuf_addf (&key,"branch.%s.deletemerged" , short_name); +if (!repo_config_get_bool (the_repository, key.buf, &opt_out) && + !opt_out) { +if (!quiet) +fprintf (stderr, +_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), + short_name, short_name); +continue ; + } +strset_add (&deletable, short_name); }
@@ -833,6 +847,7 @@ static int delete_merged_branches(int argc, const char **argv,DELETE_BRANCH_NO_HEAD_FALLBACK | flags); +strbuf_release (&key);strvec_clear (&to_delete);strset_clear (&deletable);ref_array_clear (&candidates);
t/t3200-branch.sh +26 -0
@@ -2024,4 +2024,30 @@ test_expect_success '--delete-merged clears the upstream of a kept base whose ow test_cmp expect actual ' +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch deleted origin/next && + merged_branch kept origin/next && + git -C repo config branch.kept.deleteMerged false && + git -C repo checkout --detach && + + git -C repo branch --delete-merged origin/next 2>err && + + test_grep "Skipping .kept." err && + test_must_fail git -C repo rev-parse --verify refs/heads/deleted && + git -C repo rev-parse --verify refs/heads/kept +' + +test_expect_success "branch -d still deletes a deleteMerged=false branch" ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch kept origin/next && + git -C repo config branch.kept.deleteMerged false && + git -C repo checkout --detach && + + git -C repo branch -d kept && + test_must_fail git -C repo rev-parse --verify refs/heads/kept +' + test_done
Signature · 1 line
gitgitgadget
Re: [PATCH v18 6/7] branch: add branch.<name>.deleteMerged opt-out
Hi Harald
Quoted 12 lines · Harald Nordgren via GitGitGadget
t/t3200-branch.sh +3
-0@@ -2024,4 +2024,30 @@ test_expect_success '--delete-merged clears the upstream of a kept base whose ow test_cmp expect actual ' +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged &&
t/t3200-branch.sh test_expect_success '--delete-merged clears the upstream of a kept base whose ow · line 2039
Quoted 10 lines
t/t3200-branch.sh +10
-0+ merged_branch deleted origin/next && + merged_branch kept origin/next && + git -C repo config branch.kept.deleteMerged false && + git -C repo checkout --detach && + + git -C repo branch --delete-merged origin/next 2>err && + + test_grep "Skipping .kept." err && + test_must_fail git -C repo rev-parse --verify refs/heads/deleted && + git -C repo rev-parse --verify refs/heads/kept
As with the previous patches, I think this would be nicer if we checked the output for for-each-ref. Everything else looks fine.
Thanks
Phillip
Quoted 14 lines
t/t3200-branch.sh +13
-0+' + +test_expect_success "branch -d still deletes a deleteMerged=false branch" ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch kept origin/next && + git -C repo config branch.kept.deleteMerged false && + git -C repo checkout --detach && + + git -C repo branch -d kept && + test_must_fail git -C repo rev-parse --verify refs/heads/kept +' + test_done
Re: [PATCH v18 6/7] branch: add branch.<name>.deleteMerged opt-out
Quoted 8 lines
Quoted 8 lines
t/t3200-branch.sh +8
-0+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch deleted origin/next && + merged_branch kept origin/next && + git -C repo config branch.kept.deleteMerged false && + git -C repo checkout --detach && +
Quoted 8 lines
t/t3200-branch.sh test_expect_success '--delete-merged clears the upstream of a kept base whose ow · line 2039
Quoted 5 lines
t/t3200-branch.sh +5
-0+ git -C repo branch --delete-merged origin/next 2>err && + + test_grep "Skipping .kept." err && + test_must_fail git -C repo rev-parse --verify refs/heads/deleted && + git -C repo rev-parse --verify refs/heads/keptAs with the previous patches, I think this would be nicer if we checked the output for for-each-ref. Everything else looks fine.
Thanks! Yes, I have already created a helper for it, and it makes it a lot clearer. Thanks again for a good suggestion.
Harald
PATCH v18 7/7 branch: add --dry-run for --delete-merged
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
With --dry-run, --delete-merged prints the local branches it would delete, one "Would delete branch <name>" 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 --delete-merged and is rejected otherwise.
--- Documentation/git-branch.adoc | 8 +++++++- builtin/branch.c | 22 +++++++++++++++++++--- t/t3200-branch.sh | 11 ++++++++++- 3 files changed, 36 insertions(+), 5 deletions(-)
Documentation/git-branch.adoc +7 -1
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] -git branch --delete-merged <branch>... +git branch [--dry-run] --delete-merged <branch>... DESCRIPTION -----------
@@ -231,6 +231,12 @@ kept, so a branch is never deleted out from under one stacked on top of it. If that kept branch in turn tracks a branch that is being deleted, its now-stale upstream configuration is cleared. +`--dry-run`:: + With `--delete-merged`, print which branches would be + deleted and exit without touching any ref. Useful for + sanity-checking a wide pattern like `'origin/*'` before + committing to the deletion. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +19 -3
@@ -199,6 +199,7 @@ enum delete_branch_flags {DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ),DELETE_BRANCH_NO_HEAD_FALLBACK = (1 <<3 ), + DELETE_BRANCH_DRY_RUN = (1 << 4), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -248,6 +249,7 @@ static int delete_branches(int argc, const char **argv, int kinds,bool quiet = flags &DELETE_BRANCH_QUIET ;bool skip_unmerged = flags &DELETE_BRANCH_SKIP_UNMERGED ;bool no_head_fallback = flags &DELETE_BRANCH_NO_HEAD_FALLBACK ; +bool dry_run = flags &DELETE_BRANCH_DRY_RUN ;struct strbuf bname =STRBUF_INIT ;enum interpret_branch_kind allowed_interpret;struct string_list refs_to_delete =STRING_LIST_INIT_DUP ;
@@ -346,13 +348,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
@@ -897,6 +906,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 delete_merged =0 ; +int dry_run =0 ;const char *new_upstream =NULL ;int noncreate_actions =0 ;/* possible options */
@@ -952,6 +962,8 @@ int cmd_branch(int argc, N_("edit the description for the branch")),OPT_BOOL (0 ,"delete-merged" , &delete_merged,N_ ("delete local branches whose upstream matches <branch> and are merged" )), +OPT_BOOL (0 ,"dry-run" , &dry_run, +N_ ("with --delete-merged, only print which branches would be deleted" )),OPT__FORCE (&force,N_ ("force creation, move/rename, deletion" ),PARSE_OPT_NOCOMPLETE ),OPT_MERGED (&filter,N_ ("print only branches that are merged" )), OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1014,6 +1026,9 @@ int cmd_branch(int argc,if (noncreate_actions >1 )usage_with_options (builtin_branch_usage, options); +if (dry_run && !delete_merged) +die (_ ("--dry-run requires --delete-merged" )); +if (recurse_submodules_explicit) {if (!submodule_propagate_branches)die (_ ("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled" ));
@@ -1054,7 +1069,8 @@ int cmd_branch(int argc, goto out; }else if (delete_merged) { ret =delete_merged_branches (argc, argv, - quiet ?DELETE_BRANCH_QUIET :0 ); + (quiet ?DELETE_BRANCH_QUIET :0 ) | + (dry_run ?DELETE_BRANCH_DRY_RUN :0 )); goto out; }else if (show_current) {print_current_branch_name ();
t/t3200-branch.sh +10 -1
@@ -1892,8 +1892,12 @@ test_expect_success '--delete-merged deletes merged branches and spares the rest ) && sha=$(git -C repo rev-parse --short merged) && - git -C repo branch --delete-merged origin/next >actual 2>&1 && + git -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 && + echo "Would delete branch merged (was $sha)." >expect && + test_cmp expect actual && + git -C repo rev-parse --verify refs/heads/merged && + git -C repo branch --delete-merged origin/next >actual 2>&1 && echo "Deleted branch merged (was $sha)." >expect && test_cmp expect actual && git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
@@ -2050,4 +2054,9 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" ' test_must_fail git -C repo rev-parse --verify refs/heads/kept ' +test_expect_success '--dry-run without --delete-merged is rejected' ' + test_must_fail git -C forked branch --dry-run 2>err && + test_grep "requires --delete-merged" err +' + test_done
Signature · 1 line
gitgitgadget
Re: [PATCH v18 7/7] branch: add --dry-run for --delete-merged
Hi Harald
Quoted 6 lines · Harald Nordgren via GitGitGadget
From: Harald Nordgren <[email protected]>
With --dry-run, --delete-merged prints the local branches it would delete, one "Would delete branch <name>" 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.
The same filtering as what? I think something like
"git branch --dry-run --delete-merged ..." prints one line per ref that would be deleted without modifing any refs.
would be sufficient
Quoted 2 lines
--dry-run is only meaningful together with --delete-merged and is rejected otherwise.
Good
Quoted 5 lines
builtin/branch.c +0
-1@@ -346,13 +348,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 ))
builtin/branch.c static int delete_branches(int argc, const char **argv, int kinds, · line 359
Quoted 10 lines
builtin/branch.c +4
-1+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)
This matches what we do without '--dry-run' but what use is '--dry-run --quiet' if it does not print anything?
Quoted 10 lines
builtin/branch.c +5
-0+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)) {t/t3200-branch.sh +0
-0
t/t3200-branch.sh test_expect_success '--delete-merged deletes merged branches and spares the rest · line 1900
Quoted 10 lines
t/t3200-branch.sh +5
-1@@ -1892,8 +1892,12 @@ test_expect_success '--delete-merged deletes merged branches and spares the rest ) && sha=$(git -C repo rev-parse --short merged) && - git -C repo branch --delete-merged origin/next >actual 2>&1 && + git -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 && + echo "Would delete branch merged (was $sha)." >expect && + test_cmp expect actual && + git -C repo rev-parse --verify refs/heads/merged && + git -C repo branch --delete-merged origin/next >actual 2>&1 &&
I was wondering why the diff shows the line above being deleted and then added, it is because previously there was a blank line after it. The test for --dry-run looks good.
t/t3200-branch.sh test_expect_success "branch -d still deletes a deleteMerged=false branch" ' · line 2059
Quoted 10 lines
t/t3200-branch.sh +3
-0echo "Deleted branch merged (was $sha)." >expect && test_cmp expect actual && git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&@@ -2050,4 +2054,9 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" ' test_must_fail git -C repo rev-parse --verify refs/heads/kept ' +test_expect_success '--dry-run without --delete-merged is rejected' ' + test_must_fail git -C forked branch --dry-run 2>err && + test_grep "requires --delete-merged" err
Nice
Thanks
Phillip
Re: [PATCH v18 7/7] branch: add --dry-run for --delete-merged
Quoted 2 lines
This matches what we do without '--dry-run' but what use is '--dry-run --quiet' if it does not print anything?
Yeah, it's a bit weird. What do you suggest we do instead, reject the combination or turn it into a no-op?
Harald
Re: [PATCH v18 0/7] branch: delete-merged
In reply to Harald Nordgren via GitGitGadgetHi Harald
I've looked through the first five patches and left a few comments and queries. I'll look at the last two at the start of next week so don't re-roll just yet, but my all means reply to the comments, especially where I've asked questions. So far I like what I've seen.
Thanks
Phillip
Quoted 491 lines · Harald Nordgren via GitGitGadget
Delete branches that have already been merged on upstream.
Changes in v18:
* Instead of keeping the whole chain of upstream branches, keep only the ones an unmerged branch still needs. When a kept (merged) branch in turn tracks a branch that is being deleted, clear its now-stale upstream config.
* Reworkspare_stacked_bases()to record the kept bases and, in a second pass, clear the upstream of any whose own base is going away. Build the to-delete list withstrset_for_each_entry()instead of re-walking the candidate array.Changes in v17:
* Keep a merged branch when another surviving branch still tracks it as its upstream, so --delete-merged no longer deletes a branch out from under one stacked on top of it.
* Move the --dry-run and branch.<name>.deleteMerged opt-out fully into their own commits.Changes in v16:
* Convert
delete_merged_branches()to take an unsigned int flags argument instead of separate quiet/dry_run booleans, matchingdelete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_resetper iteration, singlestrbuf_releaseafter) instead of allocating and freeing it each time
* Rewrite the --delete-merged tests as integration tests: branches that land commits upstream, with deletion and the checked-out, upstream-gone, and push-equals-upstream safety cases exercised together in one run and output asserted viatest_cmp
* Collapse the many per-aspect test repos into a single reused repo set up by asetup_repo_for_delete_mergedhelper, and rename helpers off the old pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of prefixing every command with -CChanges in v15:
* Renamed --prune-merged to --delete-merged throughout. Not necessarily final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of warning.
* Initialized thedelete_branches()flag locals where declared. Only force stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant branch NULL checks dropped,ref_arraycandidates = { 0 }, aBUG()for the unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f, --forked coverage), renamed the misleading trunk fixture, and replaced the misnamed detached branch with git checkout --detach.Changes in v14:
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the remotes path set a local force but not the
DELETE_BRANCH_FORCEbit thatcheck_branch_commit()reads, so it wrongly ran the merge check.
* Made flags the single source of truth indelete_branches()so the bit and the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other branches too, hopefully not related
(https://github.com/git/git/pull/2285).Changes in v13:
* Reworked --forked into a real ref-filter applied in
apply_ref_filter()instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the abbreviated upstream), and dropped the old helper machinery, forward declaration, andstring_listin favor of a strvec.
* Replaced the boolean parameters of delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates viafilter_refs()rather than its own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git branch --prune-merged origin/main 'feature*') instead of repeating the option.Changes in v12:
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatableOPT_STRING_LISToptions.
* Dropped the bare-remote-name resolution for --forked, the argument is now a ref or a glob.Changes in v11:
* 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 /HEAD by name guard that the reviewer rejected is gone.
* New --dry-run for --prune-merged.Changes in v10:
* --forked / --prune-merged now take a branch glob instead of a remote name — origin, origin/*, origin/release-- all work. This replaces the remote-only form and subsumes the old --all-remotes flag, which has been dropped.
* New --dry-run for --prune-merged.Changes in v9:
* --force no longer has special meaning with --prune-merged; reachability is always enforced. Use git branch -D to delete an unmerged branch. Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.Changes in v8:
* Delete only when the branch's work is actually reachable from its upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one placeChanges in v7:
* --prune-merged now checks if a branch is merged into its own upstream first. If the upstream is gone, it checks against the remote's default branch instead. If neither exists, the branch is refused (use --force to delete anyway).
Changes in v6:
* --prune-merged now measures merged-ness against the remote's default branch instead of the candidate's upstream — so the decision no longer depends on which branch happens to be checked out locally.
*delete_branches()/check_branch_commit()gained a per-candidate override that lets a caller substitute a different "what counts as merged" reference (or skip the check). branch -d callers pass NULL and keep their existing semantics.
*prune_merged_branches()resolves each candidate's push-remote HEAD and threads it through, so --prune-merged --all-remotes measures each candidate against its own remote rather than a single global reference.Changes in v5:
* Drop commit 'fetch: add --prune-merged'
Changes in v4:
* Resolve each remote's HEAD and collect the targets into a
protected_default_refsset incollect_forked_set.
* Inprune_merged_branches, skip a candidate when its upstream is a protected default ref and the local branch name matches the default branch's leaf name (so a local main tracking origin/main is spared, but a renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name (not by upstream alone); spare a branch whose push ref is the remote default.Changes in v3:
* s/remote-tracking refs/remote-tracking branches/g
Changes in v2:
* The whole feature moved out of git fetch and into git branch. git fetch --prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their tip is reachable from the upstream tracking ref; --force skips that safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running topic branch) even with --force; doesn't affect explicit git branch -d.
*delete_branches()got awarn_onlymode so bulk deletion prints a one-line warning per skipped branch instead of the noisy four-line hint that git branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention--prune-merged.* New tests in
t3200-branch.shfor the new branch flags;t5510-fetch.shshrunk since most logic moved.Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convertdelete_branches()to a flags argument
branch: letdelete_branchesskip unmerged branches on bulk refusal
branch: preparedelete_branchesfor a bulk caller
branch: add --delete-merged <branch>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-mergedDocumentation/config/branch.adoc | 7 + Documentation/git-branch.adoc | 48 ++++- builtin/branch.c | 266 +++++++++++++++++++++--- ref-filter.c | 70 +++++++ ref-filter.h | 10 + t/t3200-branch.sh | 342 +++++++++++++++++++++++++++++++ 6 files changed, 715 insertions(+), 28 deletions(-)base-commit:
ab776a62a78576513ee121424adb19597fbb7613
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v18
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v18
Pull-Request: https://github.com/git/git/pull/2285Range-diff vs v17:
1: d8cc17bd7f = 1: 3e29ff17bd branch: add --forked filter for --list mode 2: d14b0403f0 = 2: cdd4fea4a7 branch: convert delete_branches() to a flags argument 3: ef2719dac3 = 3: a0fd5b4a6c branch: let delete_branches skip unmerged branches on bulk refusal 4: 80518f5d11 = 4: a56d8fe93e branch: prepare delete_branches for a bulk caller 5: 46da7c8140 ! 5: a84c555d99 branch: add --delete-merged <branch> @@ Commit message upstream. The work has already landed on the upstream they track, so the local copy is no longer needed. - Three kinds of branches are not deleted: + A branch is not deleted when: - * any branch checked out in any worktree - * any branch whose upstream remote-tracking branch no longer - exists, since a missing upstream is not by itself a sign of - integration - * any branch whose push destination equals its upstream - (<branch>@{push} is the same as <branch>@{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 kept. Only - branches that push somewhere other than their upstream, - typically topics in a fork workflow, are candidates. + * it is checked out in any worktree + * its upstream remote-tracking branch no longer exists, since a + missing upstream is not by itself a sign of integration + * its push destination equals its upstream (<branch>@{push} is + the same as <branch>@{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 kept. Only branches that push + somewhere other than their upstream, typically topics in a fork + workflow, are candidates. A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep. A branch that another, surviving branch tracks as its upstream is also kept, so a branch is never deleted out from under one stacked - on top of it. Sparing such a base can in turn protect its own - upstream, so the check repeats until the set stops changing. + on top of it. Such a kept branch is itself merged, so when its own + upstream is being deleted, clear its now-stale upstream config. Signed-off-by: Harald Nordgren <[email protected]> @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. ++ -+A branch that another, surviving branch still tracks as its upstream -+is kept, so a branch is never deleted out from under one stacked on -+top of it. ++A branch that another, surviving branch tracks as its upstream is ++kept, so a branch is never deleted out from under one stacked on top ++of it. If that kept branch in turn tracks a branch that is being ++deleted, its now-stale upstream configuration is cleared. + `-v`:: `-vv`:: @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch return 0; } -+static int collect_upstream(const struct reference *ref, void *cb_data) -+{ -+ struct string_list *upstreams = cb_data; -+ struct branch *branch = branch_get(ref->name); -+ const char *upstream = branch_get_upstream(branch, NULL); ++struct spare_data { ++ struct strset *deletable; ++ struct strset *spared; ++}; + -+ string_list_append(upstreams, ref->name)->util = -+ xstrdup_or_null(upstream); ++/* ++ * A surviving branch stacked on a deletion candidate would lose its ++ * upstream, so drop that candidate from the delete set and remember it ++ * in "spared" so its own upstream can be tidied up afterwards. ++ */ ++static int spare_stacked_base(const struct reference *ref, void *cb_data) ++{ ++ struct spare_data *data = cb_data; ++ struct branch *branch; ++ const char *upstream, *up_short; ++ ++ if (strset_contains(data->deletable, ref->name)) ++ return 0; ++ branch = branch_get(ref->name); ++ upstream = branch_get_upstream(branch, NULL); ++ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) || ++ !strset_contains(data->deletable, up_short)) ++ return 0; ++ ++ strset_remove(data->deletable, up_short); ++ strset_add(data->spared, up_short); + return 0; +} + +/* -+ * Keep any branch that another, surviving branch tracks as its -+ * upstream, so we never delete a branch out from under one stacked on -+ * top of it. Sparing a branch makes it a survivor whose own upstream -+ * then needs the same protection, so repeat until nothing changes. ++ * Keep any branch that a surviving branch tracks as its upstream, so we ++ * never delete a branch out from under one stacked on top of it. Such a ++ * base is itself merged, so when its own upstream is also going away ++ * (no surviving branch tracks it), clear the base's now-stale upstream. + */ +static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable) +{ -+ struct string_list upstreams = STRING_LIST_INIT_DUP; -+ struct string_list_item *item; -+ bool spared; -+ -+ refs_for_each_branch_ref(refs, collect_upstream, &upstreams); -+ do { -+ spared = false; -+ for_each_string_list_item(item, &upstreams) { -+ const char *up = item->util, *up_short; -+ -+ if (!up || strset_contains(deletable, item->string)) -+ continue; -+ if (!skip_prefix(up, "refs/heads/", &up_short) || -+ !strset_contains(deletable, up_short)) -+ continue; -+ -+ strset_remove(deletable, up_short); -+ spared = true; -+ } -+ } while (spared); -+ -+ string_list_clear(&upstreams, 1); ++ struct strset spared = STRSET_INIT; ++ struct spare_data data = { .deletable = deletable, .spared = &spared }; ++ struct strbuf key = STRBUF_INIT; ++ struct hashmap_iter iter; ++ struct strmap_entry *entry; ++ ++ refs_for_each_branch_ref(refs, spare_stacked_base, &data); ++ ++ strset_for_each_entry(&spared, &iter, entry) { ++ struct branch *branch = branch_get(entry->key); ++ const char *upstream = branch_get_upstream(branch, NULL); ++ const char *up_short; ++ ++ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) || ++ !strset_contains(deletable, up_short)) ++ continue; ++ ++ strbuf_reset(&key); ++ strbuf_addf(&key, "branch.%s.merge", branch->name); ++ repo_config_set_gently(the_repository, key.buf, NULL); ++ strbuf_reset(&key); ++ strbuf_addf(&key, "branch.%s.remote", branch->name); ++ repo_config_set_gently(the_repository, key.buf, NULL); ++ } ++ ++ strbuf_release(&key); ++ strset_clear(&spared); +} + +static int delete_merged_branches(int argc, const char **argv, @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch + struct ref_array candidates = { 0 }; + struct strset deletable = STRSET_INIT; + struct strvec to_delete = STRVEC_INIT; ++ struct hashmap_iter iter; ++ struct strmap_entry *entry; + int i, ret = 0; + + if (!argc) @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch + + spare_stacked_bases(refs, &deletable); + -+ for (i = 0; i < candidates.nr; i++) { -+ const char *short_name; -+ -+ if (skip_prefix(candidates.items[i]->refname, "refs/heads/", -+ &short_name) && -+ strset_contains(&deletable, short_name)) -+ strvec_push(&to_delete, short_name); -+ } ++ strset_for_each_entry(&deletable, &iter, entry) ++ strvec_push(&to_delete, entry->key); + + if (to_delete.nr) + ret = delete_branches(to_delete.nr, to_delete.v, @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' ' + git checkout --detach + ) && + ++ git -C repo branch --dry-run --delete-merged origin/next >out && ++ test_grep ! "feature" out && ++ + git -C repo branch --delete-merged origin/next 2>err && + + test_must_be_empty err && + git -C repo rev-parse --verify refs/heads/feature && -+ git -C repo rev-parse --verify refs/heads/topic ++ git -C repo rev-parse --verify refs/heads/topic && ++ echo origin/next >expect && ++ git -C repo rev-parse --abbrev-ref feature@{upstream} >actual && ++ test_cmp expect actual && ++ echo feature >expect && ++ git -C repo rev-parse --abbrev-ref topic@{upstream} >actual && ++ test_cmp expect actual +' + +test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' ' @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' ' + EOF + test_cmp expect actual +' ++ ++test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' ' ++ test_when_finished "rm -rf repo" && ++ setup_repo_for_delete_merged && ++ ( ++ cd repo && ++ git branch lower origin/next && ++ git branch --set-upstream-to=origin/next lower && ++ git branch mid origin/next && ++ git branch --set-upstream-to=lower mid && ++ git checkout -b tip mid && ++ git commit --allow-empty -m "tip work" && ++ git branch --set-upstream-to=mid tip && ++ git checkout --detach ++ ) && ++ ++ git -C repo branch --delete-merged origin/next lower && ++ ++ test_must_fail git -C repo rev-parse --verify refs/heads/lower && ++ git -C repo rev-parse --verify refs/heads/mid && ++ test_must_fail git -C repo rev-parse mid@{upstream} && ++ echo mid >expect && ++ git -C repo rev-parse --abbrev-ref tip@{upstream} >actual && ++ test_cmp expect actual ++' + test_done 6: 27903fbb1d ! 6: d52d717b70 branch: add branch.<name>.deleteMerged opt-out @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv, struct strset deletable = STRSET_INIT; struct strvec to_delete = STRVEC_INIT; + struct strbuf key = STRBUF_INIT; + struct hashmap_iter iter; + struct strmap_entry *entry; + bool quiet = flags & DELETE_BRANCH_QUIET; int i, ret = 0; @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv, ref_array_clear(&candidates); ## t/t3200-branch.sh ## -@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch +@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the upstream of a kept base whose ow test_cmp expect actual '7: 49c1bcf1fb ! 7: 8d0323f4b3 branch: add --dry-run for --delete-merged @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>DESCRIPTION
-----------
-@@Documentation/git-branch.adoc: A branch that another, surviving branch still tracks as its upstream
- is kept, so a branch is never deleted out from under one stacked on
- top of it.
+@@Documentation/git-branch.adoc: kept, so a branch is never deleted out from under one stacked on top
+ of it. If that kept branch in turn tracks a branch that is being
+ deleted, its now-stale upstream configuration is cleared.+
--dry-run::
+ With--delete-merged, print which branches would be
v19 cover branch: delete-merged
In reply to Harald Nordgren via GitGitGadgetDelete branches that have already been merged on upstream.
Changes in v19:
* Fix bug where dry-run would still remove config, added test coverage.
* Redesigned --delete-merged as a repeatable upstream selector with optional positional patterns limiting deletion scope.
* Protect same-name upstream branches independently of push-default configuration.
* Simplified flags handling where local caching became complicated when mutating values.
* Clarified assertions in tests.
Changes in v18:
* Instead of keeping the whole chain of upstream branches, keep only the ones an unmerged branch still needs. When a kept (merged) branch in turn tracks a branch that is being deleted, clear its now-stale upstream config.
* Rework spare_stacked_bases() to record the kept bases and, in a second pass, clear the upstream of any whose own base is going away. Build the to-delete list with strset_for_each_entry() instead of re-walking the candidate array.
Changes in v17:
* Keep a merged branch when another surviving branch still tracks it as its upstream, so --delete-merged no longer deletes a branch out from under one stacked on top of it.
* Move the --dry-run and branch.<name>.deleteMerged opt-out fully into their own commits.
Changes in v16:
* Convert delete_merged_branches() to take an unsigned int flags argument instead of separate quiet/dry_run booleans, matching delete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_reset per iteration, single strbuf_release after) instead of allocating and freeing it each time
* Rewrite the --delete-merged tests as integration tests: branches that land commits upstream, with deletion and the checked-out, upstream-gone, and push-equals-upstream safety cases exercised together in one run and output asserted via test_cmp
* Collapse the many per-aspect test repos into a single reused repo set up by a setup_repo_for_delete_merged helper, and rename helpers off the old pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of prefixing every command with -C
Changes in v15:
* Renamed --prune-merged to --delete-merged throughout. Not necessarily final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of warning.
* Initialized the delete_branches() flag locals where declared. Only force stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f, --forked coverage), renamed the misleading trunk fixture, and replaced the misnamed detached branch with git checkout --detach.
Changes in v14:
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the remotes path set a local force but not the DELETE_BRANCH_FORCE bit that check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other branches too, hopefully not related
(https://github.com/git/git/pull/2285).
Changes in v13:
* Reworked --forked into a real ref-filter applied in apply_ref_filter() instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the abbreviated upstream), and dropped the old helper machinery, forward declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git branch --prune-merged origin/main 'feature*') instead of repeating the option.
Changes in v12:
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST options.
* Dropped the bare-remote-name resolution for --forked, the argument is now a ref or a glob.
Changes in v11:
* 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 /HEAD by name guard that the reviewer rejected is gone.
* New --dry-run for --prune-merged.
Changes in v10:
* --forked / --prune-merged now take a branch glob instead of a remote name — origin, origin/*, origin/release-- all work. This replaces the remote-only form and subsumes the old --all-remotes flag, which has been dropped.
* New --dry-run for --prune-merged.
Changes in v9:
* --force no longer has special meaning with --prune-merged; reachability is always enforced. Use git branch -D to delete an unmerged branch. Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.
Changes in v8:
* Delete only when the branch's work is actually reachable from its upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place
Changes in v7:
* --prune-merged now checks if a branch is merged into its own upstream first. If the upstream is gone, it checks against the remote's default branch instead. If neither exists, the branch is refused (use --force to delete anyway).
Changes in v6:
* --prune-merged now measures merged-ness against the remote's default branch instead of the candidate's upstream — so the decision no longer depends on which branch happens to be checked out locally.
* delete_branches() / check_branch_commit() gained a per-candidate override that lets a caller substitute a different "what counts as merged" reference (or skip the check). branch -d callers pass NULL and keep their existing semantics.
* prune_merged_branches() resolves each candidate's push-remote HEAD and threads it through, so --prune-merged --all-remotes measures each candidate against its own remote rather than a single global reference.
Changes in v5:
* Drop commit 'fetch: add --prune-merged'
Changes in v4:
* Resolve each remote's HEAD and collect the targets into a protected_default_refs set in collect_forked_set.
* In prune_merged_branches, skip a candidate when its upstream is a protected default ref and the local branch name matches the default branch's leaf name (so a local main tracking origin/main is spared, but a renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name (not by upstream alone); spare a branch whose push ref is the remote default.
Changes in v3:
* s/remote-tracking refs/remote-tracking branches/g
Changes in v2:
* The whole feature moved out of git fetch and into git branch. git fetch --prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their tip is reachable from the upstream tracking ref; --force skips that safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running topic branch) even with --force; doesn't affect explicit git branch -d.
* delete_branches() got a warn_only mode so bulk deletion prints a one-line warning per skipped branch instead of the noisy four-line hint that git branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention --prune-merged.
* New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh shrunk since most logic moved.
Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <branch>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged
Documentation/config/branch.adoc | 7 + Documentation/git-branch.adoc | 50 ++++- builtin/branch.c | 284 ++++++++++++++++++++++--- ref-filter.c | 70 ++++++ ref-filter.h | 10 + t/t3200-branch.sh | 352 +++++++++++++++++++++++++++++++ 6 files changed, 742 insertions(+), 31 deletions(-)
base-commit: f60db8d575adb79761d363e026fb49bddf330c73
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v19
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v19
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v18:
1: 3e29ff17bd ! 1: 562648132d branch: add --forked filter for --list mode
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ (
+ cd forked &&
+ git remote add -f other ../forked-other &&
-+ git remote set-head origin one &&
+ git branch local-base &&
+ git branch --track local-one origin/one &&
+ git branch --track local-two origin/two &&
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ git checkout local-one &&
+ test_commit --no-tag local-one-work local-one.t &&
+ git checkout local-foreign &&
-+ test_commit --no-tag local-foreign-work local-foreign.t &&
-+ git checkout --detach
++ test_commit --no-tag local-foreign-work local-foreign.t
+ )
+'
+
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked composes with --no-merged' '
-+ test_when_finished "git -C forked checkout --detach" &&
-+ 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_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '
+ git -C forked branch --forked origin --format="%(refname:short)" >actual &&
-+ echo local-one >expect &&
++ echo main >expect &&
+ test_cmp expect actual
+'
+
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ EOF
+ test_cmp expect actual
+'
++
++test_expect_success '--forked rejects unknown branch/pattern' '
++ test_must_fail git -C forked branch --forked nope 2>err &&
++ test_grep "not a valid branch or pattern" err
++'
++
++test_expect_success '--forked requires a value' '
++ test_must_fail git -C forked branch --forked 2>err &&
++ test_grep "requires a value" err
++'
+
test_done 2: cdd4fea4a7 ! 2: c7ebd9344c branch: convert delete_branches() to a flags argument
@@ Metadata
## Commit message ##
branch: convert delete_branches() to a flags argument
- delete_branches() and check_branch_commit() take a pair of int
- booleans (force and quiet) that the next commits would grow further.
- Replace them with a single "unsigned int flags" argument and an
- enum, splitting the bits back into named bool locals so the body
- keeps reading the same named values.
+ delete_branches() takes separate force and quiet parameters, while
+ check_branch_commit() takes force. The next commits would grow this
+ collection further. Replace them with a single unsigned flags argument
+ and an enum.
+
+ Test the FORCE and QUIET bits directly from flags at each use site so
+ that mutating or forwarding flags cannot leave cached values stale.
No change in behavior.
@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
- int kinds, int force)
+ int kinds, unsigned int flags)
{
-+ bool force = flags & DELETE_BRANCH_FORCE;
struct commit *rev = lookup_commit_reference(the_repository, oid);
- if (!force && !rev) {
+- if (!force && !rev) {
++ if (!(flags & DELETE_BRANCH_FORCE) && !rev) {
error(_("couldn't look up commit object for '%s'"), refname);
+ return -1;
+ }
+- if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
++ if (!(flags & DELETE_BRANCH_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, "
@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
strbuf_release(&buf);
}
@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
{
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,
- int i;
- int ret = 0;
- int remote_branch = 0;
-+ bool force;
-+ bool quiet = flags & DELETE_BRANCH_QUIET;
- struct strbuf bname = STRBUF_INIT;
- enum interpret_branch_kind allowed_interpret;
- struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
remote_branch = 1;
allowed_interpret = INTERPRET_BRANCH_REMOTE;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
}
branch_name_pos = strcspn(fmt, "%");
-+ force = flags & DELETE_BRANCH_FORCE;
-+
- if (!force)
+- if (!force)
++ if (!(flags & DELETE_BRANCH_FORCE))
head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
: repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));
next:
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+ char *name = item->string;
+ if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
+ char *refname = name + branch_name_pos;
+- if (!quiet)
++ if (!(flags & DELETE_BRANCH_QUIET))
+ printf(remote_branch
+ ? _("Deleted remote-tracking branch %s (was %s).\n")
+ : _("Deleted branch %s (was %s).\n"),
@@ builtin/branch.c: int cmd_branch(int argc,
if (delete) {
if (!argc)
3: a0fd5b4a6c ! 3: 0c4f3358e3 branch: let delete_branches skip unmerged branches on bulk refusal
@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
static int check_branch_commit(const char *branchname, const char *refname,
@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,
- int kinds, unsigned int flags)
- {
- bool force = flags & DELETE_BRANCH_FORCE;
-+ bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
- 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)) {
+ if (!(flags & DELETE_BRANCH_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 (!skip_unmerged) {
++ if (!(flags & DELETE_BRANCH_SKIP_UNMERGED)) {
+ error(_("the branch '%s' is not fully merged"),
+ branchname);
+ advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const c
return -1;
}
return 0;
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
- int remote_branch = 0;
- bool force;
- bool quiet = flags & DELETE_BRANCH_QUIET;
-+ bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
- struct strbuf bname = STRBUF_INIT;
- enum interpret_branch_kind allowed_interpret;
- struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
flags)) {
- ret = 1;
-+ if (!skip_unmerged)
++ if (!(flags & DELETE_BRANCH_SKIP_UNMERGED))
+ ret = 1;
goto next;
}
4: a56d8fe93e ! 4: 64a202526a branch: prepare delete_branches for a bulk caller
@@ Metadata
## Commit message ##
branch: prepare delete_branches for a bulk caller
- Teach delete_branches() two new modes for the upcoming
- --delete-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.
+ Teach delete_branches() a new mode for the upcoming --delete-merged
+ caller that checks whether a branch is merged into its upstream without
+ falling back to HEAD when there is no upstream. Existing callers keep
+ their current behavior.
@@ builtin/branch.c: enum delete_branch_flags {
static int check_branch_commit(const char *branchname, const char *refname,
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
- bool force;
- bool quiet = flags & DELETE_BRANCH_QUIET;
- bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
-+ bool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
- struct strbuf bname = STRBUF_INIT;
- enum interpret_branch_kind allowed_interpret;
- struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds, -
- force = flags & DELETE_BRANCH_FORCE;
+ }
+ branch_name_pos = strcspn(fmt, "%");
-- if (!force)
-+ if (!force && !no_head_fallback)
+- if (!(flags & DELETE_BRANCH_FORCE))
++ if (!(flags & DELETE_BRANCH_FORCE) &&
++ !(flags & DELETE_BRANCH_NO_HEAD_FALLBACK))
head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
5: a84c555d99 ! 5: a6caa5b397 branch: add --delete-merged <branch>
@@ Metadata
## Commit message ##
branch: add --delete-merged <branch>
- git branch --delete-merged <branch>...
+ git branch (--delete-merged <branch>)... [<pattern>...]
- deletes the local branches that "--forked <branch>" 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.
+ deletes local branches matching the optional patterns when their
+ configured upstream matches one of the --delete-merged arguments and
+ their tip is reachable from that upstream. The work has already landed
+ on the upstream they track, so the local copy is no longer needed.
+
+ The option can be repeated to widen the upstream match. Keeping the
+ candidate patterns as positional arguments lets users bound the set of
+ local branches that may be deleted independently of the upstream
+ selection.
A branch is not deleted when:
* it is checked out in any worktree
- * its upstream remote-tracking branch no longer exists, since a
- missing upstream is not by itself a sign of integration
- * its push destination equals its upstream (<branch>@{push} is
- the same as <branch>@{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 kept. Only branches that push
- somewhere other than their upstream, typically topics in a fork
- workflow, are candidates.
+ * its configured upstream ref no longer exists, since a missing
+ upstream is not by itself a sign of integration
+ * pushing it by name to the remote configured by
+ branch.<name>.remote would update its upstream, as determined by
+ mapping the branch ref through that remote's fetch refspec. For
+ example, a local "main" that tracks "origin/main" is kept even when
+ remote.pushDefault names a fork. Right after a pull it merely looks
+ fully merged.
A branch whose work is not yet merged into its upstream is silently
skipped, so one unmerged topic does not abort the whole sweep.
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-+git branch --delete-merged <branch>...
++git branch (--delete-merged <branch>)... [<pattern>...]
DESCRIPTION
-----------
@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
-+`--delete-merged <branch>...`::
-+ Delete the local branches that `--forked` would list for the
-+ given _<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. Several
-+ _<branch>_ patterns may be given, e.g. `git branch
-+ --delete-merged origin/main 'feature*'`.
++`--delete-merged <branch>`::
++ Delete local branches whose configured upstream matches
++ _<branch>_, but only when their tip is reachable from that
++ upstream. In other words, the work on the branch has already
++ landed on the upstream it tracks, so the local copy is no longer
++ needed. The option can be repeated to widen the upstream match.
++ Optional _<pattern>_ arguments limit which local branches are
++ considered, e.g. `git branch --delete-merged 'origin/*'
++ 'topic-*'`.
++
+A branch is not deleted when:
++
+--
-+* its upstream remote-tracking branch no longer exists,
++* its configured upstream ref no longer exists,
+* 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
-+ branch that just looks "fully merged" right after a pull.
++* pushing it by name to the remote configured by
++ `branch.<name>.remote` would update its upstream, so it cannot be
++ distinguished from a branch that just looks "fully merged" right
++ after a pull.
+--
++
+A branch whose work has not yet been merged into its upstream is
@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
-+ N_("git branch [<options>] --delete-merged <branch>..."),
++ N_("git branch [<options>] (--delete-merged <branch>)... [<pattern>...]"),
NULL
};
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ strset_clear(&spared);
+}
+
-+static int delete_merged_branches(int argc, const char **argv,
-+ unsigned int flags)
++static int branch_pushes_to_upstream(struct branch *branch,
++ const char *upstream)
++{
++ struct remote *remote = remote_get(remote_for_branch(branch, NULL));
++ char *tracking = NULL;
++ int ret = 0;
++
++ if (remote)
++ tracking = apply_refspecs(&remote->fetch, branch->refname);
++ if (tracking && !strcmp(tracking, upstream))
++ ret = 1;
++
++ free(tracking);
++ return ret;
++}
++
++static int delete_merged_branches(const struct strvec *upstreams,
++ const char **argv, unsigned int flags)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct ref_filter filter = REF_FILTER_INIT;
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ struct strvec to_delete = STRVEC_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
-+ int i, ret = 0;
++ size_t i;
++ int ret = 0;
+
-+ if (!argc)
-+ die(_("--delete-merged requires at least one <branch>"));
-+
-+ 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]);
++ for (i = 0; i < upstreams->nr; i++)
++ if (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)
++ die(_("'%s' is not a valid branch or pattern"),
++ upstreams->v[i]);
+
+ filter.kind = FILTER_REFS_BRANCHES;
++ filter.name_patterns = argv;
+ filter_refs(&candidates, &filter, filter.kind);
+
-+ for (i = 0; i < candidates.nr; i++) {
++ for (i = 0; i < (size_t)candidates.nr; i++) {
+ const char *full_name = candidates.items[i]->refname;
+ const char *short_name;
+ struct branch *branch;
-+ const char *upstream, *push;
++ const char *upstream;
+
+ if (!skip_prefix(full_name, "refs/heads/", &short_name))
+ BUG("filter returned non-branch ref '%s'", full_name);
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream || !refs_ref_exists(refs, upstream))
+ continue;
-+ push = branch_get_push(branch, NULL);
-+ if (!push || !strcmp(push, upstream))
++ if (branch_pushes_to_upstream(branch, upstream))
+ continue;
+ if (check_branch_commit(short_name, short_name,
+ &candidates.items[i]->objectname, NULL,
@@ builtin/branch.c: 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 delete_merged = 0;
++ struct strvec delete_merged = STRVEC_INIT;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: 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, "delete-merged", &delete_merged,
-+ N_("delete local branches whose upstream matches <branch> and are merged")),
++ OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("branch"),
++ N_("delete merged branches whose upstream matches <branch> (repeatable)"),
++ PARSE_OPT_NONEG, parse_opt_strvec),
OPT__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,
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
-+ !show_current && !unset_upstream && !delete_merged &&
++ !show_current && !unset_upstream && !delete_merged.nr &&
+ argc == 0)
list = 1;
@@ builtin/branch.c: int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
-+ !!unset_upstream + !!delete_merged;
++ !!unset_upstream + !!delete_merged.nr;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ builtin/branch.c: int cmd_branch(int argc,
(delete > 1 ? DELETE_BRANCH_FORCE : 0) |
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
-+ } else if (delete_merged) {
-+ ret = delete_merged_branches(argc, argv,
++ } else if (delete_merged.nr) {
++ ret = delete_merged_branches(&delete_merged, argv,
+ quiet ? DELETE_BRANCH_QUIET : 0);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
+@@ builtin/branch.c: int cmd_branch(int argc,
+ ret = 0;
+
+ out:
++ strvec_clear(&delete_merged);
+ string_list_clear(&sorting_options, 0);
+ return ret;
+ }
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
- test_cmp expect actual
+@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ test_grep "requires a value" err
'
+test_expect_success '--delete-merged: setup' '
@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
+ cd repo &&
+ git remote add fork ../fork &&
+ git remote add other ../other &&
-+ git config remote.pushDefault fork &&
+ git config push.default current &&
+ git fetch other
+ )
+}
+
-+merged_branch () {
++create_merged_branch () {
+ (
+ cd repo &&
-+ git checkout -b "$1" "$2" &&
++ git checkout -b "$1" origin/next --track &&
+ git commit --allow-empty -m "$1 work" &&
-+ git push origin "$1:next" &&
-+ git fetch origin &&
-+ git branch --set-upstream-to="$2" "$1"
++ git push origin "$1:next"
+ )
+}
+
-+test_expect_success '--delete-merged deletes merged branches and spares the rest' '
-+ test_when_finished "rm -rf repo" &&
-+ setup_repo_for_delete_merged &&
-+ merged_branch merged origin/next &&
-+ (
-+ cd repo &&
-+ git checkout -b unmerged origin/next &&
-+ git commit --allow-empty -m "unmerged work" &&
-+ git branch --set-upstream-to=origin/next unmerged &&
-+ git checkout -b tracks-other other/main &&
-+ git branch --set-upstream-to=other/main tracks-other &&
-+ git checkout --detach
-+ ) &&
-+ sha=$(git -C repo rev-parse --short merged) &&
-+
-+ git -C repo branch --delete-merged origin/next >actual 2>&1 &&
-+
-+ echo "Deleted branch merged (was $sha)." >expect &&
-+ test_cmp expect actual &&
-+ git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
-+ cat >expect <<-\EOF &&
-+ main
-+ tracks-other
-+ unmerged
-+ EOF
++check_branches () {
++ git for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
++ cat >expect &&
+ test_cmp expect actual
-+'
++}
+
-+test_expect_success '--delete-merged deletes merged branches and spares protected ones' '
-+ test_when_finished "rm -rf repo" &&
++test_expect_success '--delete-merged keeps cloned main without a default push remote' '
+ setup_repo_for_delete_merged &&
-+ merged_branch on-next origin/next &&
-+ merged_branch checked-out origin/next &&
-+ merged_branch upstream-gone origin/next &&
+ (
+ cd repo &&
-+ git checkout -b mainline main &&
-+ git checkout -b on-local mainline &&
-+ git branch --set-upstream-to=mainline on-local &&
-+ git update-ref refs/remotes/origin/topic refs/remotes/origin/next &&
-+ git branch --set-upstream-to=origin/topic upstream-gone &&
-+ git update-ref -d refs/remotes/origin/topic &&
-+ git branch --set-upstream-to=origin/main main &&
-+ git config branch.main.pushRemote origin &&
-+ git checkout -b tracks-other other/main &&
-+ git branch --set-upstream-to=other/main tracks-other &&
-+ git checkout checked-out
-+ ) &&
++ git checkout --detach &&
+
-+ git -C repo branch --delete-merged origin/next mainline &&
++ git branch --delete-merged */* &&
+
-+ git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
-+ cat >expect <<-\EOF &&
-+ checked-out
-+ main
-+ mainline
-+ tracks-other
-+ upstream-gone
-+ EOF
-+ test_cmp expect actual
++ check_branches <<-\EOF
++ main
++ EOF
++ )
+'
+
-+test_expect_success '--delete-merged requires at least one <branch>' '
-+ test_must_fail git -C forked branch --delete-merged 2>err &&
-+ test_grep "requires at least one <branch>" err
++test_expect_success '--delete-merged deletes only selected merged branches' '
++ setup_repo_for_delete_merged &&
++ create_merged_branch also-merged &&
++ create_merged_branch merged &&
++ (
++ cd repo &&
++ git checkout -b unmerged origin/next --track &&
++ git commit --allow-empty -m "unmerged work" &&
++ git checkout -b tracks-other other/main --track &&
++ sha=$(git rev-parse --short merged) &&
++
++ git branch --delete-merged origin/next merged >actual 2>&1 &&
++ echo "Deleted branch merged (was $sha)." >expect &&
++ test_cmp expect actual &&
++
++ check_branches <<-\EOF
++ also-merged
++ main
++ tracks-other
++ unmerged
++ EOF
++ )
+'
+
-+test_expect_success '--delete-merged keeps a branch that is an upstream' '
-+ test_when_finished "rm -rf repo" &&
++test_expect_success '--delete-merged keeps main despite a different default push remote' '
+ setup_repo_for_delete_merged &&
-+ merged_branch feature origin/next &&
++ create_merged_branch on-next &&
++ create_merged_branch checked-out &&
++ create_merged_branch upstream-gone &&
+ (
+ cd repo &&
-+ git checkout -b topic feature &&
-+ git commit --allow-empty -m "topic work" &&
-+ git branch --set-upstream-to=feature topic &&
-+ git checkout --detach
-+ ) &&
-+
-+ git -C repo branch --dry-run --delete-merged origin/next >out &&
-+ test_grep ! "feature" out &&
-+
-+ git -C repo branch --delete-merged origin/next 2>err &&
-+
-+ test_must_be_empty err &&
-+ git -C repo rev-parse --verify refs/heads/feature &&
-+ git -C repo rev-parse --verify refs/heads/topic &&
-+ echo origin/next >expect &&
-+ git -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&
-+ test_cmp expect actual &&
-+ echo feature >expect &&
-+ git -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&
-+ test_cmp expect actual
++ git config remote.pushDefault fork &&
++ git checkout -b local-to-delete main --track &&
++ git update-ref refs/remotes/origin/topic refs/remotes/origin/next &&
++ git branch --set-upstream-to=origin/topic upstream-gone &&
++ git update-ref -d refs/remotes/origin/topic &&
++ git checkout -b tracks-other other/main --track &&
++ git checkout checked-out &&
++
++ git branch --delete-merged origin/* \
++ --delete-merged main &&
++
++ check_branches <<-\EOF
++ checked-out
++ main
++ tracks-other
++ upstream-gone
++ EOF
++ )
+'
+
-+test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '
-+ test_when_finished "rm -rf repo" &&
++test_expect_success '--delete-merged keeps the upstream of a surviving branch' '
+ setup_repo_for_delete_merged &&
++ create_merged_branch feature &&
+ (
+ cd repo &&
-+ git branch b3 origin/next &&
-+ git branch --set-upstream-to=origin/next b3 &&
-+ git branch b2 origin/next &&
-+ git branch --set-upstream-to=b3 b2 &&
-+ git checkout -b b1 b2 &&
-+ git commit --allow-empty -m "b1 work" &&
-+ git branch --set-upstream-to=b2 b1 &&
-+ git checkout --detach
-+ ) &&
-+
-+ git -C repo branch --delete-merged origin/next &&
++ git checkout -b topic feature --track &&
++ git commit --allow-empty -m "topic work" &&
+
-+ git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
-+ cat >expect <<-\EOF &&
-+ b1
-+ b2
-+ b3
-+ main
-+ EOF
-+ test_cmp expect actual
++ git branch --delete-merged origin/next 2>err &&
++
++ test_must_be_empty err &&
++ check_branches <<-\EOF &&
++ feature
++ main
++ topic
++ EOF
++
++ git config --local --get-regexp "branch\\.(feature|topic)\\.(merge|remote)" >actual &&
++ cat >expect <<-\EOF &&
++ branch.feature.remote origin
++ branch.feature.merge refs/heads/next
++ branch.topic.remote .
++ branch.topic.merge refs/heads/feature
++ EOF
++ test_cmp expect actual
++ )
+'
+
-+test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '
-+ test_when_finished "rm -rf repo" &&
++test_expect_success '--delete-merged clears the deleted upstream of a spared branch' '
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
-+ git branch lower origin/next &&
-+ git branch --set-upstream-to=origin/next lower &&
-+ git branch mid origin/next &&
-+ git branch --set-upstream-to=lower mid &&
-+ git checkout -b tip mid &&
++ git config remote.pushDefault fork &&
++ git branch lower origin/next --track &&
++ git branch mid lower --track &&
++ git checkout -b tip mid --track &&
+ git commit --allow-empty -m "tip work" &&
-+ git branch --set-upstream-to=mid tip &&
-+ git checkout --detach
-+ ) &&
-+
-+ git -C repo branch --delete-merged origin/next lower &&
+
-+ test_must_fail git -C repo rev-parse --verify refs/heads/lower &&
-+ git -C repo rev-parse --verify refs/heads/mid &&
-+ test_must_fail git -C repo rev-parse mid@{upstream} &&
-+ echo mid >expect &&
-+ git -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&
-+ test_cmp expect actual
++ git branch --delete-merged origin/next \
++ --delete-merged lower &&
++
++ check_branches <<-\EOF &&
++ main
++ mid
++ tip
++ EOF
++
++ git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
++ cat >expect <<-\EOF &&
++ branch.tip.remote .
++ branch.tip.merge refs/heads/mid
++ EOF
++ test_cmp expect actual
++ )
+'
+
++test_expect_success '--delete-merged requires a value' '
++ test_must_fail git -C forked branch --delete-merged 2>err &&
++ test_grep "requires a value" err
++'
test_done
6: d52d717b70 ! 6: 734d27c908 branch: add branch.<name>.deleteMerged opt-out
@@ Documentation/git-branch.adoc
@@ Documentation/git-branch.adoc: A branch is not deleted when:
+
--
- * its upstream remote-tracking branch no longer exists,
+ * its configured upstream ref no longer exists,
-* it is checked out in any worktree, or
+* it is checked out in any worktree,
- * its push destination (`<branch>@{push}`) equals its upstream
- (`<branch>@{upstream}`), so it cannot be distinguished from a
-- branch that just looks "fully merged" right after a pull.
-+ branch that just looks "fully merged" right after a pull, or
+ * pushing it by name to the remote configured by
+ `branch.<name>.remote` would update its upstream, so it cannot be
+ distinguished from a branch that just looks "fully merged" right
+- after a pull.
++ after a pull, or
+* `branch.<name>.deleteMerged` is set to `false`.
--
+
A branch whose work has not yet been merged into its upstream is
## builtin/branch.c ##
-@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
struct ref_array candidates = { 0 };
struct strset deletable = STRSET_INIT;
struct strvec to_delete = STRVEC_INIT;
+ struct strbuf key = STRBUF_INIT;
struct hashmap_iter iter;
struct strmap_entry *entry;
-+ bool quiet = flags & DELETE_BRANCH_QUIET;
- int i, ret = 0;
-
- if (!argc)
-@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+ size_t i;
+@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
const char *short_name;
struct branch *branch;
- const char *upstream, *push;
+ const char *upstream;
+ int opt_out;
if (!skip_prefix(full_name, "refs/heads/", &short_name))
BUG("filter returned non-branch ref '%s'", full_name);
-@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
continue;
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+ strbuf_addf(&key, "branch.%s.deletemerged", short_name);
+ if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+ !opt_out) {
-+ if (!quiet)
++ if (!(flags & DELETE_BRANCH_QUIET))
+ fprintf(stderr,
+ _("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
+ short_name, short_name);
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
strset_add(&deletable, short_name);
}
-@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
DELETE_BRANCH_NO_HEAD_FALLBACK |
flags);
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
ref_array_clear(&candidates);
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the upstream of a kept base whose ow
- test_cmp expect actual
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged requires a value' '
+ test_must_fail git -C forked branch --delete-merged 2>err &&
+ test_grep "requires a value" err
'
-
++
+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '
-+ test_when_finished "rm -rf repo" &&
+ setup_repo_for_delete_merged &&
-+ merged_branch deleted origin/next &&
-+ merged_branch kept origin/next &&
-+ git -C repo config branch.kept.deleteMerged false &&
-+ git -C repo checkout --detach &&
++ create_merged_branch deleted &&
++ create_merged_branch kept &&
++ (
++ cd repo &&
++ git config branch.kept.deleteMerged false &&
++ git checkout --detach &&
+
-+ git -C repo branch --delete-merged origin/next 2>err &&
++ git branch --delete-merged origin/next 2>err &&
+
-+ test_grep "Skipping .kept." err &&
-+ test_must_fail git -C repo rev-parse --verify refs/heads/deleted &&
-+ git -C repo rev-parse --verify refs/heads/kept
++ test_grep "Skipping .kept." err &&
++ check_branches <<-\EOF
++ kept
++ main
++ EOF
++ )
+'
+
+test_expect_success "branch -d still deletes a deleteMerged=false branch" '
-+ test_when_finished "rm -rf repo" &&
+ setup_repo_for_delete_merged &&
-+ merged_branch kept origin/next &&
-+ git -C repo config branch.kept.deleteMerged false &&
-+ git -C repo checkout --detach &&
++ create_merged_branch kept &&
++ (
++ cd repo &&
++ git config branch.kept.deleteMerged false &&
++ git checkout --detach &&
++
++ git branch -d kept &&
+
-+ git -C repo branch -d kept &&
-+ test_must_fail git -C repo rev-parse --verify refs/heads/kept
++ check_branches <<-\EOF
++ main
++ EOF
++ )
+'
+
test_done
7: 8d0323f4b3 ! 7: 7aa9d5db14 branch: add --dry-run for --delete-merged
@@ Metadata
## Commit message ##
branch: add --dry-run for --delete-merged
- With --dry-run, --delete-merged prints the local branches it would
- delete, one "Would delete branch <name>" 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.
+ "git branch --dry-run --delete-merged ..." prints one line per ref that
+ would be deleted without modifying refs or branch configuration.
--dry-run is only meaningful together with --delete-merged and is
rejected otherwise.
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
--git branch --delete-merged <branch>...
-+git branch [--dry-run] --delete-merged <branch>...
+-git branch (--delete-merged <branch>)... [<pattern>...]
++git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...]
DESCRIPTION
-----------
@@ builtin/branch.c: enum delete_branch_flags {
};
static int check_branch_commit(const char *branchname, const char *refname,
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
- bool quiet = flags & DELETE_BRANCH_QUIET;
- bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
- bool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
-+ bool dry_run = flags & DELETE_BRANCH_DRY_RUN;
- struct strbuf bname = STRBUF_INIT;
- enum interpret_branch_kind allowed_interpret;
- struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ builtin/branch.c: 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 &&
++ if (!(flags & DELETE_BRANCH_DRY_RUN) &&
+ refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
ret = 1;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki
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)
++ if (flags & DELETE_BRANCH_DRY_RUN) {
++ if (!(flags & DELETE_BRANCH_QUIET))
+ printf(remote_branch
+ ? _("Would delete remote-tracking branch %s (was %s).\n")
+ : _("Would delete branch %s (was %s).\n"),
+ name + branch_name_pos, describe_ref);
+ } else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
char *refname = name + branch_name_pos;
- if (!quiet)
+ if (!(flags & DELETE_BRANCH_QUIET))
printf(remote_branch
+@@ builtin/branch.c: static int spare_stacked_base(const struct reference *ref, void *cb_data)
+ * base is itself merged, so when its own upstream is also going away
+ * (no surviving branch tracks it), clear the base's now-stale upstream.
+ */
+-static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
++static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable,
++ unsigned int flags)
+ {
+ struct strset spared = STRSET_INIT;
+ struct spare_data data = { .deletable = deletable, .spared = &spared };
+@@ builtin/branch.c: static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable
+
+ refs_for_each_branch_ref(refs, spare_stacked_base, &data);
+
+- strset_for_each_entry(&spared, &iter, entry) {
+- struct branch *branch = branch_get(entry->key);
+- const char *upstream = branch_get_upstream(branch, NULL);
+- const char *up_short;
++ if (!(flags & DELETE_BRANCH_DRY_RUN)) {
++ strset_for_each_entry(&spared, &iter, entry) {
++ struct branch *branch = branch_get(entry->key);
++ const char *upstream = branch_get_upstream(branch, NULL);
++ const char *up_short;
+
+- if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
+- !strset_contains(deletable, up_short))
+- continue;
++ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
++ !strset_contains(deletable, up_short))
++ continue;
+
+- strbuf_reset(&key);
+- strbuf_addf(&key, "branch.%s.merge", branch->name);
+- repo_config_set_gently(the_repository, key.buf, NULL);
+- strbuf_reset(&key);
+- strbuf_addf(&key, "branch.%s.remote", branch->name);
+- repo_config_set_gently(the_repository, key.buf, NULL);
++ strbuf_reset(&key);
++ strbuf_addf(&key, "branch.%s.merge", branch->name);
++ repo_config_set_gently(the_repository, key.buf, NULL);
++ strbuf_reset(&key);
++ strbuf_addf(&key, "branch.%s.remote", branch->name);
++ repo_config_set_gently(the_repository, key.buf, NULL);
++ }
+ }
+
+ strbuf_release(&key);
+@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
+ strset_add(&deletable, short_name);
+ }
+
+- spare_stacked_bases(refs, &deletable);
++ spare_stacked_bases(refs, &deletable, flags);
+
+ strset_for_each_entry(&deletable, &iter, entry)
+ strvec_push(&to_delete, entry->key);
@@ builtin/branch.c: int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
- int delete_merged = 0;
+ struct strvec delete_merged = STRVEC_INIT;
+ int dry_run = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: int cmd_branch(int argc,
- N_("edit the description for the branch")),
- OPT_BOOL(0, "delete-merged", &delete_merged,
- N_("delete local branches whose upstream matches <branch> and are merged")),
+ OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("branch"),
+ N_("delete merged branches whose upstream matches <branch> (repeatable)"),
+ PARSE_OPT_NONEG, parse_opt_strvec),
+ OPT_BOOL(0, "dry-run", &dry_run,
+ N_("with --delete-merged, only print which branches would be deleted")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
@@ builtin/branch.c: int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
-+ if (dry_run && !delete_merged)
++ if (dry_run && !delete_merged.nr)
+ die(_("--dry-run requires --delete-merged"));
+
if (recurse_submodules_explicit) {
@@ builtin/branch.c: int cmd_branch(int argc,
die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
@@ builtin/branch.c: int cmd_branch(int argc,
goto out;
- } else if (delete_merged) {
- ret = delete_merged_branches(argc, argv,
+ } else if (delete_merged.nr) {
+ ret = delete_merged_branches(&delete_merged, argv,
- quiet ? DELETE_BRANCH_QUIET : 0);
+ (quiet ? DELETE_BRANCH_QUIET : 0) |
+ (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
@@ builtin/branch.c: int cmd_branch(int argc,
print_current_branch_name();
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes merged branches and spares the rest
- ) &&
- sha=$(git -C repo rev-parse --short merged) &&
-
-- git -C repo branch --delete-merged origin/next >actual 2>&1 &&
-+ git -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 &&
-+ echo "Would delete branch merged (was $sha)." >expect &&
-+ test_cmp expect actual &&
-+ git -C repo rev-parse --verify refs/heads/merged &&
-
-+ git -C repo branch --delete-merged origin/next >actual 2>&1 &&
- echo "Deleted branch merged (was $sha)." >expect &&
- test_cmp expect actual &&
- git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected merged branches' '
+ git checkout -b tracks-other other/main --track &&
+ sha=$(git rev-parse --short merged) &&
+
++ git branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&
++ echo "Would delete branch merged (was $sha)." >expect &&
++ test_cmp expect actual &&
++ git rev-parse --verify refs/heads/merged &&
++
++ check_branches <<-\EOF &&
++ also-merged
++ main
++ merged
++ tracks-other
++ unmerged
++ EOF
++
+ git branch --delete-merged origin/next merged >actual 2>&1 &&
+ echo "Deleted branch merged (was $sha)." >expect &&
+ test_cmp expect actual &&
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a surviving branch' '
+ git checkout -b topic feature --track &&
+ git commit --allow-empty -m "topic work" &&
+
+- git branch --delete-merged origin/next 2>err &&
++ git branch --dry-run --delete-merged origin/next >out &&
++ test_grep ! "feature" out &&
+
++ git branch --delete-merged origin/next 2>err &&
+ test_must_be_empty err &&
++
+ check_branches <<-\EOF &&
+ feature
+ main
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstream of a spared bra
+ git checkout -b tip mid --track &&
+ git commit --allow-empty -m "tip work" &&
+
++ git branch --dry-run --delete-merged origin/next \
++ --delete-merged lower &&
++
++ git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
++ cat >expect <<-\EOF &&
++ branch.mid.remote .
++ branch.mid.merge refs/heads/lower
++ branch.tip.remote .
++ branch.tip.merge refs/heads/mid
++ EOF
++ test_cmp expect actual &&
++
+ git branch --delete-merged origin/next \
+ --delete-merged lower &&
+
@@ t/t3200-branch.sh: test_expect_success "branch -d still deletes a deleteMerged=false branch" '
- test_must_fail git -C repo rev-parse --verify refs/heads/kept
+ )
'
+test_expect_success '--dry-run without --delete-merged is rejected' 'Signature · 1 line
gitgitgadget
PATCH v19 1/7 branch: add --forked filter for --list mode
From: Harald Nordgren <[email protected]>
Add a --forked option to "git branch" list mode that lists only branches whose configured upstream matches <branch>. The argument can be a ref (e.g. "origin/main", "master"), a remote name like "origin" for the branch its origin/HEAD points at, or a shell 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 --delete-merged, which deletes the listed branches once they have landed on their upstream.
--- Documentation/git-branch.adoc | 12 +++- builtin/branch.c | 18 +++++- ref-filter.c | 70 ++++++++++++++++++++ ref-filter.h | 10 +++ t/t3200-branch.sh | 117 ++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 3 deletions(-)
Documentation/git-branch.adoc +11 -1
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current] [--column[=<options>] | --no-column] [--sort=<key>] [--merged [<commit>]] [--no-merged [<commit>]] [--contains [<commit>]] [--no-contains [<commit>]] + [(--forked <branch>)...] [--points-at <object>] [--format=<format>] [(-r|--remotes) | (-a|--all)] [--list] [<pattern>...]
@@ -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 _<commit>_ argument is missing it defaults to `HEAD` (i.e. the tip of the current -branch). +branch). With `--forked`, only branches whose configured upstream matches +the given branch or pattern will be listed. The command's second form creates a new branch head named _<branch-name>_ which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main". Only list branches whose tips are not reachable from _<commit>_ (`HEAD` if not specified). Implies `--list`. +`--forked <branch>`:: + Only list branches whose configured upstream matches + _<branch>_. The argument can be a ref (e.g. `origin/main`, + `master`), a remote name like `origin` for the branch its + `origin/HEAD` points at, or a shell-style glob (e.g. + `'origin/*'`). The option can be repeated to widen the + filter. Implies `--list`. + `--points-at <object>`:: Only list branches of _<object>_.
builtin/branch.c +16 -2
@@ -30,7 +30,7 @@#include "commit-reach.h" static const char *const builtin_branch_usage[] = { -N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged]" ), +N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]" ),N_ ("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]" ),N_ ("git branch [<options>] [-l] [<pattern>...]" ),N_ ("git branch [<options>] [-r] (-d | -D) <branch-name>..." ),
@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, intfree_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 <branch> (repeatable)" ), +PARSE_OPT_NONEG , parse_opt_forked),OPT_COLUMN (0 ,"column" , &colopts,N_ ("list branches in columns" )),OPT_REF_SORT (&sorting_options), OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -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 +
ref-filter.c +70 -0
88 lines not on this page · raw message
ref-filter.h +10 -0
@@ -67,6 +67,7 @@ struct ref_filter {const char **name_patterns;const char *start_after;struct strvec exclude; +struct strvec forked;struct oid_array points_at;struct commit_list *with_commit;struct commit_list *no_commit;
@@ -110,6 +111,7 @@ struct ref_format {#define REF_FILTER_INIT { \ .points_at = OID_ARRAY_INIT, \ .exclude = STRVEC_INIT, \ + .forked = STRVEC_INIT, \ }#define REF_FORMAT_INIT { \ .use_color = GIT_COLOR_UNKNOWN, \
@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);struct ref_sorting *ref_sorting_options (struct string_list *);/* Function to parse --merged and --no-merged options */ int parse_opt_merge_filter (const struct option *opt,const char *arg,int unset); +/* +* Register a --forked <branch> pattern on the filter. The argument is +* either a ref, which is resolved to its full refname, or a shell-style +* glob. Branches are kept only when their configured upstream matches +* one of the registered patterns. Returns -1 if the argument is not a +* valid ref or pattern. +*/ +int ref_filter_forked_add (struct ref_filter *filter,const char *arg);/* Get the current HEAD's description */ char *get_head_description (void );/* Set up translated strings in the output. */
t/t3200-branch.sh +117 -0
121 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v19 2/7 branch: convert delete_branches() to a flags argument
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
delete_branches() takes separate force and quiet parameters, while check_branch_commit() takes force. The next commits would grow this collection further. Replace them with a single unsigned flags argument and an enum.
Test the FORCE and QUIET bits directly from flags at each use site so that mutating or forwarding flags cannot leave cached values stale.
No change in behavior.
--- builtin/branch.c | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-)
builtin/branch.c +24 -16
@@ -189,16 +189,22 @@ 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 ), +}; +static int check_branch_commit (const char *branchname,const char *refname,const struct object_id *oid,struct commit *head_rev, -int kinds,int force) +int kinds,unsigned int flags) {struct commit *rev =lookup_commit_reference (the_repository, oid); -if (!force && !rev) { +if (!(flags &DELETE_BRANCH_FORCE ) && !rev) {error (_ ("couldn't look up commit object for '%s'" ), refname);return -1 ; } -if (!force && !branch_merged (kinds, branchname, rev, head_rev)) { +if (!(flags &DELETE_BRANCH_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, "
@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)strbuf_release (&buf); } -static int delete_branches (int argc,const char **argv,int force,int kinds, -int quiet) +static int delete_branches (int argc,const char **argv,int kinds, +unsigned int flags) {struct commit *head_rev =NULL ;struct object_id oid;
@@ -241,7 +247,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 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, } branch_name_pos =strcspn (fmt,"%" ); -if (!force) +if (!(flags &DELETE_BRANCH_FORCE )) 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 +285,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 +297,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 +312,16 @@ 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)) { + flags)) { 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:
@@ -330,7 +336,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,char *name = item->string;if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos; -if (!quiet) +if (!(flags &DELETE_BRANCH_QUIET )) printf(remote_branch ? _("Deleted remote-tracking branch %s (was %s).\n") : _("Deleted branch %s (was %s).\n"),
@@ -872,7 +878,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 ();
Signature · 1 line
gitgitgadget
PATCH v19 3/7 branch: let delete_branches skip unmerged branches on bulk refusal
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Add a skip-unmerged mode to delete_branches() and check_branch_commit() so a bulk caller can silently skip branches that are not fully merged and carry on, rather than erroring with the "use 'git branch -D'" advice that the plain "git branch -d" path emits. Existing callers are unaffected.
--- builtin/branch.c | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-)
builtin/branch.c +10 -5
@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ), +DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname, }if (!(flags &DELETE_BRANCH_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_SKIP_UNMERGED )) { +error (_ ("the branch '%s' is not fully merged" ), + branchname); +advise_if_enabled (ADVICE_FORCE_DELETE_BRANCH , +_ ("If you are sure you want to delete it, " +"run 'git branch -D %s'" ), branchname); + }return -1 ; }return 0 ;
@@ -315,7 +319,8 @@ static int delete_branches(int argc, const char **argv, int kinds,if (!(ref_flags & (REF_ISSYMREF |REF_ISBROKEN )) &&check_branch_commit (bname.buf, name, &oid, head_rev, kinds, flags)) { - ret =1 ; +if (!(flags &DELETE_BRANCH_SKIP_UNMERGED )) + ret =1 ; goto next; }
Signature · 1 line
gitgitgadget
PATCH v19 4/7 branch: prepare delete_branches for a bulk caller
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Teach delete_branches() a new mode for the upcoming --delete-merged caller that checks whether a branch is merged into its upstream without falling back to HEAD when there is no upstream. Existing callers keep their current behavior.
--- builtin/branch.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-)
builtin/branch.c +9 -4
@@ -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,7 @@ enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), + DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds, } branch_name_pos =strcspn (fmt,"%" ); -if (!(flags &DELETE_BRANCH_FORCE )) +if (!(flags &DELETE_BRANCH_FORCE ) && + !(flags &DELETE_BRANCH_NO_HEAD_FALLBACK )) head_rev =lookup_commit_reference (the_repository, &head_oid);for (i =0 ; i < argc; i++,strbuf_reset (&bname)) {
Signature · 1 line
gitgitgadget
PATCH v19 5/7 branch: add --delete-merged <branch>
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
git branch (--delete-merged <branch>)... [<pattern>...]
deletes local branches matching the optional patterns when their configured upstream matches one of the --delete-merged arguments and their tip is reachable from that upstream. The work has already landed on the upstream they track, so the local copy is no longer needed.
The option can be repeated to widen the upstream match. Keeping the candidate patterns as positional arguments lets users bound the set of local branches that may be deleted independently of the upstream selection.
A branch is not deleted when:
* it is checked out in any worktree
* its configured upstream ref no longer exists, since a missing upstream is not by itself a sign of integration
* pushing it by name to the remote configured by branch.<name>.remote would update its upstream, as determined by mapping the branch ref through that remote's fetch refspec. For example, a local "main" that tracks "origin/main" is kept even when remote.pushDefault names a fork. Right after a pull it merely looks fully merged.
A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep.
A branch that another, surviving branch tracks as its upstream is also kept, so a branch is never deleted out from under one stacked on top of it. Such a kept branch is itself merged, so when its own upstream is being deleted, clear its now-stale upstream config.
--- Documentation/git-branch.adoc | 31 +++++++ builtin/branch.c | 164 ++++++++++++++++++++++++++++++++- t/t3200-branch.sh | 166 ++++++++++++++++++++++++++++++++++ 3 files changed, 359 insertions(+), 2 deletions(-)
Documentation/git-branch.adoc +31 -0
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] +git branch (--delete-merged <branch>)... [<pattern>...] DESCRIPTION -----------
@@ -201,6 +202,36 @@ This option is only applicable in non-verbose mode. Print the name of the current branch. In detached `HEAD` state, nothing is printed. +`--delete-merged <branch>`:: + Delete local branches whose configured upstream matches + _<branch>_, but only when their tip is reachable from that + upstream. In other words, the work on the branch has already + landed on the upstream it tracks, so the local copy is no longer + needed. The option can be repeated to widen the upstream match. + Optional _<pattern>_ arguments limit which local branches are + considered, e.g. `git branch --delete-merged 'origin/*' + 'topic-*'`. ++ +A branch is not deleted when: ++ +-- +* its configured upstream ref no longer exists, +* it is checked out in any worktree, or +* pushing it by name to the remote configured by + `branch.<name>.remote` would update its upstream, so it cannot be + distinguished from a branch that just looks "fully merged" right + after a pull. +-- ++ +A branch whose work has not yet been merged into its upstream is +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. ++ +A branch that another, surviving branch tracks as its upstream is +kept, so a branch is never deleted out from under one stacked on top +of it. If that kept branch in turn tracks a branch that is being +deleted, its now-stale upstream configuration is cleared. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +162 -2
218 lines not on this page · raw message
t/t3200-branch.sh +166 -0
170 lines not on this page · raw message
Signature · 1 line
gitgitgadget
Re: [PATCH v19 5/7] branch: add --delete-merged <branch>
Quoted 34 lines · Harald Nordgren via GitGitGadget
builtin/branch.c +34
-0+struct spare_data { +struct strset *deletable; +struct strset *spared; +}; + +/* +* A surviving branch stacked on a deletion candidate would lose its +* upstream, so drop that candidate from the delete set and remember it +* in "spared" so its own upstream can be tidied up afterwards. +*/ +static int spare_stacked_base (const struct reference *ref,void *cb_data) +{ +struct spare_data *data = cb_data; +struct branch *branch; +const char *upstream, *up_short; + +if (strset_contains (data->deletable, ref->name)) +return 0 ; + branch =branch_get (ref->name); + upstream =branch_get_upstream (branch,NULL ); +if (!upstream || !skip_prefix (upstream,"refs/heads/" , &up_short) || + !strset_contains (data->deletable, up_short)) +return 0 ; + +strset_remove (data->deletable, up_short); +strset_add (data->spared, up_short); +return 0 ; +} + +/* +* Keep any branch that a surviving branch tracks as its upstream, so we +* never delete a branch out from under one stacked on top of it. Such a +* base is itself merged, so when its own upstream is also going away +* (no surviving branch tracks it), clear the base's now-stale upstream.
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 747
Quoted 10 lines
builtin/branch.c +10
-0+*/ +static void spare_stacked_bases (struct ref_store *refs,struct strset *deletable) +{ +struct strset spared =STRSET_INIT ; +struct spare_data data = { .deletable = deletable, .spared = &spared }; +struct strbuf key =STRBUF_INIT ; +struct hashmap_iter iter; +struct strmap_entry *entry; + +refs_for_each_branch_ref (refs, spare_stacked_base, &data);
Hmph. Wouldn't this implicitly make whether a stacked branch is spared or has its upstream configuration cleared depends on the order in which the branches are visited by the callback function of refs_for_each_branch_ref(), which presumably is alphabetical?
For example, if 'a_tip' (unmerged) tracks 'b_mid' (merged), which in turn tracks 'c_lower' (merged), visiting them in alphabetical order ('a_tip', 'b_mid', 'c_lower') would spare both 'b_mid' and 'c_lower'. If they, however, were named 'tip' (unmerged), which tracks 'mid' (merged), which in turn tracks 'lower' (merged), they would be visited in the order 'lower', 'mid', 'tip'. This would result in 'lower' being deleted and 'mid' being spared with its upstream configuration cleared, even though the relationship among these three branches is exactly the same.
Since the branches are visited in a fixed alphabetical order, it might not be an unpredictable order that yields unrepeatable results. Nonetheless, the behavior should be consistent and independent of branch names, as long as the inter-relationship among the branches involved is identical, no?
Or am I grossly misreading the code?
Thanks.
Re: [PATCH v19 5/7] branch: add --delete-merged <branch>
I think I can fix this with
```
-+ if (strset_contains(data->deletable, ref->name))
++ if (strset_contains(data->deletable, ref->name) ||
++ strset_contains(data->spared, ref->name))
```I used your example to write a test about it as well.
Harald
Re: [PATCH v19 5/7] branch: add --delete-merged <branch>
Quoted 8 lines · Harald Nordgren
I think I can fix this with
``` -+ if (strset_contains(data->deletable, ref->name)) ++ if (strset_contains(data->deletable, ref->name) || ++ strset_contains(data->spared, ref->name)) ```
Quoted 4 lines
I used your example to write a test about it as well.
Harald
I do not claim that the single example I gave covers all the issues that arise from failing to analyze the dependency graph, or from attempting to solve the problem sequentially, which makes the solution depend on the order in which branches are visited.
I have a suspicion that it may be unavoidable to employ a multi-pass approach that iteratively identifies all branches transitively needed by any surviving branch, though that is merely a hunch, unsupported by any proof.
Thanks.
Re: [PATCH v19 5/7] branch: add --delete-merged <branch>
Quoted 20 lines · Junio C Hamano
Quoted 8 lines · Harald Nordgren
I think I can fix this with
``` -+ if (strset_contains(data->deletable, ref->name)) ++ if (strset_contains(data->deletable, ref->name) || ++ strset_contains(data->spared, ref->name)) ```Quoted 4 lines
I used your example to write a test about it as well.
Harald
I do not claim that the single example I gave covers all the issues that arise from failing to analyze the dependency graph, or from attempting to solve the problem sequentially, which makes the solution depend on the order in which branches are visited.
Quoted 4 lines
I have a suspicion that it may be unavoidable to employ a multi-pass approach that iteratively identifies all branches transitively needed by any surviving branch, though that is merely a hunch, unsupported by any proof.
I think it would certainly be easier to reason about if we did not mutate data->deletable in spare_stacked_base() in removed the spared branches with
strset_for_each_entry(&spared, &iter, entry) strset_remove(&deletable, entry);
after the call to refs_for_each_branch_ref() returns in spare_stacked_branches().
I'll try and have a proper look at this iteration in the comming week.
Thanks
Phillip
Re: [PATCH v19 5/7] branch: add --delete-merged <branch>
In reply to Harald Nordgren via GitGitGadgetbuiltin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 707
Quoted 6 lines · Harald Nordgren via GitGitGadget
builtin/branch.c +4
-0+struct spare_data { +struct strset *deletable; +struct strset *spared; +};
Let me offer a brief comment on the data representation chosen for this design, which initially left me confused enough to suspect a bug or two. It turns out the confusion was entirely mine, and I have since convinced myself that the approach is sound.
Quoted 4 lines
builtin/branch.c +4
-0+/* +* A surviving branch stacked on a deletion candidate would lose its +* upstream, so drop that candidate from the delete set and remember it +* in "spared" so its own upstream can be tidied up afterwards.
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 722
Quoted 10 lines
builtin/branch.c +10
-0+*/ +static int spare_stacked_base (const struct reference *ref,void *cb_data) +{ +struct spare_data *data = cb_data; +struct branch *branch; +const char *upstream, *up_short; + +if (strset_contains (data->deletable, ref->name)) +return 0 ; + branch =branch_get (ref->name);
Here, spare_stacked_base() is a callback triggered by the refs_for_each_branch_ref() iterator. I initially misremembered what the for-each-ref family of iterators passes to its callbacks. I thought 'ref->name' here would be a full refname, such as 'refs/heads/main', which does not match what 'branch_get' expects (which is a branch name). The same confusion led me to think the 'deletable' strset was indexed by full refnames, which would then
...
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 726
Quoted 4 lines
builtin/branch.c +4
-0+ upstream =branch_get_upstream (branch,NULL ); +if (!upstream || !skip_prefix (upstream,"refs/heads/" , &up_short) || + !strset_contains (data->deletable, up_short)) +return 0 ;
... mean that this lookup using 'up_short' (the branch name obtained after stripping the 'refs/heads/' prefix) is buggy. But that is not the case. The 'deletable' strset stores branch names, so indexing with 'up_short' is correct, and ...
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 729
Quoted 2 lines
builtin/branch.c +2
-0+strset_remove (data->deletable, up_short); +strset_add (data->spared, up_short);
... adding 'up_short' to the 'deletable' strset is correct too. The same applies to the 'spared' strset. It is consistently indexed by branch names rather than full refnames.
This design choice makes perfect sense for this application. We have no business touching the upstream of a branch unless it is a local branch. A remote-tracking branch, such as 'refs/remotes/origin/main', lives outside the 'refs/heads/' hierarchy. Such a branch has no need to interact with either the 'deletable' or 'spared' tables.
I hope others will not be as easily confused as I was, but just in case it helps future readers ...
Thanks.
PATCH v19 6/7 branch: add branch.<name>.deleteMerged opt-out
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Setting branch.<name>.deleteMerged=false exempts that branch from "git branch --delete-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.
--- Documentation/config/branch.adoc | 7 +++++++ Documentation/git-branch.adoc | 5 +++-- builtin/branch.c | 14 +++++++++++++ t/t3200-branch.sh | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-)
Documentation/config/branch.adoc +7 -0
@@ -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.<name>.deleteMerged`:: + If set to `false`, branch _<name>_ is exempt from + `git branch --delete-merged`. Useful for a topic branch you + intend to develop further after an initial round has been + merged upstream. Defaults to true. Explicit deletion via + `git branch -d` is unaffected.
Documentation/git-branch.adoc +3 -2
@@ -216,11 +216,12 @@ A branch is not deleted when: + -- * its configured upstream ref no longer exists, -* it is checked out in any worktree, or +* it is checked out in any worktree, * pushing it by name to the remote configured by `branch.<name>.remote` would update its upstream, so it cannot be distinguished from a branch that just looks "fully merged" right - after a pull. + after a pull, or +* `branch.<name>.deleteMerged` is set to `false`. -- + A branch whose work has not yet been merged into its upstream is
builtin/branch.c +14 -0
@@ -791,6 +791,7 @@ static int delete_merged_branches(const struct strvec *upstreams,struct ref_array candidates = {0 };struct strset deletable =STRSET_INIT ;struct strvec to_delete =STRVEC_INIT ; +struct strbuf key =STRBUF_INIT ;struct hashmap_iter iter;struct strmap_entry *entry;size_t i;
@@ -810,6 +811,7 @@ static int delete_merged_branches(const struct strvec *upstreams,const char *short_name;struct branch *branch;const char *upstream; +int opt_out;if (!skip_prefix (full_name,"refs/heads/" , &short_name))BUG ("filter returned non-branch ref '%s'" , full_name);
@@ -827,6 +829,17 @@ static int delete_merged_branches(const struct strvec *upstreams, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; +strbuf_reset (&key); +strbuf_addf (&key,"branch.%s.deletemerged" , short_name); +if (!repo_config_get_bool (the_repository, key.buf, &opt_out) && + !opt_out) { +if (!(flags &DELETE_BRANCH_QUIET )) +fprintf (stderr, +_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), + short_name, short_name); +continue ; + } +strset_add (&deletable, short_name); }
@@ -842,6 +855,7 @@ static int delete_merged_branches(const struct strvec *upstreams,DELETE_BRANCH_NO_HEAD_FALLBACK | flags); +strbuf_release (&key);strvec_clear (&to_delete);strset_clear (&deletable);ref_array_clear (&candidates);
t/t3200-branch.sh +36 -0
@@ -2000,4 +2000,40 @@ test_expect_success '--delete-merged requires a value' ' test_must_fail git -C forked branch --delete-merged 2>err && test_grep "requires a value" err ' + +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' ' + setup_repo_for_delete_merged && + create_merged_branch deleted && + create_merged_branch kept && + ( + cd repo && + git config branch.kept.deleteMerged false && + git checkout --detach && + + git branch --delete-merged origin/next 2>err && + + test_grep "Skipping .kept." err && + check_branches <<-\EOF + kept + main + EOF + ) +' + +test_expect_success "branch -d still deletes a deleteMerged=false branch" ' + setup_repo_for_delete_merged && + create_merged_branch kept && + ( + cd repo && + git config branch.kept.deleteMerged false && + git checkout --detach && + + git branch -d kept && + + check_branches <<-\EOF + main + EOF + ) +' + test_done
Signature · 1 line
gitgitgadget
PATCH v19 7/7 branch: add --dry-run for --delete-merged
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
"git branch --dry-run --delete-merged ..." prints one line per ref that would be deleted without modifying refs or branch configuration.
--dry-run is only meaningful together with --delete-merged and is rejected otherwise.
--- Documentation/git-branch.adoc | 8 +++++- builtin/branch.c | 54 +++++++++++++++++++++++------------ t/t3200-branch.sh | 35 ++++++++++++++++++++++- 3 files changed, 77 insertions(+), 20 deletions(-)
Documentation/git-branch.adoc +7 -1
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] -git branch (--delete-merged <branch>)... [<pattern>...] +git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...] DESCRIPTION -----------
@@ -233,6 +233,12 @@ kept, so a branch is never deleted out from under one stacked on top of it. If that kept branch in turn tracks a branch that is being deleted, its now-stale upstream configuration is cleared. +`--dry-run`:: + With `--delete-merged`, print which branches would be + deleted and exit without touching any ref. Useful for + sanity-checking a wide pattern like `'origin/*'` before + committing to the deletion. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +36 -18
115 lines not on this page · raw message
t/t3200-branch.sh +34 -1
@@ -1900,6 +1900,19 @@ test_expect_success '--delete-merged deletes only selected merged branches' ' git checkout -b tracks-other other/main --track && sha=$(git rev-parse --short merged) && + git branch --dry-run --delete-merged origin/next merged >actual 2>&1 && + echo "Would delete branch merged (was $sha)." >expect && + test_cmp expect actual && + git rev-parse --verify refs/heads/merged && + + check_branches <<-\EOF && + also-merged + main + merged + tracks-other + unmerged + EOF + git branch --delete-merged origin/next merged >actual 2>&1 && echo "Deleted branch merged (was $sha)." >expect && test_cmp expect actual &&
@@ -1948,9 +1961,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' ' git checkout -b topic feature --track && git commit --allow-empty -m "topic work" && - git branch --delete-merged origin/next 2>err && + git branch --dry-run --delete-merged origin/next >out && + test_grep ! "feature" out && + git branch --delete-merged origin/next 2>err && test_must_be_empty err && + check_branches <<-\EOF && feature main
@@ -1978,6 +1994,18 @@ test_expect_success '--delete-merged clears the deleted upstream of a spared bra git checkout -b tip mid --track && git commit --allow-empty -m "tip work" && + git branch --dry-run --delete-merged origin/next \ + --delete-merged lower && + + git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual && + cat >expect <<-\EOF && + branch.mid.remote . + branch.mid.merge refs/heads/lower + branch.tip.remote . + branch.tip.merge refs/heads/mid + EOF + test_cmp expect actual && + git branch --delete-merged origin/next \ --delete-merged lower &&
@@ -2036,4 +2064,9 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" ' ) ' +test_expect_success '--dry-run without --delete-merged is rejected' ' + test_must_fail git -C forked branch --dry-run 2>err && + test_grep "requires --delete-merged" err +' + test_done
Signature · 1 line
gitgitgadget
v20 cover branch: delete-merged
In reply to Harald Nordgren via GitGitGadgetDelete branches that have already been merged on upstream.
Changes in v20:
* Protect branches transitively required by a surviving local upstream stack. Traverse upstream chains once and defer delete-set mutation until traversal completes.
* Make stacked-branch handling independent of ref iteration order and update the documentation accordingly.
* Clarify variable names with regards to branch names (short) to reduce confusion.
Changes in v19:
* Fix bug where dry-run would still remove config, added test coverage.
* Redesigned --delete-merged as a repeatable upstream selector with optional positional patterns limiting deletion scope.
* Protect same-name upstream branches independently of push-default configuration.
* Simplified flags handling where local caching became complicated when mutating values.
* Clarified assertions in tests.
Changes in v18:
* Instead of keeping the whole chain of upstream branches, keep only the ones an unmerged branch still needs. When a kept (merged) branch in turn tracks a branch that is being deleted, clear its now-stale upstream config.
* Rework spare_stacked_bases() to record the kept bases and, in a second pass, clear the upstream of any whose own base is going away. Build the to-delete list with strset_for_each_entry() instead of re-walking the candidate array.
Changes in v17:
* Keep a merged branch when another surviving branch still tracks it as its upstream, so --delete-merged no longer deletes a branch out from under one stacked on top of it.
* Move the --dry-run and branch.<name>.deleteMerged opt-out fully into their own commits.
Changes in v16:
* Convert delete_merged_branches() to take an unsigned int flags argument instead of separate quiet/dry_run booleans, matching delete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_reset per iteration, single strbuf_release after) instead of allocating and freeing it each time
* Rewrite the --delete-merged tests as integration tests: branches that land commits upstream, with deletion and the checked-out, upstream-gone, and push-equals-upstream safety cases exercised together in one run and output asserted via test_cmp
* Collapse the many per-aspect test repos into a single reused repo set up by a setup_repo_for_delete_merged helper, and rename helpers off the old pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of prefixing every command with -C
Changes in v15:
* Renamed --prune-merged to --delete-merged throughout. Not necessarily final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of warning.
* Initialized the delete_branches() flag locals where declared. Only force stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f, --forked coverage), renamed the misleading trunk fixture, and replaced the misnamed detached branch with git checkout --detach.
Changes in v14:
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the remotes path set a local force but not the DELETE_BRANCH_FORCE bit that check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other branches too, hopefully not related
(https://github.com/git/git/pull/2285).
Changes in v13:
* Reworked --forked into a real ref-filter applied in apply_ref_filter() instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the abbreviated upstream), and dropped the old helper machinery, forward declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git branch --prune-merged origin/main 'feature*') instead of repeating the option.
Changes in v12:
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST options.
* Dropped the bare-remote-name resolution for --forked, the argument is now a ref or a glob.
Changes in v11:
* 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 /HEAD by name guard that the reviewer rejected is gone.
* New --dry-run for --prune-merged.
Changes in v10:
* --forked / --prune-merged now take a branch glob instead of a remote name — origin, origin/*, origin/release-- all work. This replaces the remote-only form and subsumes the old --all-remotes flag, which has been dropped.
* New --dry-run for --prune-merged.
Changes in v9:
* --force no longer has special meaning with --prune-merged; reachability is always enforced. Use git branch -D to delete an unmerged branch. Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.
Changes in v8:
* Delete only when the branch's work is actually reachable from its upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place
Changes in v7:
* --prune-merged now checks if a branch is merged into its own upstream first. If the upstream is gone, it checks against the remote's default branch instead. If neither exists, the branch is refused (use --force to delete anyway).
Changes in v6:
* --prune-merged now measures merged-ness against the remote's default branch instead of the candidate's upstream — so the decision no longer depends on which branch happens to be checked out locally.
* delete_branches() / check_branch_commit() gained a per-candidate override that lets a caller substitute a different "what counts as merged" reference (or skip the check). branch -d callers pass NULL and keep their existing semantics.
* prune_merged_branches() resolves each candidate's push-remote HEAD and threads it through, so --prune-merged --all-remotes measures each candidate against its own remote rather than a single global reference.
Changes in v5:
* Drop commit 'fetch: add --prune-merged'
Changes in v4:
* Resolve each remote's HEAD and collect the targets into a protected_default_refs set in collect_forked_set.
* In prune_merged_branches, skip a candidate when its upstream is a protected default ref and the local branch name matches the default branch's leaf name (so a local main tracking origin/main is spared, but a renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name (not by upstream alone); spare a branch whose push ref is the remote default.
Changes in v3:
* s/remote-tracking refs/remote-tracking branches/g
Changes in v2:
* The whole feature moved out of git fetch and into git branch. git fetch --prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their tip is reachable from the upstream tracking ref; --force skips that safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running topic branch) even with --force; doesn't affect explicit git branch -d.
* delete_branches() got a warn_only mode so bulk deletion prints a one-line warning per skipped branch instead of the noisy four-line hint that git branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention --prune-merged.
* New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh shrunk since most logic moved.
Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <branch>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged
Documentation/config/branch.adoc | 7 + Documentation/git-branch.adoc | 49 +++- builtin/branch.c | 276 +++++++++++++++++++--- ref-filter.c | 70 ++++++ ref-filter.h | 10 + t/t3200-branch.sh | 393 +++++++++++++++++++++++++++++++ 6 files changed, 774 insertions(+), 31 deletions(-)
base-commit: 5d2e7709234afea1b6ddb25cd4f60d3d5fb3c200
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v20
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v20
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v19:
1: 562648132d = 1: 51c1c9d075 branch: add --forked filter for --list mode
2: c7ebd9344c = 2: 711574b2e5 branch: convert delete_branches() to a flags argument
3: 0c4f3358e3 = 3: 47c5975dc7 branch: let delete_branches skip unmerged branches on bulk refusal
4: 64a202526a = 4: 46268acec5 branch: prepare delete_branches for a bulk caller
5: a6caa5b397 ! 5: ef9f57e735 branch: add --delete-merged <branch>
@@ Commit message
A branch whose work is not yet merged into its upstream is silently
skipped, so one unmerged topic does not abort the whole sweep.
- A branch that another, surviving branch tracks as its upstream is
- also kept, so a branch is never deleted out from under one stacked
- on top of it. Such a kept branch is itself merged, so when its own
- upstream is being deleted, clear its now-stale upstream config.
+ A branch that a surviving branch depends on through a chain of local
+ upstreams is also kept, so no branch is deleted out from under stacked
+ work. Collect this transitive set without changing the candidate set
+ during ref iteration: walk upstream chains from surviving branches,
+ visit each branch at most once, and remove the collected bases only
+ after the iteration completes. This makes the result independent of
+ ref iteration order without repeated full scans.
Signed-off-by: Harald Nordgren <[email protected]>
@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
+silently skipped. Delete it with `git branch -D` if you want to
+remove it anyway.
++
-+A branch that another, surviving branch tracks as its upstream is
-+kept, so a branch is never deleted out from under one stacked on top
-+of it. If that kept branch in turn tracks a branch that is being
-+deleted, its now-stale upstream configuration is cleared.
++A branch that a surviving branch depends on through a chain of local
++upstreams is kept, so a branch is never deleted out from under stacked
++work.
+
`-v`::
`-vv`::
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
return 0;
}
-+struct spare_data {
-+ struct strset *deletable;
-+ struct strset *spared;
++struct stacked_branch_data {
++ struct strset *deletable_branch_names;
++ struct strset *protected_branch_names;
++ struct strset *visited_branch_names;
+};
+
-+/*
-+ * A surviving branch stacked on a deletion candidate would lose its
-+ * upstream, so drop that candidate from the delete set and remember it
-+ * in "spared" so its own upstream can be tidied up afterwards.
-+ */
-+static int spare_stacked_base(const struct reference *ref, void *cb_data)
++static int collect_stacked_branch_bases(const struct reference *ref,
++ void *cb_data)
+{
-+ struct spare_data *data = cb_data;
-+ struct branch *branch;
-+ const char *upstream, *up_short;
++ struct stacked_branch_data *data = cb_data;
++ const char *branch_name;
+
-+ if (strset_contains(data->deletable, ref->name))
-+ return 0;
-+ branch = branch_get(ref->name);
-+ upstream = branch_get_upstream(branch, NULL);
-+ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
-+ !strset_contains(data->deletable, up_short))
++ if (!skip_prefix(ref->name, "refs/heads/", &branch_name))
++ BUG("expected local branch ref, got '%s'", ref->name);
++ if (strset_contains(data->deletable_branch_names, branch_name))
+ return 0;
+
-+ strset_remove(data->deletable, up_short);
-+ strset_add(data->spared, up_short);
++ while (strset_add(data->visited_branch_names, branch_name)) {
++ struct branch *branch = branch_get(branch_name);
++ const char *upstream_refname = branch_get_upstream(branch, NULL);
++ const char *upstream_branch_name;
++
++ if (!upstream_refname ||
++ !skip_prefix(upstream_refname, "refs/heads/",
++ &upstream_branch_name) ||
++ !strset_contains(data->deletable_branch_names,
++ upstream_branch_name))
++ break;
++
++ strset_add(data->protected_branch_names, upstream_branch_name);
++ branch_name = upstream_branch_name;
++ }
++
+ return 0;
+}
+
-+/*
-+ * Keep any branch that a surviving branch tracks as its upstream, so we
-+ * never delete a branch out from under one stacked on top of it. Such a
-+ * base is itself merged, so when its own upstream is also going away
-+ * (no surviving branch tracks it), clear the base's now-stale upstream.
-+ */
-+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
++static void protect_stacked_branch_bases(struct ref_store *refs,
++ struct strset *deletable_branch_names)
+{
-+ struct strset spared = STRSET_INIT;
-+ struct spare_data data = { .deletable = deletable, .spared = &spared };
-+ struct strbuf key = STRBUF_INIT;
++ struct strset protected_branch_names = STRSET_INIT;
++ struct strset visited_branch_names = STRSET_INIT;
++ struct stacked_branch_data data = {
++ .deletable_branch_names = deletable_branch_names,
++ .protected_branch_names = &protected_branch_names,
++ .visited_branch_names = &visited_branch_names,
++ };
++ struct refs_for_each_ref_options opts = {
++ .prefix = "refs/heads/",
++ };
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+
-+ refs_for_each_branch_ref(refs, spare_stacked_base, &data);
-+
-+ strset_for_each_entry(&spared, &iter, entry) {
-+ struct branch *branch = branch_get(entry->key);
-+ const char *upstream = branch_get_upstream(branch, NULL);
-+ const char *up_short;
-+
-+ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
-+ !strset_contains(deletable, up_short))
-+ continue;
++ refs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);
+
-+ strbuf_reset(&key);
-+ strbuf_addf(&key, "branch.%s.merge", branch->name);
-+ repo_config_set_gently(the_repository, key.buf, NULL);
-+ strbuf_reset(&key);
-+ strbuf_addf(&key, "branch.%s.remote", branch->name);
-+ repo_config_set_gently(the_repository, key.buf, NULL);
-+ }
++ strset_for_each_entry(&protected_branch_names, &iter, entry)
++ strset_remove(deletable_branch_names, entry->key);
+
-+ strbuf_release(&key);
-+ strset_clear(&spared);
++ strset_clear(&visited_branch_names);
++ strset_clear(&protected_branch_names);
+}
+
+static int branch_pushes_to_upstream(struct branch *branch,
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct ref_filter filter = REF_FILTER_INIT;
+ struct ref_array candidates = { 0 };
-+ struct strset deletable = STRSET_INIT;
-+ struct strvec to_delete = STRVEC_INIT;
++ struct strset deletable_branch_names = STRSET_INIT;
++ struct strvec branches_to_delete = STRVEC_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+ size_t i;
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ filter_refs(&candidates, &filter, filter.kind);
+
+ for (i = 0; i < (size_t)candidates.nr; i++) {
-+ const char *full_name = candidates.items[i]->refname;
-+ const char *short_name;
++ const char *branch_refname = candidates.items[i]->refname;
++ const char *branch_name;
+ struct branch *branch;
-+ const char *upstream;
++ const char *upstream_refname;
+
-+ if (!skip_prefix(full_name, "refs/heads/", &short_name))
-+ BUG("filter returned non-branch ref '%s'", full_name);
-+ if (branch_checked_out(full_name))
++ if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
++ BUG("filter returned non-branch ref '%s'", branch_refname);
++ if (branch_checked_out(branch_refname))
+ continue;
+
-+ branch = branch_get(short_name);
-+ upstream = branch_get_upstream(branch, NULL);
-+ if (!upstream || !refs_ref_exists(refs, upstream))
++ branch = branch_get(branch_name);
++ upstream_refname = branch_get_upstream(branch, NULL);
++ if (!upstream_refname || !refs_ref_exists(refs, upstream_refname))
+ continue;
-+ if (branch_pushes_to_upstream(branch, upstream))
++ if (branch_pushes_to_upstream(branch, upstream_refname))
+ continue;
-+ if (check_branch_commit(short_name, short_name,
++ if (check_branch_commit(branch_name, branch_name,
+ &candidates.items[i]->objectname, NULL,
+ FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
+ continue;
+
-+ strset_add(&deletable, short_name);
++ strset_add(&deletable_branch_names, branch_name);
+ }
+
-+ spare_stacked_bases(refs, &deletable);
++ protect_stacked_branch_bases(refs, &deletable_branch_names);
+
-+ strset_for_each_entry(&deletable, &iter, entry)
-+ strvec_push(&to_delete, entry->key);
++ strset_for_each_entry(&deletable_branch_names, &iter, entry)
++ strvec_push(&branches_to_delete, entry->key);
+
-+ if (to_delete.nr)
-+ ret = delete_branches(to_delete.nr, to_delete.v,
++ if (branches_to_delete.nr)
++ ret = delete_branches(branches_to_delete.nr, branches_to_delete.v,
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_SKIP_UNMERGED |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ flags);
+
-+ strvec_clear(&to_delete);
-+ strset_clear(&deletable);
++ strvec_clear(&branches_to_delete);
++ strset_clear(&deletable_branch_names);
+ ref_array_clear(&candidates);
+ ref_filter_clear(&filter);
+ return ret;
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ )
+'
+
-+test_expect_success '--delete-merged clears the deleted upstream of a spared branch' '
++test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ git commit --allow-empty -m "tip work" &&
+
+ git branch --delete-merged origin/next \
-+ --delete-merged lower &&
++ --delete-merged lower >actual 2>&1 &&
++ test_must_be_empty actual &&
+
+ check_branches <<-\EOF &&
++ lower
+ main
+ mid
+ tip
+ EOF
+
-+ git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
++ git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual &&
+ cat >expect <<-\EOF &&
++ branch.lower.remote origin
++ branch.lower.merge refs/heads/next
++ branch.mid.remote .
++ branch.mid.merge refs/heads/lower
+ branch.tip.remote .
+ branch.tip.merge refs/heads/mid
+ EOF
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ )
+'
+
++test_expect_success '--delete-merged result is independent of stacked branch names' '
++ setup_repo_for_delete_merged &&
++ (
++ cd repo &&
++ git branch c-lower origin/next --track &&
++ git branch b-mid c-lower --track &&
++ git checkout -b a-tip b-mid --track &&
++ git commit --allow-empty -m "tip work" &&
++
++ git branch --delete-merged origin/next \
++ --delete-merged "c-*" &&
++
++ check_branches <<-\EOF &&
++ a-tip
++ b-mid
++ c-lower
++ main
++ EOF
++
++ git branch --delete-merged origin/next \
++ --delete-merged "c-*" >actual 2>&1 &&
++ test_must_be_empty actual &&
++
++ check_branches <<-\EOF
++ a-tip
++ b-mid
++ c-lower
++ main
++ EOF
++ )
++'
++
+test_expect_success '--delete-merged requires a value' '
+ test_must_fail git -C forked branch --delete-merged 2>err &&
+ test_grep "requires a value" err
6: 734d27c908 ! 6: fa70108611 branch: add branch.<name>.deleteMerged opt-out
@@ Documentation/git-branch.adoc: A branch is not deleted when:
## builtin/branch.c ##
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
struct ref_array candidates = { 0 };
- struct strset deletable = STRSET_INIT;
- struct strvec to_delete = STRVEC_INIT;
+ struct strset deletable_branch_names = STRSET_INIT;
+ struct strvec branches_to_delete = STRVEC_INIT;
+ struct strbuf key = STRBUF_INIT;
struct hashmap_iter iter;
struct strmap_entry *entry;
size_t i;
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
- const char *short_name;
+ const char *branch_name;
struct branch *branch;
- const char *upstream;
+ const char *upstream_refname;
+ int opt_out;
- if (!skip_prefix(full_name, "refs/heads/", &short_name))
- BUG("filter returned non-branch ref '%s'", full_name);
+ if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
+ BUG("filter returned non-branch ref '%s'", branch_refname);
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
continue;
+ strbuf_reset(&key);
-+ strbuf_addf(&key, "branch.%s.deletemerged", short_name);
++ strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
+ if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+ !opt_out) {
+ if (!(flags & DELETE_BRANCH_QUIET))
+ fprintf(stderr,
+ _("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
-+ short_name, short_name);
++ branch_name, branch_name);
+ continue;
+ }
+
- strset_add(&deletable, short_name);
+ strset_add(&deletable_branch_names, branch_name);
}
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
flags);
+ strbuf_release(&key);
- strvec_clear(&to_delete);
- strset_clear(&deletable);
+ strvec_clear(&branches_to_delete);
+ strset_clear(&deletable_branch_names);
ref_array_clear(&candidates);
## t/t3200-branch.sh ##
7: 7aa9d5db14 ! 7: 13bac431a3 branch: add --dry-run for --delete-merged
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: kept, so a branch is never deleted out from under one stacked on top
- of it. If that kept branch in turn tracks a branch that is being
- deleted, its now-stale upstream configuration is cleared.
+@@ Documentation/git-branch.adoc: A branch that a surviving branch depends on through a chain of local
+ upstreams is kept, so a branch is never deleted out from under stacked
+ work.
+`--dry-run`::
+ With `--delete-merged`, print which branches would be
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki
char *refname = name + branch_name_pos;
if (!(flags & DELETE_BRANCH_QUIET))
printf(remote_branch
-@@ builtin/branch.c: static int spare_stacked_base(const struct reference *ref, void *cb_data)
- * base is itself merged, so when its own upstream is also going away
- * (no surviving branch tracks it), clear the base's now-stale upstream.
- */
--static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
-+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable,
-+ unsigned int flags)
- {
- struct strset spared = STRSET_INIT;
- struct spare_data data = { .deletable = deletable, .spared = &spared };
-@@ builtin/branch.c: static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable
-
- refs_for_each_branch_ref(refs, spare_stacked_base, &data);
-
-- strset_for_each_entry(&spared, &iter, entry) {
-- struct branch *branch = branch_get(entry->key);
-- const char *upstream = branch_get_upstream(branch, NULL);
-- const char *up_short;
-+ if (!(flags & DELETE_BRANCH_DRY_RUN)) {
-+ strset_for_each_entry(&spared, &iter, entry) {
-+ struct branch *branch = branch_get(entry->key);
-+ const char *upstream = branch_get_upstream(branch, NULL);
-+ const char *up_short;
-
-- if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
-- !strset_contains(deletable, up_short))
-- continue;
-+ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
-+ !strset_contains(deletable, up_short))
-+ continue;
-
-- strbuf_reset(&key);
-- strbuf_addf(&key, "branch.%s.merge", branch->name);
-- repo_config_set_gently(the_repository, key.buf, NULL);
-- strbuf_reset(&key);
-- strbuf_addf(&key, "branch.%s.remote", branch->name);
-- repo_config_set_gently(the_repository, key.buf, NULL);
-+ strbuf_reset(&key);
-+ strbuf_addf(&key, "branch.%s.merge", branch->name);
-+ repo_config_set_gently(the_repository, key.buf, NULL);
-+ strbuf_reset(&key);
-+ strbuf_addf(&key, "branch.%s.remote", branch->name);
-+ repo_config_set_gently(the_repository, key.buf, NULL);
-+ }
- }
-
- strbuf_release(&key);
-@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
- strset_add(&deletable, short_name);
- }
-
-- spare_stacked_bases(refs, &deletable);
-+ spare_stacked_bases(refs, &deletable, flags);
-
- strset_for_each_entry(&deletable, &iter, entry)
- strvec_push(&to_delete, entry->key);
@@ builtin/branch.c: int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a
check_branches <<-\EOF &&
feature
main
-@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstream of a spared bra
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream chain of a surviving bra
git checkout -b tip mid --track &&
git commit --allow-empty -m "tip work" &&
+ git branch --dry-run --delete-merged origin/next \
-+ --delete-merged lower &&
++ --delete-merged lower >actual 2>&1 &&
++ test_must_be_empty actual &&
+
-+ git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
++ git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual &&
+ cat >expect <<-\EOF &&
++ branch.lower.remote origin
++ branch.lower.merge refs/heads/next
+ branch.mid.remote .
+ branch.mid.merge refs/heads/lower
+ branch.tip.remote .
@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstr
+ test_cmp expect actual &&
+
git branch --delete-merged origin/next \
- --delete-merged lower &&
-
+ --delete-merged lower >actual 2>&1 &&
+ test_must_be_empty actual &&
@@ t/t3200-branch.sh: test_expect_success "branch -d still deletes a deleteMerged=false branch" '
)
'Signature · 1 line
gitgitgadget
PATCH v20 1/7 branch: add --forked filter for --list mode
From: Harald Nordgren <[email protected]>
Add a --forked option to "git branch" list mode that lists only branches whose configured upstream matches <branch>. The argument can be a ref (e.g. "origin/main", "master"), a remote name like "origin" for the branch its origin/HEAD points at, or a shell 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 --delete-merged, which deletes the listed branches once they have landed on their upstream.
--- Documentation/git-branch.adoc | 12 +++- builtin/branch.c | 18 +++++- ref-filter.c | 70 ++++++++++++++++++++ ref-filter.h | 10 +++ t/t3200-branch.sh | 117 ++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 3 deletions(-)
Documentation/git-branch.adoc +11 -1
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current] [--column[=<options>] | --no-column] [--sort=<key>] [--merged [<commit>]] [--no-merged [<commit>]] [--contains [<commit>]] [--no-contains [<commit>]] + [(--forked <branch>)...] [--points-at <object>] [--format=<format>] [(-r|--remotes) | (-a|--all)] [--list] [<pattern>...]
@@ -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 _<commit>_ argument is missing it defaults to `HEAD` (i.e. the tip of the current -branch). +branch). With `--forked`, only branches whose configured upstream matches +the given branch or pattern will be listed. The command's second form creates a new branch head named _<branch-name>_ which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main". Only list branches whose tips are not reachable from _<commit>_ (`HEAD` if not specified). Implies `--list`. +`--forked <branch>`:: + Only list branches whose configured upstream matches + _<branch>_. The argument can be a ref (e.g. `origin/main`, + `master`), a remote name like `origin` for the branch its + `origin/HEAD` points at, or a shell-style glob (e.g. + `'origin/*'`). The option can be repeated to widen the + filter. Implies `--list`. + `--points-at <object>`:: Only list branches of _<object>_.
builtin/branch.c +16 -2
@@ -30,7 +30,7 @@#include "commit-reach.h" static const char *const builtin_branch_usage[] = { -N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged]" ), +N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]" ),N_ ("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]" ),N_ ("git branch [<options>] [-l] [<pattern>...]" ),N_ ("git branch [<options>] [-r] (-d | -D) <branch-name>..." ),
@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, intfree_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)
@@ -793,6 +803,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 <branch> (repeatable)" ), +PARSE_OPT_NONEG , parse_opt_forked),OPT_COLUMN (0 ,"column" , &colopts,N_ ("list branches in columns" )),OPT_REF_SORT (&sorting_options), OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -838,7 +851,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 +
ref-filter.c +70 -0
88 lines not on this page · raw message
ref-filter.h +10 -0
@@ -67,6 +67,7 @@ struct ref_filter {const char **name_patterns;const char *start_after;struct strvec exclude; +struct strvec forked;struct oid_array points_at;struct commit_list *with_commit;struct commit_list *no_commit;
@@ -110,6 +111,7 @@ struct ref_format {#define REF_FILTER_INIT { \ .points_at = OID_ARRAY_INIT, \ .exclude = STRVEC_INIT, \ + .forked = STRVEC_INIT, \ }#define REF_FORMAT_INIT { \ .use_color = GIT_COLOR_UNKNOWN, \
@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);struct ref_sorting *ref_sorting_options (struct string_list *);/* Function to parse --merged and --no-merged options */ int parse_opt_merge_filter (const struct option *opt,const char *arg,int unset); +/* +* Register a --forked <branch> pattern on the filter. The argument is +* either a ref, which is resolved to its full refname, or a shell-style +* glob. Branches are kept only when their configured upstream matches +* one of the registered patterns. Returns -1 if the argument is not a +* valid ref or pattern. +*/ +int ref_filter_forked_add (struct ref_filter *filter,const char *arg);/* Get the current HEAD's description */ char *get_head_description (void );/* Set up translated strings in the output. */
t/t3200-branch.sh +117 -0
121 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v20 2/7 branch: convert delete_branches() to a flags argument
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
delete_branches() takes separate force and quiet parameters, while check_branch_commit() takes force. The next commits would grow this collection further. Replace them with a single unsigned flags argument and an enum.
Test the FORCE and QUIET bits directly from flags at each use site so that mutating or forwarding flags cannot leave cached values stale.
No change in behavior.
--- builtin/branch.c | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-)
builtin/branch.c +24 -16
@@ -189,16 +189,22 @@ 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 ), +}; +static int check_branch_commit (const char *branchname,const char *refname,const struct object_id *oid,struct commit *head_rev, -int kinds,int force) +int kinds,unsigned int flags) {struct commit *rev =lookup_commit_reference (the_repository, oid); -if (!force && !rev) { +if (!(flags &DELETE_BRANCH_FORCE ) && !rev) {error (_ ("couldn't look up commit object for '%s'" ), refname);return -1 ; } -if (!force && !branch_merged (kinds, branchname, rev, head_rev)) { +if (!(flags &DELETE_BRANCH_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, "
@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)strbuf_release (&buf); } -static int delete_branches (int argc,const char **argv,int force,int kinds, -int quiet) +static int delete_branches (int argc,const char **argv,int kinds, +unsigned int flags) {struct commit *head_rev =NULL ;struct object_id oid;
@@ -241,7 +247,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 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, } branch_name_pos =strcspn (fmt,"%" ); -if (!force) +if (!(flags &DELETE_BRANCH_FORCE )) 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 +285,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 +297,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 +312,16 @@ 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)) { + flags)) { 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:
@@ -330,7 +336,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,char *name = item->string;if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos; -if (!quiet) +if (!(flags &DELETE_BRANCH_QUIET )) printf(remote_branch ? _("Deleted remote-tracking branch %s (was %s).\n") : _("Deleted branch %s (was %s).\n"),
@@ -895,7 +901,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 ();
Signature · 1 line
gitgitgadget
PATCH v20 3/7 branch: let delete_branches skip unmerged branches on bulk refusal
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Add a skip-unmerged mode to delete_branches() and check_branch_commit() so a bulk caller can silently skip branches that are not fully merged and carry on, rather than erroring with the "use 'git branch -D'" advice that the plain "git branch -d" path emits. Existing callers are unaffected.
--- builtin/branch.c | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-)
builtin/branch.c +10 -5
@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ), +DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname, }if (!(flags &DELETE_BRANCH_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_SKIP_UNMERGED )) { +error (_ ("the branch '%s' is not fully merged" ), + branchname); +advise_if_enabled (ADVICE_FORCE_DELETE_BRANCH , +_ ("If you are sure you want to delete it, " +"run 'git branch -D %s'" ), branchname); + }return -1 ; }return 0 ;
@@ -315,7 +319,8 @@ static int delete_branches(int argc, const char **argv, int kinds,if (!(ref_flags & (REF_ISSYMREF |REF_ISBROKEN )) &&check_branch_commit (bname.buf, name, &oid, head_rev, kinds, flags)) { - ret =1 ; +if (!(flags &DELETE_BRANCH_SKIP_UNMERGED )) + ret =1 ; goto next; }
Signature · 1 line
gitgitgadget
PATCH v20 4/7 branch: prepare delete_branches for a bulk caller
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Teach delete_branches() a new mode for the upcoming --delete-merged caller that checks whether a branch is merged into its upstream without falling back to HEAD when there is no upstream. Existing callers keep their current behavior.
--- builtin/branch.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-)
builtin/branch.c +9 -4
@@ -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,7 @@ enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), + DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds, } branch_name_pos =strcspn (fmt,"%" ); -if (!(flags &DELETE_BRANCH_FORCE )) +if (!(flags &DELETE_BRANCH_FORCE ) && + !(flags &DELETE_BRANCH_NO_HEAD_FALLBACK )) head_rev =lookup_commit_reference (the_repository, &head_oid);for (i =0 ; i < argc; i++,strbuf_reset (&bname)) {
Signature · 1 line
gitgitgadget
PATCH v20 5/7 branch: add --delete-merged <branch>
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
git branch (--delete-merged <branch>)... [<pattern>...]
deletes local branches matching the optional patterns when their configured upstream matches one of the --delete-merged arguments and their tip is reachable from that upstream. The work has already landed on the upstream they track, so the local copy is no longer needed.
The option can be repeated to widen the upstream match. Keeping the candidate patterns as positional arguments lets users bound the set of local branches that may be deleted independently of the upstream selection.
A branch is not deleted when:
* it is checked out in any worktree
* its configured upstream ref no longer exists, since a missing upstream is not by itself a sign of integration
* pushing it by name to the remote configured by branch.<name>.remote would update its upstream, as determined by mapping the branch ref through that remote's fetch refspec. For example, a local "main" that tracks "origin/main" is kept even when remote.pushDefault names a fork. Right after a pull it merely looks fully merged.
A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep.
A branch that a surviving branch depends on through a chain of local upstreams is also kept, so no branch is deleted out from under stacked work. Collect this transitive set without changing the candidate set during ref iteration: walk upstream chains from surviving branches, visit each branch at most once, and remove the collected bases only after the iteration completes. This makes the result independent of ref iteration order without repeated full scans.
--- Documentation/git-branch.adoc | 30 +++++ builtin/branch.c | 159 +++++++++++++++++++++++++- t/t3200-branch.sh | 204 ++++++++++++++++++++++++++++++++++ 3 files changed, 391 insertions(+), 2 deletions(-)
Documentation/git-branch.adoc +30 -0
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] +git branch (--delete-merged <branch>)... [<pattern>...] DESCRIPTION -----------
@@ -201,6 +202,35 @@ This option is only applicable in non-verbose mode. Print the name of the current branch. In detached `HEAD` state, nothing is printed. +`--delete-merged <branch>`:: + Delete local branches whose configured upstream matches + _<branch>_, but only when their tip is reachable from that + upstream. In other words, the work on the branch has already + landed on the upstream it tracks, so the local copy is no longer + needed. The option can be repeated to widen the upstream match. + Optional _<pattern>_ arguments limit which local branches are + considered, e.g. `git branch --delete-merged 'origin/*' + 'topic-*'`. ++ +A branch is not deleted when: ++ +-- +* its configured upstream ref no longer exists, +* it is checked out in any worktree, or +* pushing it by name to the remote configured by + `branch.<name>.remote` would update its upstream, so it cannot be + distinguished from a branch that just looks "fully merged" right + after a pull. +-- ++ +A branch whose work has not yet been merged into its upstream is +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. ++ +A branch that a surviving branch depends on through a chain of local +upstreams is kept, so a branch is never deleted out from under stacked +work. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +157 -2
213 lines not on this page · raw message
t/t3200-branch.sh +204 -0
208 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v20 6/7 branch: add branch.<name>.deleteMerged opt-out
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Setting branch.<name>.deleteMerged=false exempts that branch from "git branch --delete-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.
--- Documentation/config/branch.adoc | 7 +++++++ Documentation/git-branch.adoc | 5 +++-- builtin/branch.c | 14 +++++++++++++ t/t3200-branch.sh | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-)
Documentation/config/branch.adoc +7 -0
@@ -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.<name>.deleteMerged`:: + If set to `false`, branch _<name>_ is exempt from + `git branch --delete-merged`. Useful for a topic branch you + intend to develop further after an initial round has been + merged upstream. Defaults to true. Explicit deletion via + `git branch -d` is unaffected.
Documentation/git-branch.adoc +3 -2
@@ -216,11 +216,12 @@ A branch is not deleted when: + -- * its configured upstream ref no longer exists, -* it is checked out in any worktree, or +* it is checked out in any worktree, * pushing it by name to the remote configured by `branch.<name>.remote` would update its upstream, so it cannot be distinguished from a branch that just looks "fully merged" right - after a pull. + after a pull, or +* `branch.<name>.deleteMerged` is set to `false`. -- + A branch whose work has not yet been merged into its upstream is
builtin/branch.c +14 -0
@@ -786,6 +786,7 @@ static int delete_merged_branches(const struct strvec *upstreams,struct ref_array candidates = {0 };struct strset deletable_branch_names =STRSET_INIT ;struct strvec branches_to_delete =STRVEC_INIT ; +struct strbuf key =STRBUF_INIT ;struct hashmap_iter iter;struct strmap_entry *entry;size_t i;
@@ -805,6 +806,7 @@ static int delete_merged_branches(const struct strvec *upstreams,const char *branch_name;struct branch *branch;const char *upstream_refname; +int opt_out;if (!skip_prefix (branch_refname,"refs/heads/" , &branch_name))BUG ("filter returned non-branch ref '%s'" , branch_refname);
@@ -822,6 +824,17 @@ static int delete_merged_branches(const struct strvec *upstreams, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; +strbuf_reset (&key); +strbuf_addf (&key,"branch.%s.deletemerged" , branch_name); +if (!repo_config_get_bool (the_repository, key.buf, &opt_out) && + !opt_out) { +if (!(flags &DELETE_BRANCH_QUIET )) +fprintf (stderr, +_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), + branch_name, branch_name); +continue ; + } +strset_add (&deletable_branch_names, branch_name); }
@@ -837,6 +850,7 @@ static int delete_merged_branches(const struct strvec *upstreams,DELETE_BRANCH_NO_HEAD_FALLBACK | flags); +strbuf_release (&key);strvec_clear (&branches_to_delete);strset_clear (&deletable_branch_names);ref_array_clear (&candidates);
t/t3200-branch.sh +36 -0
@@ -2076,4 +2076,40 @@ test_expect_success '--delete-merged requires a value' ' test_must_fail git -C forked branch --delete-merged 2>err && test_grep "requires a value" err ' + +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' ' + setup_repo_for_delete_merged && + create_merged_branch deleted && + create_merged_branch kept && + ( + cd repo && + git config branch.kept.deleteMerged false && + git checkout --detach && + + git branch --delete-merged origin/next 2>err && + + test_grep "Skipping .kept." err && + check_branches <<-\EOF + kept + main + EOF + ) +' + +test_expect_success "branch -d still deletes a deleteMerged=false branch" ' + setup_repo_for_delete_merged && + create_merged_branch kept && + ( + cd repo && + git config branch.kept.deleteMerged false && + git checkout --detach && + + git branch -d kept && + + check_branches <<-\EOF + main + EOF + ) +' + test_done
Signature · 1 line
gitgitgadget
PATCH v20 7/7 branch: add --dry-run for --delete-merged
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
"git branch --dry-run --delete-merged ..." prints one line per ref that would be deleted without modifying refs or branch configuration.
--dry-run is only meaningful together with --delete-merged and is rejected otherwise.
--- Documentation/git-branch.adoc | 8 +++++++- builtin/branch.c | 21 ++++++++++++++++--- t/t3200-branch.sh | 38 ++++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 5 deletions(-)
Documentation/git-branch.adoc +7 -1
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] -git branch (--delete-merged <branch>)... [<pattern>...] +git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...] DESCRIPTION -----------
@@ -232,6 +232,12 @@ A branch that a surviving branch depends on through a chain of local upstreams is kept, so a branch is never deleted out from under stacked work. +`--dry-run`:: + With `--delete-merged`, print which branches would be + deleted and exit without touching any ref. Useful for + sanity-checking a wide pattern like `'origin/*'` before + committing to the deletion. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +18 -3
@@ -199,6 +199,7 @@ enum delete_branch_flags {DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ),DELETE_BRANCH_NO_HEAD_FALLBACK = (1 <<3 ), + DELETE_BRANCH_DRY_RUN = (1 << 4), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -340,13 +341,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 (!(flags &DELETE_BRANCH_DRY_RUN ) && +refs_delete_refs (get_main_ref_store (the_repository),NULL , &refs_to_delete,REF_NO_DEREF )) ret =1 ;for_each_string_list_item (item, &refs_to_delete) {char *describe_ref = item->util;char *name = item->string; -if (!refs_ref_exists (get_main_ref_store (the_repository), name)) { +if (flags &DELETE_BRANCH_DRY_RUN ) { +if (!(flags &DELETE_BRANCH_QUIET )) +printf (remote_branch + ?_ ("Would delete remote-tracking branch %s (was %s).\n" ) + :_ ("Would delete branch %s (was %s).\n" ), + name + branch_name_pos, describe_ref); + }else if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos;if (!(flags &DELETE_BRANCH_QUIET )) printf(remote_branch
@@ -923,6 +931,7 @@ int cmd_branch(int argc,int delete =0 , rename =0 , copy =0 , list =0 , unset_upstream =0 , show_current =0 , edit_description =0 ;struct strvec delete_merged =STRVEC_INIT ; +int dry_run =0 ;const char *new_upstream =NULL ;int noncreate_actions =0 ;/* possible options */
@@ -979,6 +988,8 @@ int cmd_branch(int argc,OPT_CALLBACK_F (0 ,"delete-merged" , &delete_merged,N_ ("branch" ),N_ ("delete merged branches whose upstream matches <branch> (repeatable)" ),PARSE_OPT_NONEG , parse_opt_strvec), +OPT_BOOL (0 ,"dry-run" , &dry_run, +N_ ("with --delete-merged, only print which branches would be deleted" )),OPT__FORCE (&force,N_ ("force creation, move/rename, deletion" ),PARSE_OPT_NOCOMPLETE ),OPT_MERGED (&filter,N_ ("print only branches that are merged" )), OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1041,6 +1052,9 @@ int cmd_branch(int argc,if (noncreate_actions >1 )usage_with_options (builtin_branch_usage, options); +if (dry_run && !delete_merged.nr) +die (_ ("--dry-run requires --delete-merged" )); +if (recurse_submodules_explicit) {if (!submodule_propagate_branches)die (_ ("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled" ));
@@ -1081,7 +1095,8 @@ int cmd_branch(int argc, goto out; }else if (delete_merged.nr) { ret =delete_merged_branches (&delete_merged, argv, - quiet ?DELETE_BRANCH_QUIET :0 ); + (quiet ?DELETE_BRANCH_QUIET :0 ) | + (dry_run ?DELETE_BRANCH_DRY_RUN :0 )); goto out; }else if (show_current) {print_current_branch_name ();
t/t3200-branch.sh +37 -1
@@ -1938,6 +1938,19 @@ test_expect_success '--delete-merged deletes only selected merged branches' ' git checkout -b tracks-other other/main --track && sha=$(git rev-parse --short merged) && + git branch --dry-run --delete-merged origin/next merged >actual 2>&1 && + echo "Would delete branch merged (was $sha)." >expect && + test_cmp expect actual && + git rev-parse --verify refs/heads/merged && + + check_branches <<-\EOF && + also-merged + main + merged + tracks-other + unmerged + EOF + git branch --delete-merged origin/next merged >actual 2>&1 && echo "Deleted branch merged (was $sha)." >expect && test_cmp expect actual &&
@@ -1986,9 +1999,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' ' git checkout -b topic feature --track && git commit --allow-empty -m "topic work" && - git branch --delete-merged origin/next 2>err && + git branch --dry-run --delete-merged origin/next >out && + test_grep ! "feature" out && + git branch --delete-merged origin/next 2>err && test_must_be_empty err && + check_branches <<-\EOF && feature main
@@ -2016,6 +2032,21 @@ test_expect_success '--delete-merged keeps the upstream chain of a surviving bra git checkout -b tip mid --track && git commit --allow-empty -m "tip work" && + git branch --dry-run --delete-merged origin/next \ + --delete-merged lower >actual 2>&1 && + test_must_be_empty actual && + + git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual && + cat >expect <<-\EOF && + branch.lower.remote origin + branch.lower.merge refs/heads/next + branch.mid.remote . + branch.mid.merge refs/heads/lower + branch.tip.remote . + branch.tip.merge refs/heads/mid + EOF + test_cmp expect actual && + git branch --delete-merged origin/next \ --delete-merged lower >actual 2>&1 && test_must_be_empty actual &&
@@ -2112,4 +2143,9 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" ' ) ' +test_expect_success '--dry-run without --delete-merged is rejected' ' + test_must_fail git -C forked branch --dry-run 2>err && + test_grep "requires --delete-merged" err +' + test_done
Signature · 1 line
gitgitgadget
Re: [PATCH v20 0/7] branch: delete-merged
In reply to Harald Nordgren via GitGitGadgetHi Harald
Quoted 11 lines · Harald Nordgren via GitGitGadget
Delete branches that have already been merged on upstream.
Changes in v20:
* Protect branches transitively required by a surviving local upstream stack. Traverse upstream chains once and defer delete-set mutation until traversal completes.
* Make stacked-branch handling independent of ref iteration order and update the documentation accordingly.
* Clarify variable names with regards to branch names (short) to reduce confusion.
I'm having a hard time reading the range diff due to the renaming of the members of "struct stacked_branch_data". Can you explain what has changed in the logic to protect branches that are upstreams of unmerged branches and why please? In particular why wasn't sufficient to stop removing the members of "spared" from "deletable" in spare_stacked_base() and remove them after refs_for_each_branch_ref() has returned instead.
Thanks
Phillip
Quoted 471 lines
Range-diff vs v19:
1: 562648132d = 1: 51c1c9d075 branch: add --forked filter for --list mode 2: c7ebd9344c = 2: 711574b2e5 branch: convert delete_branches() to a flags argument 3: 0c4f3358e3 = 3: 47c5975dc7 branch: let delete_branches skip unmerged branches on bulk refusal 4: 64a202526a = 4: 46268acec5 branch: prepare delete_branches for a bulk caller 5: a6caa5b397 ! 5: ef9f57e735 branch: add --delete-merged <branch> @@ Commit message A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep. - A branch that another, surviving branch tracks as its upstream is - also kept, so a branch is never deleted out from under one stacked - on top of it. Such a kept branch is itself merged, so when its own - upstream is being deleted, clear its now-stale upstream config. + A branch that a surviving branch depends on through a chain of local + upstreams is also kept, so no branch is deleted out from under stacked + work. Collect this transitive set without changing the candidate set + during ref iteration: walk upstream chains from surviving branches, + visit each branch at most once, and remove the collected bases only + after the iteration completes. This makes the result independent of + ref iteration order without repeated full scans. Signed-off-by: Harald Nordgren <[email protected]> @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. ++ -+A branch that another, surviving branch tracks as its upstream is -+kept, so a branch is never deleted out from under one stacked on top -+of it. If that kept branch in turn tracks a branch that is being -+deleted, its now-stale upstream configuration is cleared. ++A branch that a surviving branch depends on through a chain of local ++upstreams is kept, so a branch is never deleted out from under stacked ++work. + `-v`:: `-vv`:: @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch return 0; } -+struct spare_data { -+ struct strset *deletable; -+ struct strset *spared; ++struct stacked_branch_data { ++ struct strset *deletable_branch_names; ++ struct strset *protected_branch_names; ++ struct strset *visited_branch_names; +}; + -+/* -+ * A surviving branch stacked on a deletion candidate would lose its -+ * upstream, so drop that candidate from the delete set and remember it -+ * in "spared" so its own upstream can be tidied up afterwards. -+ */ -+static int spare_stacked_base(const struct reference *ref, void *cb_data) ++static int collect_stacked_branch_bases(const struct reference *ref, ++ void *cb_data) +{ -+ struct spare_data *data = cb_data; -+ struct branch *branch; -+ const char *upstream, *up_short; ++ struct stacked_branch_data *data = cb_data; ++ const char *branch_name; + -+ if (strset_contains(data->deletable, ref->name)) -+ return 0; -+ branch = branch_get(ref->name); -+ upstream = branch_get_upstream(branch, NULL); -+ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) || -+ !strset_contains(data->deletable, up_short)) ++ if (!skip_prefix(ref->name, "refs/heads/", &branch_name)) ++ BUG("expected local branch ref, got '%s'", ref->name); ++ if (strset_contains(data->deletable_branch_names, branch_name)) + return 0; + -+ strset_remove(data->deletable, up_short); -+ strset_add(data->spared, up_short); ++ while (strset_add(data->visited_branch_names, branch_name)) { ++ struct branch *branch = branch_get(branch_name); ++ const char *upstream_refname = branch_get_upstream(branch, NULL); ++ const char *upstream_branch_name; ++ ++ if (!upstream_refname || ++ !skip_prefix(upstream_refname, "refs/heads/", ++ &upstream_branch_name) || ++ !strset_contains(data->deletable_branch_names, ++ upstream_branch_name)) ++ break; ++ ++ strset_add(data->protected_branch_names, upstream_branch_name); ++ branch_name = upstream_branch_name; ++ } ++ + return 0; +} + -+/* -+ * Keep any branch that a surviving branch tracks as its upstream, so we -+ * never delete a branch out from under one stacked on top of it. Such a -+ * base is itself merged, so when its own upstream is also going away -+ * (no surviving branch tracks it), clear the base's now-stale upstream. -+ */ -+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable) ++static void protect_stacked_branch_bases(struct ref_store *refs, ++ struct strset *deletable_branch_names) +{ -+ struct strset spared = STRSET_INIT; -+ struct spare_data data = { .deletable = deletable, .spared = &spared }; -+ struct strbuf key = STRBUF_INIT; ++ struct strset protected_branch_names = STRSET_INIT; ++ struct strset visited_branch_names = STRSET_INIT; ++ struct stacked_branch_data data = { ++ .deletable_branch_names = deletable_branch_names, ++ .protected_branch_names = &protected_branch_names, ++ .visited_branch_names = &visited_branch_names, ++ }; ++ struct refs_for_each_ref_options opts = { ++ .prefix = "refs/heads/", ++ }; + struct hashmap_iter iter; + struct strmap_entry *entry; + -+ refs_for_each_branch_ref(refs, spare_stacked_base, &data); -+ -+ strset_for_each_entry(&spared, &iter, entry) { -+ struct branch *branch = branch_get(entry->key); -+ const char *upstream = branch_get_upstream(branch, NULL); -+ const char *up_short; -+ -+ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) || -+ !strset_contains(deletable, up_short)) -+ continue; ++ refs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts); + -+ strbuf_reset(&key); -+ strbuf_addf(&key, "branch.%s.merge", branch->name); -+ repo_config_set_gently(the_repository, key.buf, NULL); -+ strbuf_reset(&key); -+ strbuf_addf(&key, "branch.%s.remote", branch->name); -+ repo_config_set_gently(the_repository, key.buf, NULL); -+ } ++ strset_for_each_entry(&protected_branch_names, &iter, entry) ++ strset_remove(deletable_branch_names, entry->key); + -+ strbuf_release(&key); -+ strset_clear(&spared); ++ strset_clear(&visited_branch_names); ++ strset_clear(&protected_branch_names); +} + +static int branch_pushes_to_upstream(struct branch *branch, @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch + struct ref_store *refs = get_main_ref_store(the_repository); + struct ref_filter filter = REF_FILTER_INIT; + struct ref_array candidates = { 0 }; -+ struct strset deletable = STRSET_INIT; -+ struct strvec to_delete = STRVEC_INIT; ++ struct strset deletable_branch_names = STRSET_INIT; ++ struct strvec branches_to_delete = STRVEC_INIT; + struct hashmap_iter iter; + struct strmap_entry *entry; + size_t i; @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch + filter_refs(&candidates, &filter, filter.kind); + + for (i = 0; i < (size_t)candidates.nr; i++) { -+ const char *full_name = candidates.items[i]->refname; -+ const char *short_name; ++ const char *branch_refname = candidates.items[i]->refname; ++ const char *branch_name; + struct branch *branch; -+ const char *upstream; ++ const char *upstream_refname; + -+ if (!skip_prefix(full_name, "refs/heads/", &short_name)) -+ BUG("filter returned non-branch ref '%s'", full_name); -+ if (branch_checked_out(full_name)) ++ if (!skip_prefix(branch_refname, "refs/heads/", &branch_name)) ++ BUG("filter returned non-branch ref '%s'", branch_refname); ++ if (branch_checked_out(branch_refname)) + continue; + -+ branch = branch_get(short_name); -+ upstream = branch_get_upstream(branch, NULL); -+ if (!upstream || !refs_ref_exists(refs, upstream)) ++ branch = branch_get(branch_name); ++ upstream_refname = branch_get_upstream(branch, NULL); ++ if (!upstream_refname || !refs_ref_exists(refs, upstream_refname)) + continue; -+ if (branch_pushes_to_upstream(branch, upstream)) ++ if (branch_pushes_to_upstream(branch, upstream_refname)) + continue; -+ if (check_branch_commit(short_name, short_name, ++ if (check_branch_commit(branch_name, branch_name, + &candidates.items[i]->objectname, NULL, + FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) + continue; + -+ strset_add(&deletable, short_name); ++ strset_add(&deletable_branch_names, branch_name); + } + -+ spare_stacked_bases(refs, &deletable); ++ protect_stacked_branch_bases(refs, &deletable_branch_names); + -+ strset_for_each_entry(&deletable, &iter, entry) -+ strvec_push(&to_delete, entry->key); ++ strset_for_each_entry(&deletable_branch_names, &iter, entry) ++ strvec_push(&branches_to_delete, entry->key); + -+ if (to_delete.nr) -+ ret = delete_branches(to_delete.nr, to_delete.v, ++ if (branches_to_delete.nr) ++ ret = delete_branches(branches_to_delete.nr, branches_to_delete.v, + FILTER_REFS_BRANCHES, + DELETE_BRANCH_SKIP_UNMERGED | + DELETE_BRANCH_NO_HEAD_FALLBACK | + flags); + -+ strvec_clear(&to_delete); -+ strset_clear(&deletable); ++ strvec_clear(&branches_to_delete); ++ strset_clear(&deletable_branch_names); + ref_array_clear(&candidates); + ref_filter_clear(&filter); + return ret; @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' ' + ) +' + -+test_expect_success '--delete-merged clears the deleted upstream of a spared branch' ' ++test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' ' + setup_repo_for_delete_merged && + ( + cd repo && @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' ' + git commit --allow-empty -m "tip work" && + + git branch --delete-merged origin/next \ -+ --delete-merged lower && ++ --delete-merged lower >actual 2>&1 && ++ test_must_be_empty actual && + + check_branches <<-\EOF && ++ lower + main + mid + tip + EOF + -+ git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual && ++ git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual && + cat >expect <<-\EOF && ++ branch.lower.remote origin ++ branch.lower.merge refs/heads/next ++ branch.mid.remote . ++ branch.mid.merge refs/heads/lower + branch.tip.remote . + branch.tip.merge refs/heads/mid + EOF @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' ' + ) +' + ++test_expect_success '--delete-merged result is independent of stacked branch names' ' ++ setup_repo_for_delete_merged && ++ ( ++ cd repo && ++ git branch c-lower origin/next --track && ++ git branch b-mid c-lower --track && ++ git checkout -b a-tip b-mid --track && ++ git commit --allow-empty -m "tip work" && ++ ++ git branch --delete-merged origin/next \ ++ --delete-merged "c-*" && ++ ++ check_branches <<-\EOF && ++ a-tip ++ b-mid ++ c-lower ++ main ++ EOF ++ ++ git branch --delete-merged origin/next \ ++ --delete-merged "c-*" >actual 2>&1 && ++ test_must_be_empty actual && ++ ++ check_branches <<-\EOF ++ a-tip ++ b-mid ++ c-lower ++ main ++ EOF ++ ) ++' ++ +test_expect_success '--delete-merged requires a value' ' + test_must_fail git -C forked branch --delete-merged 2>err && + test_grep "requires a value" err 6: 734d27c908 ! 6: fa70108611 branch: add branch.<name>.deleteMerged opt-out @@ Documentation/git-branch.adoc: A branch is not deleted when: ## builtin/branch.c ## @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams, struct ref_array candidates = { 0 }; - struct strset deletable = STRSET_INIT; - struct strvec to_delete = STRVEC_INIT; + struct strset deletable_branch_names = STRSET_INIT; + struct strvec branches_to_delete = STRVEC_INIT; + struct strbuf key = STRBUF_INIT; struct hashmap_iter iter; struct strmap_entry *entry; size_t i; @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams, - const char *short_name; + const char *branch_name; struct branch *branch; - const char *upstream; + const char *upstream_refname; + int opt_out; - if (!skip_prefix(full_name, "refs/heads/", &short_name)) - BUG("filter returned non-branch ref '%s'", full_name); + if (!skip_prefix(branch_refname, "refs/heads/", &branch_name)) + BUG("filter returned non-branch ref '%s'", branch_refname); @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; + strbuf_reset(&key); -+ strbuf_addf(&key, "branch.%s.deletemerged", short_name); ++ strbuf_addf(&key, "branch.%s.deletemerged", branch_name); + if (!repo_config_get_bool(the_repository, key.buf, &opt_out) && + !opt_out) { + if (!(flags & DELETE_BRANCH_QUIET)) + fprintf(stderr, + _("Skipping '%s' (branch.%s.deleteMerged is false)\n"), -+ short_name, short_name); ++ branch_name, branch_name); + continue; + } + - strset_add(&deletable, short_name); + strset_add(&deletable_branch_names, branch_name); } @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams, @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea flags); + strbuf_release(&key); - strvec_clear(&to_delete); - strset_clear(&deletable); + strvec_clear(&branches_to_delete); + strset_clear(&deletable_branch_names); ref_array_clear(&candidates); ## t/t3200-branch.sh ## 7: 7aa9d5db14 ! 7: 13bac431a3 branch: add --dry-run for --delete-merged @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch> DESCRIPTION ----------- -@@ Documentation/git-branch.adoc: kept, so a branch is never deleted out from under one stacked on top - of it. If that kept branch in turn tracks a branch that is being - deleted, its now-stale upstream configuration is cleared. +@@ Documentation/git-branch.adoc: A branch that a surviving branch depends on through a chain of local + upstreams is kept, so a branch is never deleted out from under stacked + work. +`--dry-run`:: + With `--delete-merged`, print which branches would be @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki char *refname = name + branch_name_pos; if (!(flags & DELETE_BRANCH_QUIET)) printf(remote_branch -@@ builtin/branch.c: static int spare_stacked_base(const struct reference *ref, void *cb_data) - * base is itself merged, so when its own upstream is also going away - * (no surviving branch tracks it), clear the base's now-stale upstream. - */ --static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable) -+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable, -+ unsigned int flags) - { - struct strset spared = STRSET_INIT; - struct spare_data data = { .deletable = deletable, .spared = &spared }; -@@ builtin/branch.c: static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable - - refs_for_each_branch_ref(refs, spare_stacked_base, &data); - -- strset_for_each_entry(&spared, &iter, entry) { -- struct branch *branch = branch_get(entry->key); -- const char *upstream = branch_get_upstream(branch, NULL); -- const char *up_short; -+ if (!(flags & DELETE_BRANCH_DRY_RUN)) { -+ strset_for_each_entry(&spared, &iter, entry) { -+ struct branch *branch = branch_get(entry->key); -+ const char *upstream = branch_get_upstream(branch, NULL); -+ const char *up_short; - -- if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) || -- !strset_contains(deletable, up_short)) -- continue; -+ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) || -+ !strset_contains(deletable, up_short)) -+ continue; - -- strbuf_reset(&key); -- strbuf_addf(&key, "branch.%s.merge", branch->name); -- repo_config_set_gently(the_repository, key.buf, NULL); -- strbuf_reset(&key); -- strbuf_addf(&key, "branch.%s.remote", branch->name); -- repo_config_set_gently(the_repository, key.buf, NULL); -+ strbuf_reset(&key); -+ strbuf_addf(&key, "branch.%s.merge", branch->name); -+ repo_config_set_gently(the_repository, key.buf, NULL); -+ strbuf_reset(&key); -+ strbuf_addf(&key, "branch.%s.remote", branch->name); -+ repo_config_set_gently(the_repository, key.buf, NULL); -+ } - } - - strbuf_release(&key); -@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams, - strset_add(&deletable, short_name); - } - -- spare_stacked_bases(refs, &deletable); -+ spare_stacked_bases(refs, &deletable, flags); - - strset_for_each_entry(&deletable, &iter, entry) - strvec_push(&to_delete, entry->key); @@ builtin/branch.c: int cmd_branch(int argc, int delete = 0, rename = 0, copy = 0, list = 0, unset_upstream = 0, show_current = 0, edit_description = 0; @@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a check_branches <<-\EOF && feature main -@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstream of a spared bra +@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream chain of a surviving bra git checkout -b tip mid --track && git commit --allow-empty -m "tip work" && + git branch --dry-run --delete-merged origin/next \ -+ --delete-merged lower && ++ --delete-merged lower >actual 2>&1 && ++ test_must_be_empty actual && + -+ git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual && ++ git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual && + cat >expect <<-\EOF && ++ branch.lower.remote origin ++ branch.lower.merge refs/heads/next + branch.mid.remote . + branch.mid.merge refs/heads/lower + branch.tip.remote . @@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstr + test_cmp expect actual && + git branch --delete-merged origin/next \ - --delete-merged lower && - + --delete-merged lower >actual 2>&1 && + test_must_be_empty actual && @@ t/t3200-branch.sh: test_expect_success "branch -d still deletes a deleteMerged=false branch" ' ) '
Re: [PATCH v20 0/7] branch: delete-merged
Quoted 16 lines · Phillip Wood
Hi Harald
Quoted 11 lines · Harald Nordgren via GitGitGadget
Delete branches that have already been merged on upstream.
Changes in v20:
* Protect branches transitively required by a surviving local upstream stack. Traverse upstream chains once and defer delete-set mutation until traversal completes.
* Make stacked-branch handling independent of ref iteration order and update the documentation accordingly.
* Clarify variable names with regards to branch names (short) to reduce confusion.
Quoted 7 lines
I'm having a hard time reading the range diff due to the renaming of the members of "struct
stacked_branch_data". Can you explain what has changed in the logic to protect branches that are upstreams of unmerged branches and why please? In particular why wasn't sufficient to stop removing the members of "spared" from "deletable" inspare_stacked_base()and remove them afterrefs_for_each_branch_ref()has returned instead.
Hmm. The main idea is to bite the bullet and actually traverse the graph since it didn't seem possible to finish the job correctly in a single pass anotherwise which I'm trying to demonstrate with
test_expect_success '--delete-merged keeps the upstream chain of a
surviving branch' '
It becomes a bit like whack-a-mole when Junio asks me to clarify the code and now you cannot review it because of that. I'm still not friends with range-diff even after using it for some time, I agree that the diff v19...v20 doesn't look very inviting.
Harald
v21 cover branch: delete-merged
In reply to Harald Nordgren via GitGitGadgetDelete branches that have already been merged on upstream.
Changes in v21:
* Change loop variables to prevent CI timeout.
Changes in v20:
* Protect branches transitively required by a surviving local upstream stack. Traverse upstream chains once and defer delete-set mutation until traversal completes.
* Make stacked-branch handling independent of ref iteration order and update the documentation accordingly.
* Clarify variable names with regards to branch names (short) to reduce confusion.
Changes in v19:
* Fix bug where dry-run would still remove config, added test coverage.
* Redesigned --delete-merged as a repeatable upstream selector with optional positional patterns limiting deletion scope.
* Protect same-name upstream branches independently of push-default configuration.
* Simplified flags handling where local caching became complicated when mutating values.
* Clarified assertions in tests.
Changes in v18:
* Instead of keeping the whole chain of upstream branches, keep only the ones an unmerged branch still needs. When a kept (merged) branch in turn tracks a branch that is being deleted, clear its now-stale upstream config.
* Rework spare_stacked_bases() to record the kept bases and, in a second pass, clear the upstream of any whose own base is going away. Build the to-delete list with strset_for_each_entry() instead of re-walking the candidate array.
Changes in v17:
* Keep a merged branch when another surviving branch still tracks it as its upstream, so --delete-merged no longer deletes a branch out from under one stacked on top of it.
* Move the --dry-run and branch.<name>.deleteMerged opt-out fully into their own commits.
Changes in v16:
* Convert delete_merged_branches() to take an unsigned int flags argument instead of separate quiet/dry_run booleans, matching delete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_reset per iteration, single strbuf_release after) instead of allocating and freeing it each time
* Rewrite the --delete-merged tests as integration tests: branches that land commits upstream, with deletion and the checked-out, upstream-gone, and push-equals-upstream safety cases exercised together in one run and output asserted via test_cmp
* Collapse the many per-aspect test repos into a single reused repo set up by a setup_repo_for_delete_merged helper, and rename helpers off the old pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of prefixing every command with -C
Changes in v15:
* Renamed --prune-merged to --delete-merged throughout. Not necessarily final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of warning.
* Initialized the delete_branches() flag locals where declared. Only force stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f, --forked coverage), renamed the misleading trunk fixture, and replaced the misnamed detached branch with git checkout --detach.
Changes in v14:
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the remotes path set a local force but not the DELETE_BRANCH_FORCE bit that check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other branches too, hopefully not related
(https://github.com/git/git/pull/2285).
Changes in v13:
* Reworked --forked into a real ref-filter applied in apply_ref_filter() instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the abbreviated upstream), and dropped the old helper machinery, forward declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git branch --prune-merged origin/main 'feature*') instead of repeating the option.
Changes in v12:
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST options.
* Dropped the bare-remote-name resolution for --forked, the argument is now a ref or a glob.
Changes in v11:
* 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 /HEAD by name guard that the reviewer rejected is gone.
* New --dry-run for --prune-merged.
Changes in v10:
* --forked / --prune-merged now take a branch glob instead of a remote name — origin, origin/*, origin/release-- all work. This replaces the remote-only form and subsumes the old --all-remotes flag, which has been dropped.
* New --dry-run for --prune-merged.
Changes in v9:
* --force no longer has special meaning with --prune-merged; reachability is always enforced. Use git branch -D to delete an unmerged branch. Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.
Changes in v8:
* Delete only when the branch's work is actually reachable from its upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place
Changes in v7:
* --prune-merged now checks if a branch is merged into its own upstream first. If the upstream is gone, it checks against the remote's default branch instead. If neither exists, the branch is refused (use --force to delete anyway).
Changes in v6:
* --prune-merged now measures merged-ness against the remote's default branch instead of the candidate's upstream — so the decision no longer depends on which branch happens to be checked out locally.
* delete_branches() / check_branch_commit() gained a per-candidate override that lets a caller substitute a different "what counts as merged" reference (or skip the check). branch -d callers pass NULL and keep their existing semantics.
* prune_merged_branches() resolves each candidate's push-remote HEAD and threads it through, so --prune-merged --all-remotes measures each candidate against its own remote rather than a single global reference.
Changes in v5:
* Drop commit 'fetch: add --prune-merged'
Changes in v4:
* Resolve each remote's HEAD and collect the targets into a protected_default_refs set in collect_forked_set.
* In prune_merged_branches, skip a candidate when its upstream is a protected default ref and the local branch name matches the default branch's leaf name (so a local main tracking origin/main is spared, but a renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name (not by upstream alone); spare a branch whose push ref is the remote default.
Changes in v3:
* s/remote-tracking refs/remote-tracking branches/g
Changes in v2:
* The whole feature moved out of git fetch and into git branch. git fetch --prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their tip is reachable from the upstream tracking ref; --force skips that safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running topic branch) even with --force; doesn't affect explicit git branch -d.
* delete_branches() got a warn_only mode so bulk deletion prints a one-line warning per skipped branch instead of the noisy four-line hint that git branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention --prune-merged.
* New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh shrunk since most logic moved.
Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <branch>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged
Documentation/config/branch.adoc | 7 + Documentation/git-branch.adoc | 49 +++- builtin/branch.c | 275 ++++++++++++++++++--- ref-filter.c | 70 ++++++ ref-filter.h | 10 + t/t3200-branch.sh | 393 +++++++++++++++++++++++++++++++ 6 files changed, 773 insertions(+), 31 deletions(-)
base-commit: 9a0c4701dcd5725c4184599322b52933ff5005ca
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v21
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v21
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v20:
1: 51c1c9d075 = 1: 90aa528bef branch: add --forked filter for --list mode
2: 711574b2e5 = 2: 048c002cb4 branch: convert delete_branches() to a flags argument
3: 47c5975dc7 = 3: bb93303df3 branch: let delete_branches skip unmerged branches on bulk refusal
4: 46268acec5 = 4: e70e64c866 branch: prepare delete_branches for a bulk caller
5: ef9f57e735 ! 5: 598e68751e branch: add --delete-merged <branch>
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ struct strvec branches_to_delete = STRVEC_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
-+ size_t i;
+ int ret = 0;
+
-+ for (i = 0; i < upstreams->nr; i++)
++ for (size_t i = 0; i < upstreams->nr; i++)
+ if (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)
+ die(_("'%s' is not a valid branch or pattern"),
+ upstreams->v[i]);
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ filter.name_patterns = argv;
+ filter_refs(&candidates, &filter, filter.kind);
+
-+ for (i = 0; i < (size_t)candidates.nr; i++) {
++ for (size_t i = 0; i < (size_t)candidates.nr; i++) {
+ const char *branch_refname = candidates.items[i]->refname;
+ const char *branch_name;
+ struct branch *branch;
6: fa70108611 ! 6: ac63689d8a branch: add branch.<name>.deleteMerged opt-out
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
+ struct strbuf key = STRBUF_INIT;
struct hashmap_iter iter;
struct strmap_entry *entry;
- size_t i;
+ int ret = 0;
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
const char *branch_name;
struct branch *branch; 7: 13bac431a3 = 7: 73c3281b6f branch: add --dry-run for --delete-merged
Signature · 1 line
gitgitgadget
PATCH v21 1/7 branch: add --forked filter for --list mode
From: Harald Nordgren <[email protected]>
Add a --forked option to "git branch" list mode that lists only branches whose configured upstream matches <branch>. The argument can be a ref (e.g. "origin/main", "master"), a remote name like "origin" for the branch its origin/HEAD points at, or a shell 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 --delete-merged, which deletes the listed branches once they have landed on their upstream.
--- Documentation/git-branch.adoc | 12 +++- builtin/branch.c | 18 +++++- ref-filter.c | 70 ++++++++++++++++++++ ref-filter.h | 10 +++ t/t3200-branch.sh | 117 ++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 3 deletions(-)
Documentation/git-branch.adoc +11 -1
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current] [--column[=<options>] | --no-column] [--sort=<key>] [--merged [<commit>]] [--no-merged [<commit>]] [--contains [<commit>]] [--no-contains [<commit>]] + [(--forked <branch>)...] [--points-at <object>] [--format=<format>] [(-r|--remotes) | (-a|--all)] [--list] [<pattern>...]
@@ -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 _<commit>_ argument is missing it defaults to `HEAD` (i.e. the tip of the current -branch). +branch). With `--forked`, only branches whose configured upstream matches +the given branch or pattern will be listed. The command's second form creates a new branch head named _<branch-name>_ which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main". Only list branches whose tips are not reachable from _<commit>_ (`HEAD` if not specified). Implies `--list`. +`--forked <branch>`:: + Only list branches whose configured upstream matches + _<branch>_. The argument can be a ref (e.g. `origin/main`, + `master`), a remote name like `origin` for the branch its + `origin/HEAD` points at, or a shell-style glob (e.g. + `'origin/*'`). The option can be repeated to widen the + filter. Implies `--list`. + `--points-at <object>`:: Only list branches of _<object>_.
builtin/branch.c +16 -2
@@ -30,7 +30,7 @@#include "commit-reach.h" static const char *const builtin_branch_usage[] = { -N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged]" ), +N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]" ),N_ ("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]" ),N_ ("git branch [<options>] [-l] [<pattern>...]" ),N_ ("git branch [<options>] [-r] (-d | -D) <branch-name>..." ),
@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, intfree_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)
@@ -793,6 +803,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 <branch> (repeatable)" ), +PARSE_OPT_NONEG , parse_opt_forked),OPT_COLUMN (0 ,"column" , &colopts,N_ ("list branches in columns" )),OPT_REF_SORT (&sorting_options), OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -838,7 +851,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 +
ref-filter.c +70 -0
88 lines not on this page · raw message
ref-filter.h +10 -0
@@ -67,6 +67,7 @@ struct ref_filter {const char **name_patterns;const char *start_after;struct strvec exclude; +struct strvec forked;struct oid_array points_at;struct commit_list *with_commit;struct commit_list *no_commit;
@@ -110,6 +111,7 @@ struct ref_format {#define REF_FILTER_INIT { \ .points_at = OID_ARRAY_INIT, \ .exclude = STRVEC_INIT, \ + .forked = STRVEC_INIT, \ }#define REF_FORMAT_INIT { \ .use_color = GIT_COLOR_UNKNOWN, \
@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);struct ref_sorting *ref_sorting_options (struct string_list *);/* Function to parse --merged and --no-merged options */ int parse_opt_merge_filter (const struct option *opt,const char *arg,int unset); +/* +* Register a --forked <branch> pattern on the filter. The argument is +* either a ref, which is resolved to its full refname, or a shell-style +* glob. Branches are kept only when their configured upstream matches +* one of the registered patterns. Returns -1 if the argument is not a +* valid ref or pattern. +*/ +int ref_filter_forked_add (struct ref_filter *filter,const char *arg);/* Get the current HEAD's description */ char *get_head_description (void );/* Set up translated strings in the output. */
t/t3200-branch.sh +117 -0
121 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v21 2/7 branch: convert delete_branches() to a flags argument
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
delete_branches() takes separate force and quiet parameters, while check_branch_commit() takes force. The next commits would grow this collection further. Replace them with a single unsigned flags argument and an enum.
Test the FORCE and QUIET bits directly from flags at each use site so that mutating or forwarding flags cannot leave cached values stale.
No change in behavior.
--- builtin/branch.c | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-)
builtin/branch.c +24 -16
@@ -189,16 +189,22 @@ 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 ), +}; +static int check_branch_commit (const char *branchname,const char *refname,const struct object_id *oid,struct commit *head_rev, -int kinds,int force) +int kinds,unsigned int flags) {struct commit *rev =lookup_commit_reference (the_repository, oid); -if (!force && !rev) { +if (!(flags &DELETE_BRANCH_FORCE ) && !rev) {error (_ ("couldn't look up commit object for '%s'" ), refname);return -1 ; } -if (!force && !branch_merged (kinds, branchname, rev, head_rev)) { +if (!(flags &DELETE_BRANCH_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, "
@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)strbuf_release (&buf); } -static int delete_branches (int argc,const char **argv,int force,int kinds, -int quiet) +static int delete_branches (int argc,const char **argv,int kinds, +unsigned int flags) {struct commit *head_rev =NULL ;struct object_id oid;
@@ -241,7 +247,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 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, } branch_name_pos =strcspn (fmt,"%" ); -if (!force) +if (!(flags &DELETE_BRANCH_FORCE )) 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 +285,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 +297,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 +312,16 @@ 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)) { + flags)) { 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:
@@ -330,7 +336,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,char *name = item->string;if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos; -if (!quiet) +if (!(flags &DELETE_BRANCH_QUIET )) printf(remote_branch ? _("Deleted remote-tracking branch %s (was %s).\n") : _("Deleted branch %s (was %s).\n"),
@@ -895,7 +901,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 ();
Signature · 1 line
gitgitgadget
PATCH v21 3/7 branch: let delete_branches skip unmerged branches on bulk refusal
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Add a skip-unmerged mode to delete_branches() and check_branch_commit() so a bulk caller can silently skip branches that are not fully merged and carry on, rather than erroring with the "use 'git branch -D'" advice that the plain "git branch -d" path emits. Existing callers are unaffected.
--- builtin/branch.c | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-)
builtin/branch.c +10 -5
@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ), +DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname, }if (!(flags &DELETE_BRANCH_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_SKIP_UNMERGED )) { +error (_ ("the branch '%s' is not fully merged" ), + branchname); +advise_if_enabled (ADVICE_FORCE_DELETE_BRANCH , +_ ("If you are sure you want to delete it, " +"run 'git branch -D %s'" ), branchname); + }return -1 ; }return 0 ;
@@ -315,7 +319,8 @@ static int delete_branches(int argc, const char **argv, int kinds,if (!(ref_flags & (REF_ISSYMREF |REF_ISBROKEN )) &&check_branch_commit (bname.buf, name, &oid, head_rev, kinds, flags)) { - ret =1 ; +if (!(flags &DELETE_BRANCH_SKIP_UNMERGED )) + ret =1 ; goto next; }
Signature · 1 line
gitgitgadget
PATCH v21 4/7 branch: prepare delete_branches for a bulk caller
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Teach delete_branches() a new mode for the upcoming --delete-merged caller that checks whether a branch is merged into its upstream without falling back to HEAD when there is no upstream. Existing callers keep their current behavior.
--- builtin/branch.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-)
builtin/branch.c +9 -4
@@ -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,7 @@ enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), + DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds, } branch_name_pos =strcspn (fmt,"%" ); -if (!(flags &DELETE_BRANCH_FORCE )) +if (!(flags &DELETE_BRANCH_FORCE ) && + !(flags &DELETE_BRANCH_NO_HEAD_FALLBACK )) head_rev =lookup_commit_reference (the_repository, &head_oid);for (i =0 ; i < argc; i++,strbuf_reset (&bname)) {
Signature · 1 line
gitgitgadget
PATCH v21 5/7 branch: add --delete-merged <branch>
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
git branch (--delete-merged <branch>)... [<pattern>...]
deletes local branches matching the optional patterns when their configured upstream matches one of the --delete-merged arguments and their tip is reachable from that upstream. The work has already landed on the upstream they track, so the local copy is no longer needed.
The option can be repeated to widen the upstream match. Keeping the candidate patterns as positional arguments lets users bound the set of local branches that may be deleted independently of the upstream selection.
A branch is not deleted when:
* it is checked out in any worktree
* its configured upstream ref no longer exists, since a missing upstream is not by itself a sign of integration
* pushing it by name to the remote configured by branch.<name>.remote would update its upstream, as determined by mapping the branch ref through that remote's fetch refspec. For example, a local "main" that tracks "origin/main" is kept even when remote.pushDefault names a fork. Right after a pull it merely looks fully merged.
A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep.
A branch that a surviving branch depends on through a chain of local upstreams is also kept, so no branch is deleted out from under stacked work. Collect this transitive set without changing the candidate set during ref iteration: walk upstream chains from surviving branches, visit each branch at most once, and remove the collected bases only after the iteration completes. This makes the result independent of ref iteration order without repeated full scans.
--- Documentation/git-branch.adoc | 30 +++++ builtin/branch.c | 158 +++++++++++++++++++++++++- t/t3200-branch.sh | 204 ++++++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 2 deletions(-)
Documentation/git-branch.adoc +30 -0
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] +git branch (--delete-merged <branch>)... [<pattern>...] DESCRIPTION -----------
@@ -201,6 +202,35 @@ This option is only applicable in non-verbose mode. Print the name of the current branch. In detached `HEAD` state, nothing is printed. +`--delete-merged <branch>`:: + Delete local branches whose configured upstream matches + _<branch>_, but only when their tip is reachable from that + upstream. In other words, the work on the branch has already + landed on the upstream it tracks, so the local copy is no longer + needed. The option can be repeated to widen the upstream match. + Optional _<pattern>_ arguments limit which local branches are + considered, e.g. `git branch --delete-merged 'origin/*' + 'topic-*'`. ++ +A branch is not deleted when: ++ +-- +* its configured upstream ref no longer exists, +* it is checked out in any worktree, or +* pushing it by name to the remote configured by + `branch.<name>.remote` would update its upstream, so it cannot be + distinguished from a branch that just looks "fully merged" right + after a pull. +-- ++ +A branch whose work has not yet been merged into its upstream is +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. ++ +A branch that a surviving branch depends on through a chain of local +upstreams is kept, so a branch is never deleted out from under stacked +work. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +156 -2
212 lines not on this page · raw message
t/t3200-branch.sh +204 -0
208 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v21 6/7 branch: add branch.<name>.deleteMerged opt-out
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Setting branch.<name>.deleteMerged=false exempts that branch from "git branch --delete-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.
--- Documentation/config/branch.adoc | 7 +++++++ Documentation/git-branch.adoc | 5 +++-- builtin/branch.c | 14 +++++++++++++ t/t3200-branch.sh | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-)
Documentation/config/branch.adoc +7 -0
@@ -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.<name>.deleteMerged`:: + If set to `false`, branch _<name>_ is exempt from + `git branch --delete-merged`. Useful for a topic branch you + intend to develop further after an initial round has been + merged upstream. Defaults to true. Explicit deletion via + `git branch -d` is unaffected.
Documentation/git-branch.adoc +3 -2
@@ -216,11 +216,12 @@ A branch is not deleted when: + -- * its configured upstream ref no longer exists, -* it is checked out in any worktree, or +* it is checked out in any worktree, * pushing it by name to the remote configured by `branch.<name>.remote` would update its upstream, so it cannot be distinguished from a branch that just looks "fully merged" right - after a pull. + after a pull, or +* `branch.<name>.deleteMerged` is set to `false`. -- + A branch whose work has not yet been merged into its upstream is
builtin/branch.c +14 -0
@@ -786,6 +786,7 @@ static int delete_merged_branches(const struct strvec *upstreams,struct ref_array candidates = {0 };struct strset deletable_branch_names =STRSET_INIT ;struct strvec branches_to_delete =STRVEC_INIT ; +struct strbuf key =STRBUF_INIT ;struct hashmap_iter iter;struct strmap_entry *entry;int ret =0 ;
@@ -804,6 +805,7 @@ static int delete_merged_branches(const struct strvec *upstreams,const char *branch_name;struct branch *branch;const char *upstream_refname; +int opt_out;if (!skip_prefix (branch_refname,"refs/heads/" , &branch_name))BUG ("filter returned non-branch ref '%s'" , branch_refname);
@@ -821,6 +823,17 @@ static int delete_merged_branches(const struct strvec *upstreams, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; +strbuf_reset (&key); +strbuf_addf (&key,"branch.%s.deletemerged" , branch_name); +if (!repo_config_get_bool (the_repository, key.buf, &opt_out) && + !opt_out) { +if (!(flags &DELETE_BRANCH_QUIET )) +fprintf (stderr, +_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), + branch_name, branch_name); +continue ; + } +strset_add (&deletable_branch_names, branch_name); }
@@ -836,6 +849,7 @@ static int delete_merged_branches(const struct strvec *upstreams,DELETE_BRANCH_NO_HEAD_FALLBACK | flags); +strbuf_release (&key);strvec_clear (&branches_to_delete);strset_clear (&deletable_branch_names);ref_array_clear (&candidates);
t/t3200-branch.sh +36 -0
@@ -2076,4 +2076,40 @@ test_expect_success '--delete-merged requires a value' ' test_must_fail git -C forked branch --delete-merged 2>err && test_grep "requires a value" err ' + +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' ' + setup_repo_for_delete_merged && + create_merged_branch deleted && + create_merged_branch kept && + ( + cd repo && + git config branch.kept.deleteMerged false && + git checkout --detach && + + git branch --delete-merged origin/next 2>err && + + test_grep "Skipping .kept." err && + check_branches <<-\EOF + kept + main + EOF + ) +' + +test_expect_success "branch -d still deletes a deleteMerged=false branch" ' + setup_repo_for_delete_merged && + create_merged_branch kept && + ( + cd repo && + git config branch.kept.deleteMerged false && + git checkout --detach && + + git branch -d kept && + + check_branches <<-\EOF + main + EOF + ) +' + test_done
Signature · 1 line
gitgitgadget
PATCH v21 7/7 branch: add --dry-run for --delete-merged
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
"git branch --dry-run --delete-merged ..." prints one line per ref that would be deleted without modifying refs or branch configuration.
--dry-run is only meaningful together with --delete-merged and is rejected otherwise.
--- Documentation/git-branch.adoc | 8 +++++++- builtin/branch.c | 21 ++++++++++++++++--- t/t3200-branch.sh | 38 ++++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 5 deletions(-)
Documentation/git-branch.adoc +7 -1
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] -git branch (--delete-merged <branch>)... [<pattern>...] +git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...] DESCRIPTION -----------
@@ -232,6 +232,12 @@ A branch that a surviving branch depends on through a chain of local upstreams is kept, so a branch is never deleted out from under stacked work. +`--dry-run`:: + With `--delete-merged`, print which branches would be + deleted and exit without touching any ref. Useful for + sanity-checking a wide pattern like `'origin/*'` before + committing to the deletion. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +18 -3
@@ -199,6 +199,7 @@ enum delete_branch_flags {DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ),DELETE_BRANCH_NO_HEAD_FALLBACK = (1 <<3 ), + DELETE_BRANCH_DRY_RUN = (1 << 4), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -340,13 +341,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 (!(flags &DELETE_BRANCH_DRY_RUN ) && +refs_delete_refs (get_main_ref_store (the_repository),NULL , &refs_to_delete,REF_NO_DEREF )) ret =1 ;for_each_string_list_item (item, &refs_to_delete) {char *describe_ref = item->util;char *name = item->string; -if (!refs_ref_exists (get_main_ref_store (the_repository), name)) { +if (flags &DELETE_BRANCH_DRY_RUN ) { +if (!(flags &DELETE_BRANCH_QUIET )) +printf (remote_branch + ?_ ("Would delete remote-tracking branch %s (was %s).\n" ) + :_ ("Would delete branch %s (was %s).\n" ), + name + branch_name_pos, describe_ref); + }else if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos;if (!(flags &DELETE_BRANCH_QUIET )) printf(remote_branch
@@ -922,6 +930,7 @@ int cmd_branch(int argc,int delete =0 , rename =0 , copy =0 , list =0 , unset_upstream =0 , show_current =0 , edit_description =0 ;struct strvec delete_merged =STRVEC_INIT ; +int dry_run =0 ;const char *new_upstream =NULL ;int noncreate_actions =0 ;/* possible options */
@@ -978,6 +987,8 @@ int cmd_branch(int argc,OPT_CALLBACK_F (0 ,"delete-merged" , &delete_merged,N_ ("branch" ),N_ ("delete merged branches whose upstream matches <branch> (repeatable)" ),PARSE_OPT_NONEG , parse_opt_strvec), +OPT_BOOL (0 ,"dry-run" , &dry_run, +N_ ("with --delete-merged, only print which branches would be deleted" )),OPT__FORCE (&force,N_ ("force creation, move/rename, deletion" ),PARSE_OPT_NOCOMPLETE ),OPT_MERGED (&filter,N_ ("print only branches that are merged" )), OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1040,6 +1051,9 @@ int cmd_branch(int argc,if (noncreate_actions >1 )usage_with_options (builtin_branch_usage, options); +if (dry_run && !delete_merged.nr) +die (_ ("--dry-run requires --delete-merged" )); +if (recurse_submodules_explicit) {if (!submodule_propagate_branches)die (_ ("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled" ));
@@ -1080,7 +1094,8 @@ int cmd_branch(int argc, goto out; }else if (delete_merged.nr) { ret =delete_merged_branches (&delete_merged, argv, - quiet ?DELETE_BRANCH_QUIET :0 ); + (quiet ?DELETE_BRANCH_QUIET :0 ) | + (dry_run ?DELETE_BRANCH_DRY_RUN :0 )); goto out; }else if (show_current) {print_current_branch_name ();
t/t3200-branch.sh +37 -1
@@ -1938,6 +1938,19 @@ test_expect_success '--delete-merged deletes only selected merged branches' ' git checkout -b tracks-other other/main --track && sha=$(git rev-parse --short merged) && + git branch --dry-run --delete-merged origin/next merged >actual 2>&1 && + echo "Would delete branch merged (was $sha)." >expect && + test_cmp expect actual && + git rev-parse --verify refs/heads/merged && + + check_branches <<-\EOF && + also-merged + main + merged + tracks-other + unmerged + EOF + git branch --delete-merged origin/next merged >actual 2>&1 && echo "Deleted branch merged (was $sha)." >expect && test_cmp expect actual &&
@@ -1986,9 +1999,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' ' git checkout -b topic feature --track && git commit --allow-empty -m "topic work" && - git branch --delete-merged origin/next 2>err && + git branch --dry-run --delete-merged origin/next >out && + test_grep ! "feature" out && + git branch --delete-merged origin/next 2>err && test_must_be_empty err && + check_branches <<-\EOF && feature main
@@ -2016,6 +2032,21 @@ test_expect_success '--delete-merged keeps the upstream chain of a surviving bra git checkout -b tip mid --track && git commit --allow-empty -m "tip work" && + git branch --dry-run --delete-merged origin/next \ + --delete-merged lower >actual 2>&1 && + test_must_be_empty actual && + + git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual && + cat >expect <<-\EOF && + branch.lower.remote origin + branch.lower.merge refs/heads/next + branch.mid.remote . + branch.mid.merge refs/heads/lower + branch.tip.remote . + branch.tip.merge refs/heads/mid + EOF + test_cmp expect actual && + git branch --delete-merged origin/next \ --delete-merged lower >actual 2>&1 && test_must_be_empty actual &&
@@ -2112,4 +2143,9 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" ' ) ' +test_expect_success '--dry-run without --delete-merged is rejected' ' + test_must_fail git -C forked branch --dry-run 2>err && + test_grep "requires --delete-merged" err +' + test_done
Signature · 1 line
gitgitgadget
Re: [PATCH v21 0/7] branch: delete-merged
In reply to Harald Nordgren via GitGitGadgetQuoted 5 lines · Harald Nordgren via GitGitGadget
Delete branches that have already been merged on upstream.
Changes in v21:
* Change loop variables to prevent CI timeout.
I see no other changes, which matches expectations at this point.
Is this topic pretty much done by now, or are there any outstanding issues other than this workaround for the performance issue with 'strvec.cocci' in Coccinelle?
Thanks for the prompt update.
v22 cover branch: delete-merged
In reply to Harald Nordgren via GitGitGadgetDelete branches that have already been merged on upstream.
Changes in v22:
* Loop variable int when appropriate to avoid typecast.
Changes in v21:
* Change loop variables to prevent CI timeout.
Changes in v20:
* Protect branches transitively required by a surviving local upstream stack. Traverse upstream chains once and defer delete-set mutation until traversal completes.
* Make stacked-branch handling independent of ref iteration order and update the documentation accordingly.
* Clarify variable names with regards to branch names (short) to reduce confusion.
Changes in v19:
* Fix bug where dry-run would still remove config, added test coverage.
* Redesigned --delete-merged as a repeatable upstream selector with optional positional patterns limiting deletion scope.
* Protect same-name upstream branches independently of push-default configuration.
* Simplified flags handling where local caching became complicated when mutating values.
* Clarified assertions in tests.
Changes in v18:
* Instead of keeping the whole chain of upstream branches, keep only the ones an unmerged branch still needs. When a kept (merged) branch in turn tracks a branch that is being deleted, clear its now-stale upstream config.
* Rework spare_stacked_bases() to record the kept bases and, in a second pass, clear the upstream of any whose own base is going away. Build the to-delete list with strset_for_each_entry() instead of re-walking the candidate array.
Changes in v17:
* Keep a merged branch when another surviving branch still tracks it as its upstream, so --delete-merged no longer deletes a branch out from under one stacked on top of it.
* Move the --dry-run and branch.<name>.deleteMerged opt-out fully into their own commits.
Changes in v16:
* Convert delete_merged_branches() to take an unsigned int flags argument instead of separate quiet/dry_run booleans, matching delete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_reset per iteration, single strbuf_release after) instead of allocating and freeing it each time
* Rewrite the --delete-merged tests as integration tests: branches that land commits upstream, with deletion and the checked-out, upstream-gone, and push-equals-upstream safety cases exercised together in one run and output asserted via test_cmp
* Collapse the many per-aspect test repos into a single reused repo set up by a setup_repo_for_delete_merged helper, and rename helpers off the old pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of prefixing every command with -C
Changes in v15:
* Renamed --prune-merged to --delete-merged throughout. Not necessarily final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of warning.
* Initialized the delete_branches() flag locals where declared. Only force stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f, --forked coverage), renamed the misleading trunk fixture, and replaced the misnamed detached branch with git checkout --detach.
Changes in v14:
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the remotes path set a local force but not the DELETE_BRANCH_FORCE bit that check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other branches too, hopefully not related
(https://github.com/git/git/pull/2285).
Changes in v13:
* Reworked --forked into a real ref-filter applied in apply_ref_filter() instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the abbreviated upstream), and dropped the old helper machinery, forward declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git branch --prune-merged origin/main 'feature*') instead of repeating the option.
Changes in v12:
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST options.
* Dropped the bare-remote-name resolution for --forked, the argument is now a ref or a glob.
Changes in v11:
* 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 /HEAD by name guard that the reviewer rejected is gone.
* New --dry-run for --prune-merged.
Changes in v10:
* --forked / --prune-merged now take a branch glob instead of a remote name — origin, origin/*, origin/release-- all work. This replaces the remote-only form and subsumes the old --all-remotes flag, which has been dropped.
* New --dry-run for --prune-merged.
Changes in v9:
* --force no longer has special meaning with --prune-merged; reachability is always enforced. Use git branch -D to delete an unmerged branch. Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.
Changes in v8:
* Delete only when the branch's work is actually reachable from its upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place
Changes in v7:
* --prune-merged now checks if a branch is merged into its own upstream first. If the upstream is gone, it checks against the remote's default branch instead. If neither exists, the branch is refused (use --force to delete anyway).
Changes in v6:
* --prune-merged now measures merged-ness against the remote's default branch instead of the candidate's upstream — so the decision no longer depends on which branch happens to be checked out locally.
* delete_branches() / check_branch_commit() gained a per-candidate override that lets a caller substitute a different "what counts as merged" reference (or skip the check). branch -d callers pass NULL and keep their existing semantics.
* prune_merged_branches() resolves each candidate's push-remote HEAD and threads it through, so --prune-merged --all-remotes measures each candidate against its own remote rather than a single global reference.
Changes in v5:
* Drop commit 'fetch: add --prune-merged'
Changes in v4:
* Resolve each remote's HEAD and collect the targets into a protected_default_refs set in collect_forked_set.
* In prune_merged_branches, skip a candidate when its upstream is a protected default ref and the local branch name matches the default branch's leaf name (so a local main tracking origin/main is spared, but a renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name (not by upstream alone); spare a branch whose push ref is the remote default.
Changes in v3:
* s/remote-tracking refs/remote-tracking branches/g
Changes in v2:
* The whole feature moved out of git fetch and into git branch. git fetch --prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their tip is reachable from the upstream tracking ref; --force skips that safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running topic branch) even with --force; doesn't affect explicit git branch -d.
* delete_branches() got a warn_only mode so bulk deletion prints a one-line warning per skipped branch instead of the noisy four-line hint that git branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention --prune-merged.
* New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh shrunk since most logic moved.
Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <branch>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged
Documentation/config/branch.adoc | 7 + Documentation/git-branch.adoc | 49 +++- builtin/branch.c | 275 ++++++++++++++++++--- ref-filter.c | 70 ++++++ ref-filter.h | 10 + t/t3200-branch.sh | 393 +++++++++++++++++++++++++++++++ 6 files changed, 773 insertions(+), 31 deletions(-)
base-commit: 9a0c4701dcd5725c4184599322b52933ff5005ca
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v22
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v22
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v21:
1: 90aa528bef = 1: 90aa528bef branch: add --forked filter for --list mode
2: 048c002cb4 = 2: 048c002cb4 branch: convert delete_branches() to a flags argument
3: bb93303df3 = 3: bb93303df3 branch: let delete_branches skip unmerged branches on bulk refusal
4: e70e64c866 = 4: e70e64c866 branch: prepare delete_branches for a bulk caller
5: 598e68751e ! 5: 7dbfc6e243 branch: add --delete-merged <branch>
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ filter.name_patterns = argv;
+ filter_refs(&candidates, &filter, filter.kind);
+
-+ for (size_t i = 0; i < (size_t)candidates.nr; i++) {
++ for (int i = 0; i < candidates.nr; i++) {
+ const char *branch_refname = candidates.items[i]->refname;
+ const char *branch_name;
+ struct branch *branch;
6: ac63689d8a = 6: 6f27770b9f branch: add branch.<name>.deleteMerged opt-out
7: 73c3281b6f = 7: 4a42b6f5f9 branch: add --dry-run for --delete-mergedSignature · 1 line
gitgitgadget
PATCH v22 1/7 branch: add --forked filter for --list mode
From: Harald Nordgren <[email protected]>
Add a --forked option to "git branch" list mode that lists only branches whose configured upstream matches <branch>. The argument can be a ref (e.g. "origin/main", "master"), a remote name like "origin" for the branch its origin/HEAD points at, or a shell 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 --delete-merged, which deletes the listed branches once they have landed on their upstream.
--- Documentation/git-branch.adoc | 12 +++- builtin/branch.c | 18 +++++- ref-filter.c | 70 ++++++++++++++++++++ ref-filter.h | 10 +++ t/t3200-branch.sh | 117 ++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 3 deletions(-)
Documentation/git-branch.adoc +11 -1
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current] [--column[=<options>] | --no-column] [--sort=<key>] [--merged [<commit>]] [--no-merged [<commit>]] [--contains [<commit>]] [--no-contains [<commit>]] + [(--forked <branch>)...] [--points-at <object>] [--format=<format>] [(-r|--remotes) | (-a|--all)] [--list] [<pattern>...]
@@ -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 _<commit>_ argument is missing it defaults to `HEAD` (i.e. the tip of the current -branch). +branch). With `--forked`, only branches whose configured upstream matches +the given branch or pattern will be listed. The command's second form creates a new branch head named _<branch-name>_ which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main". Only list branches whose tips are not reachable from _<commit>_ (`HEAD` if not specified). Implies `--list`. +`--forked <branch>`:: + Only list branches whose configured upstream matches + _<branch>_. The argument can be a ref (e.g. `origin/main`, + `master`), a remote name like `origin` for the branch its + `origin/HEAD` points at, or a shell-style glob (e.g. + `'origin/*'`). The option can be repeated to widen the + filter. Implies `--list`. + `--points-at <object>`:: Only list branches of _<object>_.
builtin/branch.c +16 -2
@@ -30,7 +30,7 @@#include "commit-reach.h" static const char *const builtin_branch_usage[] = { -N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged]" ), +N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]" ),N_ ("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]" ),N_ ("git branch [<options>] [-l] [<pattern>...]" ),N_ ("git branch [<options>] [-r] (-d | -D) <branch-name>..." ),
@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, intfree_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)
@@ -793,6 +803,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 <branch> (repeatable)" ), +PARSE_OPT_NONEG , parse_opt_forked),OPT_COLUMN (0 ,"column" , &colopts,N_ ("list branches in columns" )),OPT_REF_SORT (&sorting_options), OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -838,7 +851,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 +
ref-filter.c +70 -0
88 lines not on this page · raw message
ref-filter.h +10 -0
@@ -67,6 +67,7 @@ struct ref_filter {const char **name_patterns;const char *start_after;struct strvec exclude; +struct strvec forked;struct oid_array points_at;struct commit_list *with_commit;struct commit_list *no_commit;
@@ -110,6 +111,7 @@ struct ref_format {#define REF_FILTER_INIT { \ .points_at = OID_ARRAY_INIT, \ .exclude = STRVEC_INIT, \ + .forked = STRVEC_INIT, \ }#define REF_FORMAT_INIT { \ .use_color = GIT_COLOR_UNKNOWN, \
@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);struct ref_sorting *ref_sorting_options (struct string_list *);/* Function to parse --merged and --no-merged options */ int parse_opt_merge_filter (const struct option *opt,const char *arg,int unset); +/* +* Register a --forked <branch> pattern on the filter. The argument is +* either a ref, which is resolved to its full refname, or a shell-style +* glob. Branches are kept only when their configured upstream matches +* one of the registered patterns. Returns -1 if the argument is not a +* valid ref or pattern. +*/ +int ref_filter_forked_add (struct ref_filter *filter,const char *arg);/* Get the current HEAD's description */ char *get_head_description (void );/* Set up translated strings in the output. */
t/t3200-branch.sh +117 -0
121 lines not on this page · raw message
Signature · 1 line
gitgitgadget
Re: [PATCH v22 1/7] branch: add --forked filter for --list mode
Quoted 5 lines · Harald Nordgren via GitGitGadget
ref-filter.c +0
-0
ref-filter.c static int filter_exclude_match(struct ref_filter *filter, const char *refname) · line 2753
Quoted 10 lines
ref-filter.c +7
-0@@ -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; +}
It is a bit tricky to read the above, which is equivalent to this
if (!skip_prefix(short_name, "refs/heads/", &short_name)) skip_prefix(short_name, "refs/remotes/", &short_name); return short_name;
which is written in a more dumb way.
Unless we are going to add more "if the string does not begin with this prefix, try this other one" to the cascade, the dumb "try stripping local branch prefix, if not, try the remote-tracking branch prefix" way might be easier to understand.
Quoted 14 lines
ref-filter.c +14
-0+/* +* 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; +
ref-filter.c static int filter_exclude_match(struct ref_filter *filter, const char *refname) · line 2778
Quoted 10 lines
ref-filter.c +10
-0+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++) {
The 'filter->forked' member is added by this patch and of type 'struct strvec', filter->forked.nr is of type 'size_t'. And 'i', which is of type 'int', is compared with it.
Quoted 12 lines
ref-filter.h +1
-0@@ -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;
PATCH v22 2/7 branch: convert delete_branches() to a flags argument
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
delete_branches() takes separate force and quiet parameters, while check_branch_commit() takes force. The next commits would grow this collection further. Replace them with a single unsigned flags argument and an enum.
Test the FORCE and QUIET bits directly from flags at each use site so that mutating or forwarding flags cannot leave cached values stale.
No change in behavior.
--- builtin/branch.c | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-)
builtin/branch.c +24 -16
@@ -189,16 +189,22 @@ 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 ), +}; +static int check_branch_commit (const char *branchname,const char *refname,const struct object_id *oid,struct commit *head_rev, -int kinds,int force) +int kinds,unsigned int flags) {struct commit *rev =lookup_commit_reference (the_repository, oid); -if (!force && !rev) { +if (!(flags &DELETE_BRANCH_FORCE ) && !rev) {error (_ ("couldn't look up commit object for '%s'" ), refname);return -1 ; } -if (!force && !branch_merged (kinds, branchname, rev, head_rev)) { +if (!(flags &DELETE_BRANCH_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, "
@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)strbuf_release (&buf); } -static int delete_branches (int argc,const char **argv,int force,int kinds, -int quiet) +static int delete_branches (int argc,const char **argv,int kinds, +unsigned int flags) {struct commit *head_rev =NULL ;struct object_id oid;
@@ -241,7 +247,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 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, } branch_name_pos =strcspn (fmt,"%" ); -if (!force) +if (!(flags &DELETE_BRANCH_FORCE )) 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 +285,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 +297,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 +312,16 @@ 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)) { + flags)) { 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:
@@ -330,7 +336,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,char *name = item->string;if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos; -if (!quiet) +if (!(flags &DELETE_BRANCH_QUIET )) printf(remote_branch ? _("Deleted remote-tracking branch %s (was %s).\n") : _("Deleted branch %s (was %s).\n"),
@@ -895,7 +901,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 ();
Signature · 1 line
gitgitgadget
PATCH v22 3/7 branch: let delete_branches skip unmerged branches on bulk refusal
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Add a skip-unmerged mode to delete_branches() and check_branch_commit() so a bulk caller can silently skip branches that are not fully merged and carry on, rather than erroring with the "use 'git branch -D'" advice that the plain "git branch -d" path emits. Existing callers are unaffected.
--- builtin/branch.c | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-)
builtin/branch.c +10 -5
@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ), +DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname, }if (!(flags &DELETE_BRANCH_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_SKIP_UNMERGED )) { +error (_ ("the branch '%s' is not fully merged" ), + branchname); +advise_if_enabled (ADVICE_FORCE_DELETE_BRANCH , +_ ("If you are sure you want to delete it, " +"run 'git branch -D %s'" ), branchname); + }return -1 ; }return 0 ;
@@ -315,7 +319,8 @@ static int delete_branches(int argc, const char **argv, int kinds,if (!(ref_flags & (REF_ISSYMREF |REF_ISBROKEN )) &&check_branch_commit (bname.buf, name, &oid, head_rev, kinds, flags)) { - ret =1 ; +if (!(flags &DELETE_BRANCH_SKIP_UNMERGED )) + ret =1 ; goto next; }
Signature · 1 line
gitgitgadget
PATCH v22 4/7 branch: prepare delete_branches for a bulk caller
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Teach delete_branches() a new mode for the upcoming --delete-merged caller that checks whether a branch is merged into its upstream without falling back to HEAD when there is no upstream. Existing callers keep their current behavior.
--- builtin/branch.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-)
builtin/branch.c +9 -4
@@ -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,7 @@ enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), + DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds, } branch_name_pos =strcspn (fmt,"%" ); -if (!(flags &DELETE_BRANCH_FORCE )) +if (!(flags &DELETE_BRANCH_FORCE ) && + !(flags &DELETE_BRANCH_NO_HEAD_FALLBACK )) head_rev =lookup_commit_reference (the_repository, &head_oid);for (i =0 ; i < argc; i++,strbuf_reset (&bname)) {
Signature · 1 line
gitgitgadget
PATCH v22 5/7 branch: add --delete-merged <branch>
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
git branch (--delete-merged <branch>)... [<pattern>...]
deletes local branches matching the optional patterns when their configured upstream matches one of the --delete-merged arguments and their tip is reachable from that upstream. The work has already landed on the upstream they track, so the local copy is no longer needed.
The option can be repeated to widen the upstream match. Keeping the candidate patterns as positional arguments lets users bound the set of local branches that may be deleted independently of the upstream selection.
A branch is not deleted when:
* it is checked out in any worktree
* its configured upstream ref no longer exists, since a missing upstream is not by itself a sign of integration
* pushing it by name to the remote configured by branch.<name>.remote would update its upstream, as determined by mapping the branch ref through that remote's fetch refspec. For example, a local "main" that tracks "origin/main" is kept even when remote.pushDefault names a fork. Right after a pull it merely looks fully merged.
A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep.
A branch that a surviving branch depends on through a chain of local upstreams is also kept, so no branch is deleted out from under stacked work. Collect this transitive set without changing the candidate set during ref iteration: walk upstream chains from surviving branches, visit each branch at most once, and remove the collected bases only after the iteration completes. This makes the result independent of ref iteration order without repeated full scans.
--- Documentation/git-branch.adoc | 30 +++++ builtin/branch.c | 158 +++++++++++++++++++++++++- t/t3200-branch.sh | 204 ++++++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 2 deletions(-)
Documentation/git-branch.adoc +30 -0
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] +git branch (--delete-merged <branch>)... [<pattern>...] DESCRIPTION -----------
@@ -201,6 +202,35 @@ This option is only applicable in non-verbose mode. Print the name of the current branch. In detached `HEAD` state, nothing is printed. +`--delete-merged <branch>`:: + Delete local branches whose configured upstream matches + _<branch>_, but only when their tip is reachable from that + upstream. In other words, the work on the branch has already + landed on the upstream it tracks, so the local copy is no longer + needed. The option can be repeated to widen the upstream match. + Optional _<pattern>_ arguments limit which local branches are + considered, e.g. `git branch --delete-merged 'origin/*' + 'topic-*'`. ++ +A branch is not deleted when: ++ +-- +* its configured upstream ref no longer exists, +* it is checked out in any worktree, or +* pushing it by name to the remote configured by + `branch.<name>.remote` would update its upstream, so it cannot be + distinguished from a branch that just looks "fully merged" right + after a pull. +-- ++ +A branch whose work has not yet been merged into its upstream is +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. ++ +A branch that a surviving branch depends on through a chain of local +upstreams is kept, so a branch is never deleted out from under stacked +work. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +156 -2
212 lines not on this page · raw message
t/t3200-branch.sh +204 -0
208 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v22 6/7 branch: add branch.<name>.deleteMerged opt-out
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Setting branch.<name>.deleteMerged=false exempts that branch from "git branch --delete-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.
--- Documentation/config/branch.adoc | 7 +++++++ Documentation/git-branch.adoc | 5 +++-- builtin/branch.c | 14 +++++++++++++ t/t3200-branch.sh | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-)
Documentation/config/branch.adoc +7 -0
@@ -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.<name>.deleteMerged`:: + If set to `false`, branch _<name>_ is exempt from + `git branch --delete-merged`. Useful for a topic branch you + intend to develop further after an initial round has been + merged upstream. Defaults to true. Explicit deletion via + `git branch -d` is unaffected.
Documentation/git-branch.adoc +3 -2
@@ -216,11 +216,12 @@ A branch is not deleted when: + -- * its configured upstream ref no longer exists, -* it is checked out in any worktree, or +* it is checked out in any worktree, * pushing it by name to the remote configured by `branch.<name>.remote` would update its upstream, so it cannot be distinguished from a branch that just looks "fully merged" right - after a pull. + after a pull, or +* `branch.<name>.deleteMerged` is set to `false`. -- + A branch whose work has not yet been merged into its upstream is
builtin/branch.c +14 -0
@@ -786,6 +786,7 @@ static int delete_merged_branches(const struct strvec *upstreams,struct ref_array candidates = {0 };struct strset deletable_branch_names =STRSET_INIT ;struct strvec branches_to_delete =STRVEC_INIT ; +struct strbuf key =STRBUF_INIT ;struct hashmap_iter iter;struct strmap_entry *entry;int ret =0 ;
@@ -804,6 +805,7 @@ static int delete_merged_branches(const struct strvec *upstreams,const char *branch_name;struct branch *branch;const char *upstream_refname; +int opt_out;if (!skip_prefix (branch_refname,"refs/heads/" , &branch_name))BUG ("filter returned non-branch ref '%s'" , branch_refname);
@@ -821,6 +823,17 @@ static int delete_merged_branches(const struct strvec *upstreams, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; +strbuf_reset (&key); +strbuf_addf (&key,"branch.%s.deletemerged" , branch_name); +if (!repo_config_get_bool (the_repository, key.buf, &opt_out) && + !opt_out) { +if (!(flags &DELETE_BRANCH_QUIET )) +fprintf (stderr, +_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), + branch_name, branch_name); +continue ; + } +strset_add (&deletable_branch_names, branch_name); }
@@ -836,6 +849,7 @@ static int delete_merged_branches(const struct strvec *upstreams,DELETE_BRANCH_NO_HEAD_FALLBACK | flags); +strbuf_release (&key);strvec_clear (&branches_to_delete);strset_clear (&deletable_branch_names);ref_array_clear (&candidates);
t/t3200-branch.sh +36 -0
@@ -2076,4 +2076,40 @@ test_expect_success '--delete-merged requires a value' ' test_must_fail git -C forked branch --delete-merged 2>err && test_grep "requires a value" err ' + +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' ' + setup_repo_for_delete_merged && + create_merged_branch deleted && + create_merged_branch kept && + ( + cd repo && + git config branch.kept.deleteMerged false && + git checkout --detach && + + git branch --delete-merged origin/next 2>err && + + test_grep "Skipping .kept." err && + check_branches <<-\EOF + kept + main + EOF + ) +' + +test_expect_success "branch -d still deletes a deleteMerged=false branch" ' + setup_repo_for_delete_merged && + create_merged_branch kept && + ( + cd repo && + git config branch.kept.deleteMerged false && + git checkout --detach && + + git branch -d kept && + + check_branches <<-\EOF + main + EOF + ) +' + test_done
Signature · 1 line
gitgitgadget
PATCH v22 7/7 branch: add --dry-run for --delete-merged
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
"git branch --dry-run --delete-merged ..." prints one line per ref that would be deleted without modifying refs or branch configuration.
--dry-run is only meaningful together with --delete-merged and is rejected otherwise.
--- Documentation/git-branch.adoc | 8 +++++++- builtin/branch.c | 21 ++++++++++++++++--- t/t3200-branch.sh | 38 ++++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 5 deletions(-)
Documentation/git-branch.adoc +7 -1
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] -git branch (--delete-merged <branch>)... [<pattern>...] +git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...] DESCRIPTION -----------
@@ -232,6 +232,12 @@ A branch that a surviving branch depends on through a chain of local upstreams is kept, so a branch is never deleted out from under stacked work. +`--dry-run`:: + With `--delete-merged`, print which branches would be + deleted and exit without touching any ref. Useful for + sanity-checking a wide pattern like `'origin/*'` before + committing to the deletion. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +18 -3
@@ -199,6 +199,7 @@ enum delete_branch_flags {DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ),DELETE_BRANCH_NO_HEAD_FALLBACK = (1 <<3 ), + DELETE_BRANCH_DRY_RUN = (1 << 4), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -340,13 +341,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 (!(flags &DELETE_BRANCH_DRY_RUN ) && +refs_delete_refs (get_main_ref_store (the_repository),NULL , &refs_to_delete,REF_NO_DEREF )) ret =1 ;for_each_string_list_item (item, &refs_to_delete) {char *describe_ref = item->util;char *name = item->string; -if (!refs_ref_exists (get_main_ref_store (the_repository), name)) { +if (flags &DELETE_BRANCH_DRY_RUN ) { +if (!(flags &DELETE_BRANCH_QUIET )) +printf (remote_branch + ?_ ("Would delete remote-tracking branch %s (was %s).\n" ) + :_ ("Would delete branch %s (was %s).\n" ), + name + branch_name_pos, describe_ref); + }else if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos;if (!(flags &DELETE_BRANCH_QUIET )) printf(remote_branch
@@ -922,6 +930,7 @@ int cmd_branch(int argc,int delete =0 , rename =0 , copy =0 , list =0 , unset_upstream =0 , show_current =0 , edit_description =0 ;struct strvec delete_merged =STRVEC_INIT ; +int dry_run =0 ;const char *new_upstream =NULL ;int noncreate_actions =0 ;/* possible options */
@@ -978,6 +987,8 @@ int cmd_branch(int argc,OPT_CALLBACK_F (0 ,"delete-merged" , &delete_merged,N_ ("branch" ),N_ ("delete merged branches whose upstream matches <branch> (repeatable)" ),PARSE_OPT_NONEG , parse_opt_strvec), +OPT_BOOL (0 ,"dry-run" , &dry_run, +N_ ("with --delete-merged, only print which branches would be deleted" )),OPT__FORCE (&force,N_ ("force creation, move/rename, deletion" ),PARSE_OPT_NOCOMPLETE ),OPT_MERGED (&filter,N_ ("print only branches that are merged" )), OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1040,6 +1051,9 @@ int cmd_branch(int argc,if (noncreate_actions >1 )usage_with_options (builtin_branch_usage, options); +if (dry_run && !delete_merged.nr) +die (_ ("--dry-run requires --delete-merged" )); +if (recurse_submodules_explicit) {if (!submodule_propagate_branches)die (_ ("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled" ));
@@ -1080,7 +1094,8 @@ int cmd_branch(int argc, goto out; }else if (delete_merged.nr) { ret =delete_merged_branches (&delete_merged, argv, - quiet ?DELETE_BRANCH_QUIET :0 ); + (quiet ?DELETE_BRANCH_QUIET :0 ) | + (dry_run ?DELETE_BRANCH_DRY_RUN :0 )); goto out; }else if (show_current) {print_current_branch_name ();
t/t3200-branch.sh +37 -1
@@ -1938,6 +1938,19 @@ test_expect_success '--delete-merged deletes only selected merged branches' ' git checkout -b tracks-other other/main --track && sha=$(git rev-parse --short merged) && + git branch --dry-run --delete-merged origin/next merged >actual 2>&1 && + echo "Would delete branch merged (was $sha)." >expect && + test_cmp expect actual && + git rev-parse --verify refs/heads/merged && + + check_branches <<-\EOF && + also-merged + main + merged + tracks-other + unmerged + EOF + git branch --delete-merged origin/next merged >actual 2>&1 && echo "Deleted branch merged (was $sha)." >expect && test_cmp expect actual &&
@@ -1986,9 +1999,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' ' git checkout -b topic feature --track && git commit --allow-empty -m "topic work" && - git branch --delete-merged origin/next 2>err && + git branch --dry-run --delete-merged origin/next >out && + test_grep ! "feature" out && + git branch --delete-merged origin/next 2>err && test_must_be_empty err && + check_branches <<-\EOF && feature main
@@ -2016,6 +2032,21 @@ test_expect_success '--delete-merged keeps the upstream chain of a surviving bra git checkout -b tip mid --track && git commit --allow-empty -m "tip work" && + git branch --dry-run --delete-merged origin/next \ + --delete-merged lower >actual 2>&1 && + test_must_be_empty actual && + + git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual && + cat >expect <<-\EOF && + branch.lower.remote origin + branch.lower.merge refs/heads/next + branch.mid.remote . + branch.mid.merge refs/heads/lower + branch.tip.remote . + branch.tip.merge refs/heads/mid + EOF + test_cmp expect actual && + git branch --delete-merged origin/next \ --delete-merged lower >actual 2>&1 && test_must_be_empty actual &&
@@ -2112,4 +2143,9 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" ' ) ' +test_expect_success '--dry-run without --delete-merged is rejected' ' + test_must_fail git -C forked branch --dry-run 2>err && + test_grep "requires --delete-merged" err +' + test_done
Signature · 1 line
gitgitgadget
v23 cover branch: delete-merged
In reply to Harald Nordgren via GitGitGadgetDelete branches that have already been merged on upstream.
Changes in v23:
* Use a loop-local size_t iterator for --forked patterns.
* Simplify upstream-prefix stripping into an explicit fallback.
Changes in v22:
* Loop variable int when appropriate to avoid typecast.
Changes in v21:
* Change loop variables to prevent CI timeout.
Changes in v20:
* Protect branches transitively required by a surviving local upstream stack. Traverse upstream chains once and defer delete-set mutation until traversal completes.
* Make stacked-branch handling independent of ref iteration order and update the documentation accordingly.
* Clarify variable names with regards to branch names (short) to reduce confusion.
Changes in v19:
* Fix bug where dry-run would still remove config, added test coverage.
* Redesigned --delete-merged as a repeatable upstream selector with optional positional patterns limiting deletion scope.
* Protect same-name upstream branches independently of push-default configuration.
* Simplified flags handling where local caching became complicated when mutating values.
* Clarified assertions in tests.
Changes in v18:
* Instead of keeping the whole chain of upstream branches, keep only the ones an unmerged branch still needs. When a kept (merged) branch in turn tracks a branch that is being deleted, clear its now-stale upstream config.
* Rework spare_stacked_bases() to record the kept bases and, in a second pass, clear the upstream of any whose own base is going away. Build the to-delete list with strset_for_each_entry() instead of re-walking the candidate array.
Changes in v17:
* Keep a merged branch when another surviving branch still tracks it as its upstream, so --delete-merged no longer deletes a branch out from under one stacked on top of it.
* Move the --dry-run and branch.<name>.deleteMerged opt-out fully into their own commits.
Changes in v16:
* Convert delete_merged_branches() to take an unsigned int flags argument instead of separate quiet/dry_run booleans, matching delete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_reset per iteration, single strbuf_release after) instead of allocating and freeing it each time
* Rewrite the --delete-merged tests as integration tests: branches that land commits upstream, with deletion and the checked-out, upstream-gone, and push-equals-upstream safety cases exercised together in one run and output asserted via test_cmp
* Collapse the many per-aspect test repos into a single reused repo set up by a setup_repo_for_delete_merged helper, and rename helpers off the old pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of prefixing every command with -C
Changes in v15:
* Renamed --prune-merged to --delete-merged throughout. Not necessarily final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of warning.
* Initialized the delete_branches() flag locals where declared. Only force stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f, --forked coverage), renamed the misleading trunk fixture, and replaced the misnamed detached branch with git checkout --detach.
Changes in v14:
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the remotes path set a local force but not the DELETE_BRANCH_FORCE bit that check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other branches too, hopefully not related
(https://github.com/git/git/pull/2285).
Changes in v13:
* Reworked --forked into a real ref-filter applied in apply_ref_filter() instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the abbreviated upstream), and dropped the old helper machinery, forward declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git branch --prune-merged origin/main 'feature*') instead of repeating the option.
Changes in v12:
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST options.
* Dropped the bare-remote-name resolution for --forked, the argument is now a ref or a glob.
Changes in v11:
* 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 /HEAD by name guard that the reviewer rejected is gone.
* New --dry-run for --prune-merged.
Changes in v10:
* --forked / --prune-merged now take a branch glob instead of a remote name — origin, origin/*, origin/release-- all work. This replaces the remote-only form and subsumes the old --all-remotes flag, which has been dropped.
* New --dry-run for --prune-merged.
Changes in v9:
* --force no longer has special meaning with --prune-merged; reachability is always enforced. Use git branch -D to delete an unmerged branch. Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.
Changes in v8:
* Delete only when the branch's work is actually reachable from its upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place
Changes in v7:
* --prune-merged now checks if a branch is merged into its own upstream first. If the upstream is gone, it checks against the remote's default branch instead. If neither exists, the branch is refused (use --force to delete anyway).
Changes in v6:
* --prune-merged now measures merged-ness against the remote's default branch instead of the candidate's upstream — so the decision no longer depends on which branch happens to be checked out locally.
* delete_branches() / check_branch_commit() gained a per-candidate override that lets a caller substitute a different "what counts as merged" reference (or skip the check). branch -d callers pass NULL and keep their existing semantics.
* prune_merged_branches() resolves each candidate's push-remote HEAD and threads it through, so --prune-merged --all-remotes measures each candidate against its own remote rather than a single global reference.
Changes in v5:
* Drop commit 'fetch: add --prune-merged'
Changes in v4:
* Resolve each remote's HEAD and collect the targets into a protected_default_refs set in collect_forked_set.
* In prune_merged_branches, skip a candidate when its upstream is a protected default ref and the local branch name matches the default branch's leaf name (so a local main tracking origin/main is spared, but a renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name (not by upstream alone); spare a branch whose push ref is the remote default.
Changes in v3:
* s/remote-tracking refs/remote-tracking branches/g
Changes in v2:
* The whole feature moved out of git fetch and into git branch. git fetch --prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their tip is reachable from the upstream tracking ref; --force skips that safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running topic branch) even with --force; doesn't affect explicit git branch -d.
* delete_branches() got a warn_only mode so bulk deletion prints a one-line warning per skipped branch instead of the noisy four-line hint that git branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention --prune-merged.
* New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh shrunk since most logic moved.
Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <branch>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged
Documentation/config/branch.adoc | 7 + Documentation/git-branch.adoc | 49 +++- builtin/branch.c | 275 ++++++++++++++++++--- ref-filter.c | 70 ++++++ ref-filter.h | 10 + t/t3200-branch.sh | 393 +++++++++++++++++++++++++++++++ 6 files changed, 773 insertions(+), 31 deletions(-)
base-commit: 9a0c4701dcd5725c4184599322b52933ff5005ca
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v23
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v23
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v22:
1: 90aa528bef ! 1: 0c4fe549cb branch: add --forked filter for --list mode
@@ ref-filter.c: static int filter_exclude_match(struct ref_filter *filter, const c
+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));
++
++ if (!skip_prefix(short_name, "refs/heads/", &short_name))
++ skip_prefix(short_name, "refs/remotes/", &short_name);
+ return short_name;
+}
+
@@ ref-filter.c: static int filter_exclude_match(struct ref_filter *filter, const c
+ const char *short_name;
+ struct branch *branch;
+ const char *upstream;
-+ int i;
+
+ if (!skip_prefix(refname, "refs/heads/", &short_name))
+ return 0;
@@ ref-filter.c: static int filter_exclude_match(struct ref_filter *filter, const c
+ if (!upstream)
+ return 0;
+
-+ for (i = 0; i < filter->forked.nr; i++) {
++ for (size_t i = 0; i < filter->forked.nr; i++) {
+ const char *pattern = filter->forked.v[i];
+ if (has_glob_specials(pattern)) {
+ if (!wildmatch(pattern, short_upstream_name(upstream),
2: 048c002cb4 = 2: 2d20015ba9 branch: convert delete_branches() to a flags argument
3: bb93303df3 = 3: 34b37aeb43 branch: let delete_branches skip unmerged branches on bulk refusal
4: e70e64c866 = 4: 606ce4082c branch: prepare delete_branches for a bulk caller
5: 7dbfc6e243 = 5: 5fd74f0050 branch: add --delete-merged <branch>
6: 6f27770b9f = 6: 6494be7c75 branch: add branch.<name>.deleteMerged opt-out
7: 4a42b6f5f9 = 7: 7102c931e2 branch: add --dry-run for --delete-mergedSignature · 1 line
gitgitgadget
PATCH v23 1/7 branch: add --forked filter for --list mode
From: Harald Nordgren <[email protected]>
Add a --forked option to "git branch" list mode that lists only branches whose configured upstream matches <branch>. The argument can be a ref (e.g. "origin/main", "master"), a remote name like "origin" for the branch its origin/HEAD points at, or a shell 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 --delete-merged, which deletes the listed branches once they have landed on their upstream.
--- Documentation/git-branch.adoc | 12 +++- builtin/branch.c | 18 +++++- ref-filter.c | 70 ++++++++++++++++++++ ref-filter.h | 10 +++ t/t3200-branch.sh | 117 ++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 3 deletions(-)
Documentation/git-branch.adoc +11 -1
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current] [--column[=<options>] | --no-column] [--sort=<key>] [--merged [<commit>]] [--no-merged [<commit>]] [--contains [<commit>]] [--no-contains [<commit>]] + [(--forked <branch>)...] [--points-at <object>] [--format=<format>] [(-r|--remotes) | (-a|--all)] [--list] [<pattern>...]
@@ -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 _<commit>_ argument is missing it defaults to `HEAD` (i.e. the tip of the current -branch). +branch). With `--forked`, only branches whose configured upstream matches +the given branch or pattern will be listed. The command's second form creates a new branch head named _<branch-name>_ which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main". Only list branches whose tips are not reachable from _<commit>_ (`HEAD` if not specified). Implies `--list`. +`--forked <branch>`:: + Only list branches whose configured upstream matches + _<branch>_. The argument can be a ref (e.g. `origin/main`, + `master`), a remote name like `origin` for the branch its + `origin/HEAD` points at, or a shell-style glob (e.g. + `'origin/*'`). The option can be repeated to widen the + filter. Implies `--list`. + `--points-at <object>`:: Only list branches of _<object>_.
builtin/branch.c +16 -2
@@ -30,7 +30,7 @@#include "commit-reach.h" static const char *const builtin_branch_usage[] = { -N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged]" ), +N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]" ),N_ ("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]" ),N_ ("git branch [<options>] [-l] [<pattern>...]" ),N_ ("git branch [<options>] [-r] (-d | -D) <branch-name>..." ),
@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, intfree_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)
@@ -793,6 +803,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 <branch> (repeatable)" ), +PARSE_OPT_NONEG , parse_opt_forked),OPT_COLUMN (0 ,"column" , &colopts,N_ ("list branches in columns" )),OPT_REF_SORT (&sorting_options), OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -838,7 +851,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 +
ref-filter.c +70 -0
88 lines not on this page · raw message
ref-filter.h +10 -0
@@ -67,6 +67,7 @@ struct ref_filter {const char **name_patterns;const char *start_after;struct strvec exclude; +struct strvec forked;struct oid_array points_at;struct commit_list *with_commit;struct commit_list *no_commit;
@@ -110,6 +111,7 @@ struct ref_format {#define REF_FILTER_INIT { \ .points_at = OID_ARRAY_INIT, \ .exclude = STRVEC_INIT, \ + .forked = STRVEC_INIT, \ }#define REF_FORMAT_INIT { \ .use_color = GIT_COLOR_UNKNOWN, \
@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);struct ref_sorting *ref_sorting_options (struct string_list *);/* Function to parse --merged and --no-merged options */ int parse_opt_merge_filter (const struct option *opt,const char *arg,int unset); +/* +* Register a --forked <branch> pattern on the filter. The argument is +* either a ref, which is resolved to its full refname, or a shell-style +* glob. Branches are kept only when their configured upstream matches +* one of the registered patterns. Returns -1 if the argument is not a +* valid ref or pattern. +*/ +int ref_filter_forked_add (struct ref_filter *filter,const char *arg);/* Get the current HEAD's description */ char *get_head_description (void );/* Set up translated strings in the output. */
t/t3200-branch.sh +117 -0
121 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v23 2/7 branch: convert delete_branches() to a flags argument
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
delete_branches() takes separate force and quiet parameters, while check_branch_commit() takes force. The next commits would grow this collection further. Replace them with a single unsigned flags argument and an enum.
Test the FORCE and QUIET bits directly from flags at each use site so that mutating or forwarding flags cannot leave cached values stale.
No change in behavior.
--- builtin/branch.c | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-)
builtin/branch.c +24 -16
@@ -189,16 +189,22 @@ 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 ), +}; +static int check_branch_commit (const char *branchname,const char *refname,const struct object_id *oid,struct commit *head_rev, -int kinds,int force) +int kinds,unsigned int flags) {struct commit *rev =lookup_commit_reference (the_repository, oid); -if (!force && !rev) { +if (!(flags &DELETE_BRANCH_FORCE ) && !rev) {error (_ ("couldn't look up commit object for '%s'" ), refname);return -1 ; } -if (!force && !branch_merged (kinds, branchname, rev, head_rev)) { +if (!(flags &DELETE_BRANCH_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, "
@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)strbuf_release (&buf); } -static int delete_branches (int argc,const char **argv,int force,int kinds, -int quiet) +static int delete_branches (int argc,const char **argv,int kinds, +unsigned int flags) {struct commit *head_rev =NULL ;struct object_id oid;
@@ -241,7 +247,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 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, } branch_name_pos =strcspn (fmt,"%" ); -if (!force) +if (!(flags &DELETE_BRANCH_FORCE )) 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 +285,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 +297,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 +312,16 @@ 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)) { + flags)) { 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:
@@ -330,7 +336,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,char *name = item->string;if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos; -if (!quiet) +if (!(flags &DELETE_BRANCH_QUIET )) printf(remote_branch ? _("Deleted remote-tracking branch %s (was %s).\n") : _("Deleted branch %s (was %s).\n"),
@@ -895,7 +901,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 ();
Signature · 1 line
gitgitgadget
PATCH v23 3/7 branch: let delete_branches skip unmerged branches on bulk refusal
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Add a skip-unmerged mode to delete_branches() and check_branch_commit() so a bulk caller can silently skip branches that are not fully merged and carry on, rather than erroring with the "use 'git branch -D'" advice that the plain "git branch -d" path emits. Existing callers are unaffected.
--- builtin/branch.c | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-)
builtin/branch.c +10 -5
@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ), +DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname, }if (!(flags &DELETE_BRANCH_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_SKIP_UNMERGED )) { +error (_ ("the branch '%s' is not fully merged" ), + branchname); +advise_if_enabled (ADVICE_FORCE_DELETE_BRANCH , +_ ("If you are sure you want to delete it, " +"run 'git branch -D %s'" ), branchname); + }return -1 ; }return 0 ;
@@ -315,7 +319,8 @@ static int delete_branches(int argc, const char **argv, int kinds,if (!(ref_flags & (REF_ISSYMREF |REF_ISBROKEN )) &&check_branch_commit (bname.buf, name, &oid, head_rev, kinds, flags)) { - ret =1 ; +if (!(flags &DELETE_BRANCH_SKIP_UNMERGED )) + ret =1 ; goto next; }
Signature · 1 line
gitgitgadget
PATCH v23 4/7 branch: prepare delete_branches for a bulk caller
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Teach delete_branches() a new mode for the upcoming --delete-merged caller that checks whether a branch is merged into its upstream without falling back to HEAD when there is no upstream. Existing callers keep their current behavior.
--- builtin/branch.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-)
builtin/branch.c +9 -4
@@ -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,7 @@ enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), + DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds, } branch_name_pos =strcspn (fmt,"%" ); -if (!(flags &DELETE_BRANCH_FORCE )) +if (!(flags &DELETE_BRANCH_FORCE ) && + !(flags &DELETE_BRANCH_NO_HEAD_FALLBACK )) head_rev =lookup_commit_reference (the_repository, &head_oid);for (i =0 ; i < argc; i++,strbuf_reset (&bname)) {
Signature · 1 line
gitgitgadget
PATCH v23 5/7 branch: add --delete-merged <branch>
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
git branch (--delete-merged <branch>)... [<pattern>...]
deletes local branches matching the optional patterns when their configured upstream matches one of the --delete-merged arguments and their tip is reachable from that upstream. The work has already landed on the upstream they track, so the local copy is no longer needed.
The option can be repeated to widen the upstream match. Keeping the candidate patterns as positional arguments lets users bound the set of local branches that may be deleted independently of the upstream selection.
A branch is not deleted when:
* it is checked out in any worktree
* its configured upstream ref no longer exists, since a missing upstream is not by itself a sign of integration
* pushing it by name to the remote configured by branch.<name>.remote would update its upstream, as determined by mapping the branch ref through that remote's fetch refspec. For example, a local "main" that tracks "origin/main" is kept even when remote.pushDefault names a fork. Right after a pull it merely looks fully merged.
A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep.
A branch that a surviving branch depends on through a chain of local upstreams is also kept, so no branch is deleted out from under stacked work. Collect this transitive set without changing the candidate set during ref iteration: walk upstream chains from surviving branches, visit each branch at most once, and remove the collected bases only after the iteration completes. This makes the result independent of ref iteration order without repeated full scans.
--- Documentation/git-branch.adoc | 30 +++++ builtin/branch.c | 158 +++++++++++++++++++++++++- t/t3200-branch.sh | 204 ++++++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 2 deletions(-)
Documentation/git-branch.adoc +30 -0
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] +git branch (--delete-merged <branch>)... [<pattern>...] DESCRIPTION -----------
@@ -201,6 +202,35 @@ This option is only applicable in non-verbose mode. Print the name of the current branch. In detached `HEAD` state, nothing is printed. +`--delete-merged <branch>`:: + Delete local branches whose configured upstream matches + _<branch>_, but only when their tip is reachable from that + upstream. In other words, the work on the branch has already + landed on the upstream it tracks, so the local copy is no longer + needed. The option can be repeated to widen the upstream match. + Optional _<pattern>_ arguments limit which local branches are + considered, e.g. `git branch --delete-merged 'origin/*' + 'topic-*'`. ++ +A branch is not deleted when: ++ +-- +* its configured upstream ref no longer exists, +* it is checked out in any worktree, or +* pushing it by name to the remote configured by + `branch.<name>.remote` would update its upstream, so it cannot be + distinguished from a branch that just looks "fully merged" right + after a pull. +-- ++ +A branch whose work has not yet been merged into its upstream is +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. ++ +A branch that a surviving branch depends on through a chain of local +upstreams is kept, so a branch is never deleted out from under stacked +work. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +156 -2
212 lines not on this page · raw message
t/t3200-branch.sh +204 -0
208 lines not on this page · raw message
Signature · 1 line
gitgitgadget
Re: [PATCH v23 5/7] branch: add --delete-merged <branch>
Hi Harald
Sorry it has taken so long for we to look at this again - I'm not sure where last week went.
Quoted 26 lines · Harald Nordgren via GitGitGadget
From: Harald Nordgren <[email protected]>
git branch (--delete-merged <branch>)... [<pattern>...]deletes local branches matching the optional patterns when their configured upstream matches one of the --delete-merged arguments and their tip is reachable from that upstream. The work has already landed on the upstream they track, so the local copy is no longer needed.
The option can be repeated to widen the upstream match. Keeping the candidate patterns as positional arguments lets users bound the set of local branches that may be deleted independently of the upstream selection.
A branch is not deleted when:
* it is checked out in any worktree
* its configured upstream ref no longer exists, since a missing upstream is not by itself a sign of integration
* pushing it by name to the remote configured by branch.<name>.remote would update its upstream, as determined by mapping the branch ref through that remote's fetch refspec. For example, a local "main" that tracks "origin/main" is kept even when remote.pushDefault names a fork. Right after a pull it merely looks fully merged.
Quoted 6 lines
A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep.
A branch that a surviving branch depends on through a chain of local upstreams is also kept, so no branch is deleted out from under stacked work.
Shouldn't this be part of the list above. So we no-longer delete any branch in a chain of stacked branches when one of them is unmerged? Previously we only kept the upstream of the unmerged branch and deleted the rest.
Collect this transitive set without changing the candidate set
Quoted 37 lines
during ref iteration: walk upstream chains from surviving branches, visit each branch at most once, and remove the collected bases only after the iteration completes. This makes the result independent of ref iteration order without repeated full scans.
Signed-off-by Harald Nordgren <[email protected]>--- Documentation/git-branch.adoc | 30 +++++ builtin/branch.c | 158 +++++++++++++++++++++++++- t/t3200-branch.sh | 204 ++++++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 2 deletions(-)Documentation/git-branch.adoc +10
-0@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] +git branch (--delete-merged <branch>)... [<pattern>...] DESCRIPTION -----------@@ -201,6 +202,35 @@ This option is only applicable in non-verbose mode. Print the name of the current branch. In detached `HEAD` state, nothing is printed. +`--delete-merged <branch>`:: + Delete local branches whose configured upstream matches + _<branch>_, but only when their tip is reachable from that + upstream. In other words, the work on the branch has already + landed on the upstream it tracks, so the local copy is no longer + needed. The option can be repeated to widen the upstream match. + Optional _<pattern>_ arguments limit which local branches are + considered, e.g. `git branch --delete-merged 'origin/*' + 'topic-*'`.
Documentation/git-branch.adoc This option is only applicable in non-verbose mode. · line 223
Quoted 10 lines
Documentation/git-branch.adoc +10
-0++ +A branch is not deleted when: ++ +-- +* its configured upstream ref no longer exists, +* it is checked out in any worktree, or +* pushing it by name to the remote configured by + `branch.<name>.remote` would update its upstream, so it cannot be + distinguished from a branch that just looks "fully merged" right + after a pull.
* it is the upstream of an unmerged branch
Quoted 26 lines
Documentation/git-branch.adoc +10
-0+-- ++ +A branch whose work has not yet been merged into its upstream is +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. ++ +A branch that a surviving branch depends on through a chain of local +upstreams is kept, so a branch is never deleted out from under stacked +work. + `-v`:: `-vv`:: `--verbose`::builtin/branch.c +1
-0@@ -21,6 +21,7 @@#include "branch.h" #include "path.h" #include "string-list.h" +#include "strmap.h" #include "column.h" #include "utf8.h" #include "ref-filter.h"
builtin/branch.c static const char * const builtin_branch_usage[] = { · line 42
Quoted 4 lines
builtin/branch.c +1
-0@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {N_ ("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>" ),N_ ("git branch [<options>] [-r | -a] [--points-at]" ),N_ ("git branch [<options>] [-r | -a] [--format]" ), + N_("git branch [<options>] (--delete-merged <branch>)... [<pattern>...]"),
I don't quite follow this - why the "()" and doesn't --delete-merged take a pattern?
Quoted 29 lines
builtin/branch.c +22
-0NULL };@@ -699,6 +701,148 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unsetreturn 0 ; } +struct stacked_branch_data { +struct strset *deletable_branch_names; +struct strset *protected_branch_names; +struct strset *visited_branch_names; +}; + +static int collect_stacked_branch_bases (const struct reference *ref, +void *cb_data) +{ +struct stacked_branch_data *data = cb_data; +const char *branch_name; + +if (!skip_prefix (ref->name,"refs/heads/" , &branch_name)) +BUG ("expected local branch ref, got '%s'" , ref->name); +if (strset_contains (data->deletable_branch_names, branch_name)) +return 0 ; + +while (strset_add (data->visited_branch_names, branch_name)) { +struct branch *branch =branch_get (branch_name); +const char *upstream_refname =branch_get_upstream (branch,NULL ); +const char *upstream_branch_name; +
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 735
Quoted 10 lines
builtin/branch.c +10
-0+if (!upstream_refname || + !skip_prefix (upstream_refname,"refs/heads/" , + &upstream_branch_name) || + !strset_contains (data->deletable_branch_names, + upstream_branch_name)) +break ; + +strset_add (data->protected_branch_names, upstream_branch_name); + branch_name = upstream_branch_name; + }
This looks correct, it is a shame we have to build "visited_branch_names" but the code is clear.
Quoted 13 lines
builtin/branch.c +13
-0+return 0 ; +} + +static void protect_stacked_branch_bases (struct ref_store *refs, +struct strset *deletable_branch_names) +{ +struct strset protected_branch_names =STRSET_INIT ; +struct strset visited_branch_names =STRSET_INIT ; +struct stacked_branch_data data = { + .deletable_branch_names = deletable_branch_names, + .protected_branch_names = &protected_branch_names, + .visited_branch_names = &visited_branch_names, + };
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 759
Quoted 10 lines
builtin/branch.c +10
-0+struct refs_for_each_ref_options opts = { + .prefix ="refs/heads/" , + }; +struct hashmap_iter iter; +struct strmap_entry *entry; + +refs_for_each_ref_ext (refs, collect_stacked_branch_bases, &data, &opts); + +strset_for_each_entry (&protected_branch_names, &iter, entry) +strset_remove (deletable_branch_names, entry->key);
We remove the protected branches from deleteable - good
Quoted 4 lines
builtin/branch.c +4
-0+ +strset_clear (&visited_branch_names); +strset_clear (&protected_branch_names); +}
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 773
Quoted 10 lines
builtin/branch.c +10
-0+ +static int branch_pushes_to_upstream (struct branch *branch, +const char *upstream) +{ +struct remote *remote =remote_get (remote_for_branch (branch,NULL )); +char *tracking =NULL ; +int ret =0 ; + +if (remote) + tracking =apply_refspecs (&remote->fetch, branch->refname);
This tells us which remote tracking ref corresponds to the branch
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 775
Quoted 2 lines
builtin/branch.c +2
-0+if (tracking && !strcmp (tracking, upstream)) + ret =1 ;
Here we check that it does not match the upstream branch. That ignores the push refspect though so does not tell us whether pushing the branch to the upstream remote would update the upstream branch on that remote.
We need to apply the push refspec to the local branch, apply the fetch refspec in reverse to the result and then compare that to the upstream branch.
Quoted 40 lines
builtin/branch.c +40
-0+ +free (tracking); +return ret; +} + +static int delete_merged_branches (const struct strvec *upstreams, +const char **argv,unsigned int flags) +{ +struct ref_store *refs =get_main_ref_store (the_repository); +struct ref_filter filter =REF_FILTER_INIT ; +struct ref_array candidates = {0 }; +struct strset deletable_branch_names =STRSET_INIT ; +struct strvec branches_to_delete =STRVEC_INIT ; +struct hashmap_iter iter; +struct strmap_entry *entry; +int ret =0 ; + +for (size_t i =0 ; i < upstreams->nr; i++) +if (ref_filter_forked_add (&filter, upstreams->v[i]) <0 ) +die (_ ("'%s' is not a valid branch or pattern" ), + upstreams->v[i]); + + filter.kind =FILTER_REFS_BRANCHES ; + filter.name_patterns = argv; +filter_refs (&candidates, &filter, filter.kind); + +for (int i =0 ; i < candidates.nr; i++) { +const char *branch_refname = candidates.items[i]->refname; +const char *branch_name; +struct branch *branch; +const char *upstream_refname; + +if (!skip_prefix (branch_refname,"refs/heads/" , &branch_name)) +BUG ("filter returned non-branch ref '%s'" , branch_refname); +if (branch_checked_out (branch_refname)) +continue ; + + branch =branch_get (branch_name); + upstream_refname =branch_get_upstream (branch,NULL ); +if (!upstream_refname || !refs_ref_exists (refs, upstream_refname))
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 825
Quoted 10 lines
builtin/branch.c +10
-0+continue ; +if (branch_pushes_to_upstream (branch, upstream_refname)) +continue ; +if (check_branch_commit (branch_name, branch_name, + &candidates.items[i]->objectname,NULL , +FILTER_REFS_BRANCHES ,DELETE_BRANCH_SKIP_UNMERGED )) +continue ; + +strset_add (&deletable_branch_names, branch_name); + }
We build the set of branches to delete and then
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 827
Quoted 1 line
builtin/branch.c +1
-0+protect_stacked_branch_bases (refs, &deletable_branch_names);
remove the ones that are upstreams of branches that are not in the set and then
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 830
Quoted 2 lines
builtin/branch.c +2
-0+strset_for_each_entry (&deletable_branch_names, &iter, entry) +strvec_push (&branches_to_delete, entry->key);
build a list of branches to delete - good.
Quoted 52 lines
builtin/branch.c +13
-0+if (branches_to_delete.nr) + ret =delete_branches (branches_to_delete.nr, branches_to_delete.v, +FILTER_REFS_BRANCHES , +DELETE_BRANCH_SKIP_UNMERGED | +DELETE_BRANCH_NO_HEAD_FALLBACK | + flags); + +strvec_clear (&branches_to_delete); +strset_clear (&deletable_branch_names); +ref_array_clear (&candidates); +ref_filter_clear (&filter); +return ret; +}t/t3200-branch.sh +30
-0@@ -1872,4 +1872,208 @@ test_expect_success '--forked requires a value' ' test_grep "requires a value" err ' +test_expect_success '--delete-merged: setup' ' + git init -b main upstream && + ( + cd upstream && + test_commit base && + git checkout -b next && + test_commit next-work && + git checkout main + ) && + git init -b main other && + test_commit -C other other-base && + git init -b main fork +' + +setup_repo_for_delete_merged () { + rm -rf repo && + git clone upstream repo && + ( + cd repo && + git remote add fork ../fork && + git remote add other ../other && + git config push.default current && + git fetch other + ) +} + +create_merged_branch () { + ( + cd repo && + git checkout -b "$1" origin/next --track &&
t/t3200-branch.sh test_expect_success '--forked requires a value' ' · line 1914
Quoted 10 lines
t/t3200-branch.sh +10
-0+ git commit --allow-empty -m "$1 work" && + git push origin "$1:next" + ) +} + +check_branches () { + git for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect && + test_cmp expect actual +}
This makes checking the remaining branches in the tests really nice> +
t/t3200-branch.sh test_expect_success '--forked requires a value' ' · line 1922
Quoted 7 lines
t/t3200-branch.sh +7
-0+test_expect_success '--delete-merged keeps cloned main without a default push remote' ' + setup_repo_for_delete_merged && + ( + cd repo && + git checkout --detach && + + git branch --delete-merged */* &&
Unless I've missed something main does not have an upstream branch set, so we'd never expect it to be deleted, even if a push remote was set.
Quoted 4 lines
t/t3200-branch.sh +4
-0+ + check_branches <<-\EOF + main + EOF
t/t3200-branch.sh test_expect_success '--forked requires a value' ' · line 1936
Quoted 10 lines
t/t3200-branch.sh +10
-0+ ) +' + +test_expect_success '--delete-merged deletes only selected merged branches' ' + setup_repo_for_delete_merged && + create_merged_branch also-merged && + create_merged_branch merged && + ( + cd repo && + git checkout -b unmerged origin/next --track &&
In the tests we try to avoid mixing options and positional arguments so we use either
git checkout --track -b unmerged origin/next or git checkout -b unmerged --track origin/next
t/t3200-branch.sh test_expect_success '--forked requires a value' ' · line 1946
Quoted 10 lines
t/t3200-branch.sh +10
-0+ git commit --allow-empty -m "unmerged work" && + git checkout -b tracks-other other/main --track && + sha=$(git rev-parse --short merged) && + + git branch --delete-merged origin/next merged >actual 2>&1 && + echo "Deleted branch merged (was $sha)." >expect && + test_cmp expect actual && + + check_branches <<-\EOF + also-merged
As we passed "merged" on the command line "also-merged" was not deleted - good.
Quoted 9 lines
t/t3200-branch.sh +9
-0+ main + tracks-other + unmerged + EOF + ) +' + +test_expect_success '--delete-merged keeps main despite a different default push remote' ' + setup_repo_for_delete_merged &&
t/t3200-branch.sh test_expect_success '--forked requires a value' ' · line 1965
Quoted 10 lines
t/t3200-branch.sh +10
-0+ create_merged_branch on-next && + create_merged_branch checked-out && + create_merged_branch upstream-gone && + ( + cd repo && + git config remote.pushDefault fork && + git checkout -b local-to-delete main --track && + git update-ref refs/remotes/origin/topic refs/remotes/origin/next && + git branch --set-upstream-to=origin/topic upstream-gone && + git update-ref -d refs/remotes/origin/topic &&
As I think I said last time, this is a very round-about way to have the upstream gone. It would be much simpler just to set the config directly.
t/t3200-branch.sh test_expect_success '--forked requires a value' ' · line 1970
Quoted 5 lines
t/t3200-branch.sh +5
-0+ git checkout -b tracks-other other/main --track && + git checkout checked-out && + + git branch --delete-merged origin/* \ + --delete-merged main &&
These lines look very short why the wrapping?
Quoted 6 lines
t/t3200-branch.sh +6
-0+ + check_branches <<-\EOF + checked-out + main + tracks-other + upstream-gone
t/t3200-branch.sh test_expect_success '--forked requires a value' ' · line 1986
Quoted 10 lines
t/t3200-branch.sh +10
-0+ EOF + ) +' + +test_expect_success '--delete-merged keeps the upstream of a surviving branch' ' + setup_repo_for_delete_merged && + create_merged_branch feature && + ( + cd repo && + git checkout -b topic feature --track &&
we base our branch on feature which is merged into origin/next
Quoted 5 lines
t/t3200-branch.sh +5
-0+ git commit --allow-empty -m "topic work" && + + git branch --delete-merged origin/next 2>err && + + test_must_be_empty err &&
t/t3200-branch.sh test_expect_success '--forked requires a value' ' · line 2001
Quoted 10 lines
t/t3200-branch.sh +10
-0+ check_branches <<-\EOF && + feature + main + topic + EOF + + git config --local --get-regexp "branch\\.(feature|topic)\\.(merge|remote)" >actual && + cat >expect <<-\EOF && + branch.feature.remote origin + branch.feature.merge refs/heads/next
and so we keep feature and it's upstream config (because it's upstream branch still exists) - good.
Quoted 13 lines
t/t3200-branch.sh +13
-0+ branch.topic.remote . + branch.topic.merge refs/heads/feature + EOF + test_cmp expect actual + ) +' + +test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' ' + setup_repo_for_delete_merged && + ( + cd repo && + git config remote.pushDefault fork && + git branch lower origin/next --track &&
t/t3200-branch.sh test_expect_success '--forked requires a value' ' · line 2024
Quoted 10 lines
t/t3200-branch.sh +10
-0+ git branch mid lower --track && + git checkout -b tip mid --track && + git commit --allow-empty -m "tip work" && + + git branch --delete-merged origin/next \ + --delete-merged lower >actual 2>&1 && + test_must_be_empty actual && + + check_branches <<-\EOF && + lower
Why do we keep "lower", rather than clear the upstream config of "mid"?
I'm not completely convinced by the new behavior wrt stacked branches, but this looks good apart from the branch_pushes_to_upstream() issue.
Thanks
Phillip
Quoted 55 lines
t/t3200-branch.sh +54
-0+ main + mid + tip + EOF + + git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual && + cat >expect <<-\EOF && + branch.lower.remote origin + branch.lower.merge refs/heads/next + branch.mid.remote . + branch.mid.merge refs/heads/lower + branch.tip.remote . + branch.tip.merge refs/heads/mid + EOF + test_cmp expect actual + ) +' + +test_expect_success '--delete-merged result is independent of stacked branch names' ' + setup_repo_for_delete_merged && + ( + cd repo && + git branch c-lower origin/next --track && + git branch b-mid c-lower --track && + git checkout -b a-tip b-mid --track && + git commit --allow-empty -m "tip work" && + + git branch --delete-merged origin/next \ + --delete-merged "c-*" && + + check_branches <<-\EOF && + a-tip + b-mid + c-lower + main + EOF + + git branch --delete-merged origin/next \ + --delete-merged "c-*" >actual 2>&1 && + test_must_be_empty actual && + + check_branches <<-\EOF + a-tip + b-mid + c-lower + main + EOF + ) +' + +test_expect_success '--delete-merged requires a value' ' + test_must_fail git -C forked branch --delete-merged 2>err && + test_grep "requires a value" err +' test_done
Re: [PATCH v23 5/7] branch: add --delete-merged <branch>
Quoted 4 lines
Hi Harald
Sorry it has taken so long for we to look at this again - I'm not sure where last week went.
No worries!
Quoted 15 lines
Quoted 15 lines
A branch is not deleted when:
* it is checked out in any worktree
* its configured upstream ref no longer exists, since a missing upstream is not by itself a sign of integration
* pushing it by name to the remote configured by branch.<name>.remote would update its upstream, as determined by mapping the branch ref through that remote's fetch refspec. For example, a local "main" that tracks "origin/main" is kept even when remote.pushDefault names a fork. Right after a pull it merely looks fully merged.A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep.
Quoted 8 lines
Quoted 3 lines
A branch that a surviving branch depends on through a chain of local upstreams is also kept, so no branch is deleted out from under stacked work.
Shouldn't this be part of the list above. So we no-longer delete any branch in a chain of stacked branches when one of them is unmerged? Previously we only kept the upstream of the unmerged branch and deleted the rest.
Good point.
Quoted 8 lines
Documentation/git-branch.adoc This option is only applicable in non-verbose mode. · line 223
Quoted 6 lines
Documentation/git-branch.adoc +6
-0+* its configured upstream ref no longer exists, +* it is checked out in any worktree, or +* pushing it by name to the remote configured by + `branch.<name>.remote` would update its upstream, so it cannot be + distinguished from a branch that just looks "fully merged" right + after a pull.* it is the upstream of an unmerged branch
Thanks!
Quoted 25 lines
Quoted 25 lines
+--
++Documentation/git-branch.adoc +8
-0+A branch whose work has not yet been merged into its upstream is +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. ++ +A branch that a surviving branch depends on through a chain of local +upstreams is kept, so a branch is never deleted out from under stacked +work. + `-v`:: `-vv`:: `--verbose`::builtin/branch.c +1
-0@@ -21,6 +21,7 @@#include "branch.h" #include "path.h" #include "string-list.h" +#include "strmap.h" #include "column.h" #include "utf8.h" #include "ref-filter.h"
Quoted 8 lines
builtin/branch.c static const char * const builtin_branch_usage[] = { · line 42
Quoted 5 lines
builtin/branch.c +1
-0@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {N_ ("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>" ),N_ ("git branch [<options>] [-r | -a] [--points-at]" ),N_ ("git branch [<options>] [-r | -a] [--format]" ), + N_("git branch [<options>] (--delete-merged <branch>)... [<pattern>...]"),I don't quite follow this - why the "()" and doesn't --delete-merged take a pattern?
I don't get this one, but would this be better?
``` git branch [--dry-run] --delete-merged <pattern> [--delete-merged <pattern>]... [<branch-pattern>...] ```
Quoted 89 lines
Quoted 29 lines
builtin/branch.c +22
-0NULL };@@ -699,6 +701,148 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unsetreturn 0 ; } +struct stacked_branch_data { +struct strset *deletable_branch_names; +struct strset *protected_branch_names; +struct strset *visited_branch_names; +}; + +static int collect_stacked_branch_bases (const struct reference *ref, +void *cb_data) +{ +struct stacked_branch_data *data = cb_data; +const char *branch_name; + +if (!skip_prefix (ref->name,"refs/heads/" , &branch_name)) +BUG ("expected local branch ref, got '%s'" , ref->name); +if (strset_contains (data->deletable_branch_names, branch_name)) +return 0 ; + +while (strset_add (data->visited_branch_names, branch_name)) { +struct branch *branch =branch_get (branch_name); +const char *upstream_refname =branch_get_upstream (branch,NULL ); +const char *upstream_branch_name; +builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 735
Quoted 10 lines
builtin/branch.c +10
-0+if (!upstream_refname || + !skip_prefix (upstream_refname,"refs/heads/" , + &upstream_branch_name) || + !strset_contains (data->deletable_branch_names, + upstream_branch_name)) +break ; + +strset_add (data->protected_branch_names, upstream_branch_name); + branch_name = upstream_branch_name; + }This looks correct, it is a shame we have to build "
visited_branch_names" but the code is clear.Quoted 13 lines
builtin/branch.c +13
-0+return 0 ; +} + +static void protect_stacked_branch_bases (struct ref_store *refs, +struct strset *deletable_branch_names) +{ +struct strset protected_branch_names =STRSET_INIT ; +struct strset visited_branch_names =STRSET_INIT ; +struct stacked_branch_data data = { + .deletable_branch_names = deletable_branch_names, + .protected_branch_names = &protected_branch_names, + .visited_branch_names = &visited_branch_names, + };builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 759
Quoted 10 lines
builtin/branch.c +10
-0+struct refs_for_each_ref_options opts = { + .prefix ="refs/heads/" , + }; +struct hashmap_iter iter; +struct strmap_entry *entry; + +refs_for_each_ref_ext (refs, collect_stacked_branch_bases, &data, &opts); + +strset_for_each_entry (&protected_branch_names, &iter, entry) +strset_remove (deletable_branch_names, entry->key);We remove the protected branches from deleteable - good
Quoted 4 lines
builtin/branch.c +4
-0+ +strset_clear (&visited_branch_names); +strset_clear (&protected_branch_names); +}builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 773
Quoted 10 lines
builtin/branch.c +10
-0+ +static int branch_pushes_to_upstream (struct branch *branch, +const char *upstream) +{ +struct remote *remote =remote_get (remote_for_branch (branch,NULL )); +char *tracking =NULL ; +int ret =0 ; + +if (remote) + tracking =apply_refspecs (&remote->fetch, branch->refname);This tells us which remote tracking ref corresponds to the branch
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 775
Quoted 2 lines
builtin/branch.c +2
-0+if (tracking && !strcmp (tracking, upstream)) + ret =1 ;
Quoted 7 lines
Here we check that it does not match the upstream branch. That ignores the push refspect though so does not tell us whether pushing the branch to the upstream remote would update the upstream branch on that remote.
We need to apply the push refspec to the local branch, apply the fetch refspec in reverse to the result and then compare that to the upstream branch.
I'll try to do something about this, but I removed a big part of the push logic when introducing the stacked branches, it simplified things a lot without it. But maybe too simplistic.
Quoted 10 lines
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 781
Quoted 7 lines
builtin/branch.c +7
-0+test_expect_success '--delete-merged keeps cloned main without a default push remote' ' +setup_repo_for_delete_merged && +( +cd repo && +git checkout --detach && + + git branch --delete-merged */* &&Unless I've missed something main does not have an upstream branch set, so we'd never expect it to be deleted, even if a push remote was set.
I'll take a look.
Quoted 8 lines
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 788
Quoted 7 lines
builtin/branch.c +7
-0+test_expect_success '--delete-merged deletes only selected merged branches' ' + setup_repo_for_delete_merged && + create_merged_branch also-merged && + create_merged_branch merged && + ( + cd repo && + git checkout -b unmerged origin/next --track &&
Quoted 5 lines
In the tests we try to avoid mixing options and positional arguments so we use either
git checkout --track -b unmerged origin/next or git checkout -b unmerged --track origin/next
Good point.
Quoted 13 lines
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 800
Quoted 12 lines
builtin/branch.c +12
-0+test_expect_success '--delete-merged keeps main despite a different default push remote' ' + setup_repo_for_delete_merged && + create_merged_branch on-next && + create_merged_branch checked-out && + create_merged_branch upstream-gone && + ( + cd repo && + git config remote.pushDefault fork && + git checkout -b local-to-delete main --track && + git update-ref refs/remotes/origin/topic refs/remotes/origin/next && + git branch --set-upstream-to=origin/topic upstream-gone && + git update-ref -d refs/remotes/origin/topic &&
Quoted 2 lines
As I think I said last time, this is a very round-about way to have the upstream gone. It would be much simpler just to set the config directly.
Good point.
Quoted 7 lines
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 805
Quoted 5 lines
builtin/branch.c +5
-0+ git checkout -b tracks-other other/main --track && + git checkout checked-out && + + git branch --delete-merged origin/* \ +--delete-merged main && These lines look very short why the wrapping?
Will unwrap some of these.
Quoted 1 line
Why do we keep "lower", rather than clear the upstream config of "mid"?
We keep lower to preserve the upstream chain of surviving tip.
Harald
Re: [PATCH v23 5/7] branch: add --delete-merged <branch>
Hi Harald
Quoted 9 lines · Harald Nordgren
Quoted 8 lines
builtin/branch.c static const char * const builtin_branch_usage[] = { · line 42
Quoted 5 lines
builtin/branch.c +1
-0@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {N_ ("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>" ),N_ ("git branch [<options>] [-r | -a] [--points-at]" ),N_ ("git branch [<options>] [-r | -a] [--format]" ), + N_("git branch [<options>] (--delete-merged <branch>)... [<pattern>...]"),I don't quite follow this - why the "()" and doesn't --delete-merged take a pattern?
Quoted 5 lines
I don't get this one, but would this be better?
```
git branch [--dry-run] --delete-merged <pattern>[--delete-merged <pattern>]... [<branch-pattern>...]
I think I just didn't understand what "(--option)..." means - grepping around that does seem to be what we use for "give this option at least once" so I think the original is fine.
Quoted 15 lines
Quoted 15 lines
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 773
Quoted 9 lines
builtin/branch.c +9
-0+static int branch_pushes_to_upstream (struct branch *branch, +const char *upstream) +{ +struct remote *remote =remote_get (remote_for_branch (branch,NULL )); +char *tracking =NULL ; +int ret =0 ; + +if (remote) + tracking =apply_refspecs (&remote->fetch, branch->refname);This tells us which remote tracking ref corresponds to the branch
Quoted 2 lines
builtin/branch.c +2
-0+if (tracking && !strcmp (tracking, upstream)) + ret =1 ;
Quoted 7 lines
Quoted 7 lines
Here we check that it does not match the upstream branch. That ignores the push refspect though so does not tell us whether pushing the branch to the upstream remote would update the upstream branch on that remote.
We need to apply the push refspec to the local branch, apply the fetch refspec in reverse to the result and then compare that to the upstream branch.
Oops I think that's wrong. We should apply the fetch refspec to the result of the push refspec, not apply it in reverse.
If we want to pretend that origin uses "main" rather than "master" we could set
remote.origin.push refs/heads/main:refs/heads/master remote.origin.fetch refs/heads/master:refs/remotes/origin/main
so we map refs/heads/main to refs/heads/master and then map that to refs/remotes/origin/main. There's nothing (apart from maybe common sense) to say the refspecs have to be symmetric though.
Quoted 3 lines
I'll try to do something about this, but I removed a big part of the push logic when introducing the stacked branches, it simplified things a lot without it. But maybe too simplistic.
Maybe I'm missing something but aren't the two concerns independent?
Quoted 6 lines
Quoted 6 lines
Quoted 5 lines
t/t3200-branch.sh +5
-0+ git checkout -b tracks-other other/main --track && + git checkout checked-out && + + git branch --delete-merged origin/* \ + --delete-merged main &&
Quoted 7 lines
Quoted 1 line
These lines look very short why the wrapping?
Will unwrap some of these.
Quoted 1 line
Why do we keep "lower", rather than clear the upstream config of "mid"?
We keep lower to preserve the upstream chain of surviving tip.
Yes but why do we want to do that? It's a change in behavior from clearing the upstream of mid that wasn't really called out clearly as a change in behavior for xxx reason in the cover letter of the iteration that introduced it. Why is it desirable to keep the whole chain rather than just the upstream of the unmerged branch?
Thanks
Phillip
Re: [PATCH v23 5/7] branch: add --delete-merged <branch>
Thanks for all the help to drive this across the finish line!
Quoted 11 lines
Quoted 11 lines
Quoted 8 lines
builtin/branch.c static const char * const builtin_branch_usage[] = { · line 42
Quoted 5 lines
builtin/branch.c +1
-0@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {N_ ("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>" ),N_ ("git branch [<options>] [-r | -a] [--points-at]" ),N_ ("git branch [<options>] [-r | -a] [--format]" ), + N_("git branch [<options>] (--delete-merged <branch>)... [<pattern>...]"),I don't quite follow this - why the "()" and doesn't --delete-merged take a pattern?
I don't get this one, but would this be better?
Quoted 7 lines
Quoted 3 lines
```
git branch [--dry-run] --delete-merged <pattern>[--delete-merged <pattern>]... [<branch-pattern>...]I think I just didn't understand what "(--option)..." means - grepping around that does seem to be what we use for "give this option at least once" so I think the original is fine.
Ok, will use the ().
Quoted 29 lines
Quoted 15 lines
Quoted 15 lines
builtin/branch.c static int parse_opt_forked(const struct option *opt, const char *arg, int unset · line 773
Quoted 9 lines
builtin/branch.c +9
-0+static int branch_pushes_to_upstream (struct branch *branch, +const char *upstream) +{ +struct remote *remote =remote_get (remote_for_branch (branch,NULL )); +char *tracking =NULL ; +int ret =0 ; + +if (remote) + tracking =apply_refspecs (&remote->fetch, branch->refname);This tells us which remote tracking ref corresponds to the branch
Quoted 2 lines
builtin/branch.c +2
-0+if (tracking && !strcmp (tracking, upstream)) + ret =1 ;Quoted 7 lines
Quoted 7 lines
Here we check that it does not match the upstream branch. That ignores the push refspect though so does not tell us whether pushing the branch to the upstream remote would update the upstream branch on that remote.
We need to apply the push refspec to the local branch, apply the fetch refspec in reverse to the result and then compare that to the upstream branch.
Oops I think that's wrong. We should apply the fetch refspec to the result of the push refspec, not apply it in reverse.
If we want to pretend that origin uses "main" rather than "master" we could set
Quoted 6 lines
remote.origin.push refs/heads/main:refs/heads/master remote.origin.fetch refs/heads/master:refs/remotes/origin/main
so we map refs/heads/main to refs/heads/master and then map that to refs/remotes/origin/main. There's nothing (apart from maybe common sense) to say the refspecs have to be symmetric though.
I'm writing logic for this and an integration test. Thanks!
Quoted 9 lines
Quoted 3 lines
Quoted 1 line
Why do we keep "lower", rather than clear the upstream config of "mid"?
We keep lower to preserve the upstream chain of surviving tip.
Yes but why do we want to do that? It's a change in behavior from clearing the upstream of mid that wasn't really called out clearly as a change in behavior for xxx reason in the cover letter of the iteration that introduced it. Why is it desirable to keep the whole chain rather than just the upstream of the unmerged branch?
I can do it and get rid of the graph traversal (visited), but I think we are once again going back to an earlier iteration. One of the other reviewers wanted to protect transitive branches I think.
Either way is fine, I think it's a pretty small detail in the grand scheme of things, I just don't want another round of ping-pongs on this part.
Harald
PATCH v23 6/7 branch: add branch.<name>.deleteMerged opt-out
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Setting branch.<name>.deleteMerged=false exempts that branch from "git branch --delete-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.
--- Documentation/config/branch.adoc | 7 +++++++ Documentation/git-branch.adoc | 5 +++-- builtin/branch.c | 14 +++++++++++++ t/t3200-branch.sh | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-)
Documentation/config/branch.adoc +7 -0
@@ -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.<name>.deleteMerged`:: + If set to `false`, branch _<name>_ is exempt from + `git branch --delete-merged`. Useful for a topic branch you + intend to develop further after an initial round has been + merged upstream. Defaults to true. Explicit deletion via + `git branch -d` is unaffected.
Documentation/git-branch.adoc +3 -2
@@ -216,11 +216,12 @@ A branch is not deleted when: + -- * its configured upstream ref no longer exists, -* it is checked out in any worktree, or +* it is checked out in any worktree, * pushing it by name to the remote configured by `branch.<name>.remote` would update its upstream, so it cannot be distinguished from a branch that just looks "fully merged" right - after a pull. + after a pull, or +* `branch.<name>.deleteMerged` is set to `false`. -- + A branch whose work has not yet been merged into its upstream is
builtin/branch.c +14 -0
@@ -786,6 +786,7 @@ static int delete_merged_branches(const struct strvec *upstreams,struct ref_array candidates = {0 };struct strset deletable_branch_names =STRSET_INIT ;struct strvec branches_to_delete =STRVEC_INIT ; +struct strbuf key =STRBUF_INIT ;struct hashmap_iter iter;struct strmap_entry *entry;int ret =0 ;
@@ -804,6 +805,7 @@ static int delete_merged_branches(const struct strvec *upstreams,const char *branch_name;struct branch *branch;const char *upstream_refname; +int opt_out;if (!skip_prefix (branch_refname,"refs/heads/" , &branch_name))BUG ("filter returned non-branch ref '%s'" , branch_refname);
@@ -821,6 +823,17 @@ static int delete_merged_branches(const struct strvec *upstreams, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; +strbuf_reset (&key); +strbuf_addf (&key,"branch.%s.deletemerged" , branch_name); +if (!repo_config_get_bool (the_repository, key.buf, &opt_out) && + !opt_out) { +if (!(flags &DELETE_BRANCH_QUIET )) +fprintf (stderr, +_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), + branch_name, branch_name); +continue ; + } +strset_add (&deletable_branch_names, branch_name); }
@@ -836,6 +849,7 @@ static int delete_merged_branches(const struct strvec *upstreams,DELETE_BRANCH_NO_HEAD_FALLBACK | flags); +strbuf_release (&key);strvec_clear (&branches_to_delete);strset_clear (&deletable_branch_names);ref_array_clear (&candidates);
t/t3200-branch.sh +36 -0
@@ -2076,4 +2076,40 @@ test_expect_success '--delete-merged requires a value' ' test_must_fail git -C forked branch --delete-merged 2>err && test_grep "requires a value" err ' + +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' ' + setup_repo_for_delete_merged && + create_merged_branch deleted && + create_merged_branch kept && + ( + cd repo && + git config branch.kept.deleteMerged false && + git checkout --detach && + + git branch --delete-merged origin/next 2>err && + + test_grep "Skipping .kept." err && + check_branches <<-\EOF + kept + main + EOF + ) +' + +test_expect_success "branch -d still deletes a deleteMerged=false branch" ' + setup_repo_for_delete_merged && + create_merged_branch kept && + ( + cd repo && + git config branch.kept.deleteMerged false && + git checkout --detach && + + git branch -d kept && + + check_branches <<-\EOF + main + EOF + ) +' + test_done
Signature · 1 line
gitgitgadget
PATCH v23 7/7 branch: add --dry-run for --delete-merged
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
"git branch --dry-run --delete-merged ..." prints one line per ref that would be deleted without modifying refs or branch configuration.
--dry-run is only meaningful together with --delete-merged and is rejected otherwise.
--- Documentation/git-branch.adoc | 8 +++++++- builtin/branch.c | 21 ++++++++++++++++--- t/t3200-branch.sh | 38 ++++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 5 deletions(-)
Documentation/git-branch.adoc +7 -1
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] -git branch (--delete-merged <branch>)... [<pattern>...] +git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...] DESCRIPTION -----------
@@ -232,6 +232,12 @@ A branch that a surviving branch depends on through a chain of local upstreams is kept, so a branch is never deleted out from under stacked work. +`--dry-run`:: + With `--delete-merged`, print which branches would be + deleted and exit without touching any ref. Useful for + sanity-checking a wide pattern like `'origin/*'` before + committing to the deletion. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +18 -3
@@ -199,6 +199,7 @@ enum delete_branch_flags {DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ),DELETE_BRANCH_NO_HEAD_FALLBACK = (1 <<3 ), + DELETE_BRANCH_DRY_RUN = (1 << 4), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -340,13 +341,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 (!(flags &DELETE_BRANCH_DRY_RUN ) && +refs_delete_refs (get_main_ref_store (the_repository),NULL , &refs_to_delete,REF_NO_DEREF )) ret =1 ;for_each_string_list_item (item, &refs_to_delete) {char *describe_ref = item->util;char *name = item->string; -if (!refs_ref_exists (get_main_ref_store (the_repository), name)) { +if (flags &DELETE_BRANCH_DRY_RUN ) { +if (!(flags &DELETE_BRANCH_QUIET )) +printf (remote_branch + ?_ ("Would delete remote-tracking branch %s (was %s).\n" ) + :_ ("Would delete branch %s (was %s).\n" ), + name + branch_name_pos, describe_ref); + }else if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos;if (!(flags &DELETE_BRANCH_QUIET )) printf(remote_branch
@@ -922,6 +930,7 @@ int cmd_branch(int argc,int delete =0 , rename =0 , copy =0 , list =0 , unset_upstream =0 , show_current =0 , edit_description =0 ;struct strvec delete_merged =STRVEC_INIT ; +int dry_run =0 ;const char *new_upstream =NULL ;int noncreate_actions =0 ;/* possible options */
@@ -978,6 +987,8 @@ int cmd_branch(int argc,OPT_CALLBACK_F (0 ,"delete-merged" , &delete_merged,N_ ("branch" ),N_ ("delete merged branches whose upstream matches <branch> (repeatable)" ),PARSE_OPT_NONEG , parse_opt_strvec), +OPT_BOOL (0 ,"dry-run" , &dry_run, +N_ ("with --delete-merged, only print which branches would be deleted" )),OPT__FORCE (&force,N_ ("force creation, move/rename, deletion" ),PARSE_OPT_NOCOMPLETE ),OPT_MERGED (&filter,N_ ("print only branches that are merged" )), OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1040,6 +1051,9 @@ int cmd_branch(int argc,if (noncreate_actions >1 )usage_with_options (builtin_branch_usage, options); +if (dry_run && !delete_merged.nr) +die (_ ("--dry-run requires --delete-merged" )); +if (recurse_submodules_explicit) {if (!submodule_propagate_branches)die (_ ("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled" ));
@@ -1080,7 +1094,8 @@ int cmd_branch(int argc, goto out; }else if (delete_merged.nr) { ret =delete_merged_branches (&delete_merged, argv, - quiet ?DELETE_BRANCH_QUIET :0 ); + (quiet ?DELETE_BRANCH_QUIET :0 ) | + (dry_run ?DELETE_BRANCH_DRY_RUN :0 )); goto out; }else if (show_current) {print_current_branch_name ();
t/t3200-branch.sh +37 -1
@@ -1938,6 +1938,19 @@ test_expect_success '--delete-merged deletes only selected merged branches' ' git checkout -b tracks-other other/main --track && sha=$(git rev-parse --short merged) && + git branch --dry-run --delete-merged origin/next merged >actual 2>&1 && + echo "Would delete branch merged (was $sha)." >expect && + test_cmp expect actual && + git rev-parse --verify refs/heads/merged && + + check_branches <<-\EOF && + also-merged + main + merged + tracks-other + unmerged + EOF + git branch --delete-merged origin/next merged >actual 2>&1 && echo "Deleted branch merged (was $sha)." >expect && test_cmp expect actual &&
@@ -1986,9 +1999,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' ' git checkout -b topic feature --track && git commit --allow-empty -m "topic work" && - git branch --delete-merged origin/next 2>err && + git branch --dry-run --delete-merged origin/next >out && + test_grep ! "feature" out && + git branch --delete-merged origin/next 2>err && test_must_be_empty err && + check_branches <<-\EOF && feature main
@@ -2016,6 +2032,21 @@ test_expect_success '--delete-merged keeps the upstream chain of a surviving bra git checkout -b tip mid --track && git commit --allow-empty -m "tip work" && + git branch --dry-run --delete-merged origin/next \ + --delete-merged lower >actual 2>&1 && + test_must_be_empty actual && + + git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual && + cat >expect <<-\EOF && + branch.lower.remote origin + branch.lower.merge refs/heads/next + branch.mid.remote . + branch.mid.merge refs/heads/lower + branch.tip.remote . + branch.tip.merge refs/heads/mid + EOF + test_cmp expect actual && + git branch --delete-merged origin/next \ --delete-merged lower >actual 2>&1 && test_must_be_empty actual &&
@@ -2112,4 +2143,9 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" ' ) ' +test_expect_success '--dry-run without --delete-merged is rejected' ' + test_must_fail git -C forked branch --dry-run 2>err && + test_grep "requires --delete-merged" err +' + test_done
Signature · 1 line
gitgitgadget
Re: [PATCH v23 0/7] branch: delete-merged
In reply to Harald Nordgren via GitGitGadgetQuoted 6 lines · Harald Nordgren via GitGitGadget
Delete branches that have already been merged on upstream.
Changes in v23:
* Use a loop-local
size_titerator for --forked patterns.
* Simplify upstream-prefix stripping into an explicit fallback.
That was quick. I have no more comments to add.
Thanks.
v24 cover branch: delete-merged
In reply to Harald Nordgren via GitGitGadgetDelete branches that have already been merged on upstream.
Changes in v24:
* Map branches through the configured push and fetch refspecs before deciding whether pushing would update their upstream.
* Protect only the direct upstream of surviving work, delete deeper merged bases and clear stale upstream configuration.
* Clarify that --delete-merged takes upstream patterns in docs.
Changes in v23:
* Use a loop-local size_t iterator for --forked patterns.
* Simplify upstream-prefix stripping into an explicit fallback.
Changes in v22:
* Loop variable int when appropriate to avoid typecast.
Changes in v21:
* Change loop variables to prevent CI timeout.
Changes in v20:
* Protect branches transitively required by a surviving local upstream stack. Traverse upstream chains once and defer delete-set mutation until traversal completes.
* Make stacked-branch handling independent of ref iteration order and update the documentation accordingly.
* Clarify variable names with regards to branch names (short) to reduce confusion.
Changes in v19:
* Fix bug where dry-run would still remove config, added test coverage.
* Redesigned --delete-merged as a repeatable upstream selector with optional positional patterns limiting deletion scope.
* Protect same-name upstream branches independently of push-default configuration.
* Simplified flags handling where local caching became complicated when mutating values.
* Clarified assertions in tests.
Changes in v18:
* Instead of keeping the whole chain of upstream branches, keep only the ones an unmerged branch still needs. When a kept (merged) branch in turn tracks a branch that is being deleted, clear its now-stale upstream config.
* Rework spare_stacked_bases() to record the kept bases and, in a second pass, clear the upstream of any whose own base is going away. Build the to-delete list with strset_for_each_entry() instead of re-walking the candidate array.
Changes in v17:
* Keep a merged branch when another surviving branch still tracks it as its upstream, so --delete-merged no longer deletes a branch out from under one stacked on top of it.
* Move the --dry-run and branch.<name>.deleteMerged opt-out fully into their own commits.
Changes in v16:
* Convert delete_merged_branches() to take an unsigned int flags argument instead of separate quiet/dry_run booleans, matching delete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_reset per iteration, single strbuf_release after) instead of allocating and freeing it each time
* Rewrite the --delete-merged tests as integration tests: branches that land commits upstream, with deletion and the checked-out, upstream-gone, and push-equals-upstream safety cases exercised together in one run and output asserted via test_cmp
* Collapse the many per-aspect test repos into a single reused repo set up by a setup_repo_for_delete_merged helper, and rename helpers off the old pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of prefixing every command with -C
Changes in v15:
* Renamed --prune-merged to --delete-merged throughout. Not necessarily final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of warning.
* Initialized the delete_branches() flag locals where declared. Only force stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f, --forked coverage), renamed the misleading trunk fixture, and replaced the misnamed detached branch with git checkout --detach.
Changes in v14:
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the remotes path set a local force but not the DELETE_BRANCH_FORCE bit that check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other branches too, hopefully not related
(https://github.com/git/git/pull/2285).
Changes in v13:
* Reworked --forked into a real ref-filter applied in apply_ref_filter() instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the abbreviated upstream), and dropped the old helper machinery, forward declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git branch --prune-merged origin/main 'feature*') instead of repeating the option.
Changes in v12:
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST options.
* Dropped the bare-remote-name resolution for --forked, the argument is now a ref or a glob.
Changes in v11:
* 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 /HEAD by name guard that the reviewer rejected is gone.
* New --dry-run for --prune-merged.
Changes in v10:
* --forked / --prune-merged now take a branch glob instead of a remote name — origin, origin/*, origin/release-- all work. This replaces the remote-only form and subsumes the old --all-remotes flag, which has been dropped.
* New --dry-run for --prune-merged.
Changes in v9:
* --force no longer has special meaning with --prune-merged; reachability is always enforced. Use git branch -D to delete an unmerged branch. Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.
Changes in v8:
* Delete only when the branch's work is actually reachable from its upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place
Changes in v7:
* --prune-merged now checks if a branch is merged into its own upstream first. If the upstream is gone, it checks against the remote's default branch instead. If neither exists, the branch is refused (use --force to delete anyway).
Changes in v6:
* --prune-merged now measures merged-ness against the remote's default branch instead of the candidate's upstream — so the decision no longer depends on which branch happens to be checked out locally.
* delete_branches() / check_branch_commit() gained a per-candidate override that lets a caller substitute a different "what counts as merged" reference (or skip the check). branch -d callers pass NULL and keep their existing semantics.
* prune_merged_branches() resolves each candidate's push-remote HEAD and threads it through, so --prune-merged --all-remotes measures each candidate against its own remote rather than a single global reference.
Changes in v5:
* Drop commit 'fetch: add --prune-merged'
Changes in v4:
* Resolve each remote's HEAD and collect the targets into a protected_default_refs set in collect_forked_set.
* In prune_merged_branches, skip a candidate when its upstream is a protected default ref and the local branch name matches the default branch's leaf name (so a local main tracking origin/main is spared, but a renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name (not by upstream alone); spare a branch whose push ref is the remote default.
Changes in v3:
* s/remote-tracking refs/remote-tracking branches/g
Changes in v2:
* The whole feature moved out of git fetch and into git branch. git fetch --prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their tip is reachable from the upstream tracking ref; --force skips that safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running topic branch) even with --force; doesn't affect explicit git branch -d.
* delete_branches() got a warn_only mode so bulk deletion prints a one-line warning per skipped branch instead of the noisy four-line hint that git branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention --prune-merged.
* New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh shrunk since most logic moved.
Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <pattern>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged
Documentation/config/branch.adoc | 7 + Documentation/git-branch.adoc | 52 +++- builtin/branch.c | 312 +++++++++++++++++++++--- ref-filter.c | 70 ++++++ ref-filter.h | 10 + t/t3200-branch.sh | 401 +++++++++++++++++++++++++++++++ 6 files changed, 821 insertions(+), 31 deletions(-)
base-commit: 13c7afec212fc97ce257d15601659314c6673d6c
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v24
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v24
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v23:
1: 0c4fe549cb ! 1: 59157142b3 branch: add --forked filter for --list mode
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked composes with --no-merged' '
-+ git -C forked branch --forked "origin/*" --no-merged origin/one \
-+ --format="%(refname:short)" >actual &&
++ git -C forked branch --forked "origin/*" --no-merged origin/one --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked narrows a <pattern> argument' '
-+ git -C forked branch --forked "origin/*" "local-*" \
-+ --format="%(refname:short)" >actual &&
++ git -C forked branch --forked "origin/*" "local-*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two 2: 2d20015ba9 ! 2: 5d310da9aa branch: convert delete_branches() to a flags argument
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
- int flags = 0;
+ int ref_flags = 0;
- copy_branchname(&bname, argv[i], allowed_interpret);
- free(name);
+ copy_branchname(the_repository, &bname,
+ argv[i], allowed_interpret);
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
RESOLVE_REF_READING
| RESOLVE_REF_NO_RECURSE
3: 34b37aeb43 = 3: 263e90f6ed branch: let delete_branches skip unmerged branches on bulk refusal
4: 606ce4082c = 4: 22e31fd35c branch: prepare delete_branches for a bulk caller
5: 5fd74f0050 ! 5: d9f93e66c9 branch: add --delete-merged <branch>
@@ Metadata
Author: Harald Nordgren <[email protected]>
## Commit message ##
- branch: add --delete-merged <branch>
+ branch: add --delete-merged <pattern>
- git branch (--delete-merged <branch>)... [<pattern>...]
+ git branch (--delete-merged <pattern>)... [<branch-pattern>...]
- deletes local branches matching the optional patterns when their
+ deletes local branches matching the optional branch patterns when their
configured upstream matches one of the --delete-merged arguments and
their tip is reachable from that upstream. The work has already landed
on the upstream they track, so the local copy is no longer needed.
- The option can be repeated to widen the upstream match. Keeping the
- candidate patterns as positional arguments lets users bound the set of
- local branches that may be deleted independently of the upstream
- selection.
+ Each <pattern> may name a ref, a remote, or a shell glob. The option can
+ be repeated to widen the upstream match. Keeping the candidate patterns
+ as positional arguments lets users bound the set of local branches that
+ may be deleted independently of the upstream selection.
A branch is not deleted when:
* it is checked out in any worktree
* its configured upstream ref no longer exists, since a missing
upstream is not by itself a sign of integration
- * pushing it by name to the remote configured by
- branch.<name>.remote would update its upstream, as determined by
- mapping the branch ref through that remote's fetch refspec. For
- example, a local "main" that tracks "origin/main" is kept even when
- remote.pushDefault names a fork. Right after a pull it merely looks
- fully merged.
+ * pushing it to the remote configured by branch.<name>.remote would
+ update its upstream, as determined by that remote's configured push
+ and fetch refspecs. For example, a local "main" that tracks
+ "origin/main" is kept even when remote.pushDefault names a fork.
+ Right after a pull it merely looks fully merged.
+ * it is the local upstream of a branch that is not being deleted, so
+ no branch is deleted out from under stacked work.
A branch whose work is not yet merged into its upstream is silently
skipped, so one unmerged topic does not abort the whole sweep.
- A branch that a surviving branch depends on through a chain of local
- upstreams is also kept, so no branch is deleted out from under stacked
- work. Collect this transitive set without changing the candidate set
- during ref iteration: walk upstream chains from surviving branches,
- visit each branch at most once, and remove the collected bases only
- after the iteration completes. This makes the result independent of
- ref iteration order without repeated full scans.
+ Collect protected local upstreams without changing the candidate set
+ during ref iteration, then remove them after iteration. This makes the
+ result independent of ref iteration order. If a protected branch's own
+ upstream is deleted by the same sweep, clear its upstream configuration.
Signed-off-by: Harald Nordgren <[email protected]>
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-+git branch (--delete-merged <branch>)... [<pattern>...]
++git branch (--delete-merged <pattern>)... [<branch-pattern>...]
DESCRIPTION
-----------
@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
-+`--delete-merged <branch>`::
++`--delete-merged <pattern>`::
+ Delete local branches whose configured upstream matches
-+ _<branch>_, but only when their tip is reachable from that
++ _<pattern>_, but only when their tip is reachable from that
+ upstream. In other words, the work on the branch has already
+ landed on the upstream it tracks, so the local copy is no longer
-+ needed. The option can be repeated to widen the upstream match.
-+ Optional _<pattern>_ arguments limit which local branches are
-+ considered, e.g. `git branch --delete-merged 'origin/*'
++ needed. _<pattern>_ may name a ref, a remote (using the branch its
++ `HEAD` points at), or a shell-style glob. The option can be
++ repeated to widen the upstream match.
++ Optional _<branch-pattern>_ arguments limit which local branches
++ are considered, e.g. `git branch --delete-merged 'origin/*'
+ 'topic-*'`.
++
+A branch is not deleted when:
++
+--
+* its configured upstream ref no longer exists,
-+* it is checked out in any worktree, or
-+* pushing it by name to the remote configured by
++* it is checked out in any worktree,
++* pushing it to the remote configured by
+ `branch.<name>.remote` would update its upstream, so it cannot be
-+ distinguished from a branch that just looks "fully merged" right
-+ after a pull.
++ distinguished from a branch that just looks fully merged right
++ after a pull; this is determined by the remote's configured push and
++ fetch refspecs,
++* it is the local upstream of a branch that is not being deleted.
+--
++
++When such a local upstream branch has its own upstream deleted by the
++same operation, its upstream configuration is cleared.
+++
+A branch whose work has not yet been merged into its upstream is
+silently skipped. Delete it with `git branch -D` if you want to
+remove it anyway.
-++
-+A branch that a surviving branch depends on through a chain of local
-+upstreams is kept, so a branch is never deleted out from under stacked
-+work.
+
`-v`::
`-vv`::
@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
-+ N_("git branch [<options>] (--delete-merged <branch>)... [<pattern>...]"),
++ N_("git branch [<options>] (--delete-merged <pattern>)... "
++ "[<branch-pattern>...]"),
NULL
};
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+struct stacked_branch_data {
+ struct strset *deletable_branch_names;
+ struct strset *protected_branch_names;
-+ struct strset *visited_branch_names;
+};
+
-+static int collect_stacked_branch_bases(const struct reference *ref,
-+ void *cb_data)
++static int collect_stacked_branch_base(const struct reference *ref,
++ void *cb_data)
+{
+ struct stacked_branch_data *data = cb_data;
+ const char *branch_name;
++ struct branch *branch;
++ const char *upstream_refname;
++ const char *upstream_branch_name;
+
+ if (!skip_prefix(ref->name, "refs/heads/", &branch_name))
+ BUG("expected local branch ref, got '%s'", ref->name);
+ if (strset_contains(data->deletable_branch_names, branch_name))
+ return 0;
+
-+ while (strset_add(data->visited_branch_names, branch_name)) {
-+ struct branch *branch = branch_get(branch_name);
-+ const char *upstream_refname = branch_get_upstream(branch, NULL);
-+ const char *upstream_branch_name;
-+
-+ if (!upstream_refname ||
-+ !skip_prefix(upstream_refname, "refs/heads/",
-+ &upstream_branch_name) ||
-+ !strset_contains(data->deletable_branch_names,
-+ upstream_branch_name))
-+ break;
-+
-+ strset_add(data->protected_branch_names, upstream_branch_name);
-+ branch_name = upstream_branch_name;
-+ }
++ branch = branch_get(branch_name);
++ upstream_refname = branch_get_upstream(branch, NULL);
++ if (!upstream_refname ||
++ !skip_prefix(upstream_refname, "refs/heads/",
++ &upstream_branch_name) ||
++ !strset_contains(data->deletable_branch_names,
++ upstream_branch_name))
++ return 0;
+
++ strset_add(data->protected_branch_names, upstream_branch_name);
+ return 0;
+}
+
+static void protect_stacked_branch_bases(struct ref_store *refs,
-+ struct strset *deletable_branch_names)
++ struct strset *deletable_branch_names,
++ struct strset *protected_branch_names)
+{
-+ struct strset protected_branch_names = STRSET_INIT;
-+ struct strset visited_branch_names = STRSET_INIT;
+ struct stacked_branch_data data = {
+ .deletable_branch_names = deletable_branch_names,
-+ .protected_branch_names = &protected_branch_names,
-+ .visited_branch_names = &visited_branch_names,
++ .protected_branch_names = protected_branch_names,
+ };
+ struct refs_for_each_ref_options opts = {
+ .prefix = "refs/heads/",
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+
-+ refs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);
++ refs_for_each_ref_ext(refs, collect_stacked_branch_base, &data, &opts);
+
-+ strset_for_each_entry(&protected_branch_names, &iter, entry)
++ strset_for_each_entry(protected_branch_names, &iter, entry)
+ strset_remove(deletable_branch_names, entry->key);
++}
+
-+ strset_clear(&visited_branch_names);
-+ strset_clear(&protected_branch_names);
++static void clear_deleted_upstreams(struct strset *protected_branch_names,
++ struct strset *deletable_branch_names)
++{
++ struct strbuf key = STRBUF_INIT;
++ struct hashmap_iter iter;
++ struct strmap_entry *entry;
++
++ strset_for_each_entry(protected_branch_names, &iter, entry) {
++ struct branch *branch = branch_get(entry->key);
++ const char *upstream_refname = branch_get_upstream(branch, NULL);
++ const char *upstream_branch_name;
++
++ if (!upstream_refname ||
++ !skip_prefix(upstream_refname, "refs/heads/",
++ &upstream_branch_name) ||
++ !strset_contains(deletable_branch_names,
++ upstream_branch_name))
++ continue;
++
++ strbuf_addf(&key, "branch.%s.merge", branch->name);
++ repo_config_set_gently(the_repository, key.buf, NULL);
++ strbuf_reset(&key);
++ strbuf_addf(&key, "branch.%s.remote", branch->name);
++ repo_config_set_gently(the_repository, key.buf, NULL);
++ strbuf_reset(&key);
++ }
++
++ strbuf_release(&key);
+}
+
+static int branch_pushes_to_upstream(struct branch *branch,
+ const char *upstream)
+{
+ struct remote *remote = remote_get(remote_for_branch(branch, NULL));
++ char *push_refname = NULL;
+ char *tracking = NULL;
+ int ret = 0;
+
-+ if (remote)
-+ tracking = apply_refspecs(&remote->fetch, branch->refname);
++ if (!remote)
++ return 0;
++ if (remote->push.nr)
++ push_refname = apply_refspecs(&remote->push, branch->refname);
++ else
++ push_refname = xstrdup(branch->refname);
++ if (push_refname)
++ tracking = apply_refspecs(&remote->fetch, push_refname);
+ if (tracking && !strcmp(tracking, upstream))
+ ret = 1;
+
++ free(push_refname);
+ free(tracking);
+ return ret;
+}
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ struct ref_filter filter = REF_FILTER_INIT;
+ struct ref_array candidates = { 0 };
+ struct strset deletable_branch_names = STRSET_INIT;
++ struct strset protected_branch_names = STRSET_INIT;
+ struct strvec branches_to_delete = STRVEC_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ strset_add(&deletable_branch_names, branch_name);
+ }
+
-+ protect_stacked_branch_bases(refs, &deletable_branch_names);
++ protect_stacked_branch_bases(refs, &deletable_branch_names,
++ &protected_branch_names);
+
+ strset_for_each_entry(&deletable_branch_names, &iter, entry)
+ strvec_push(&branches_to_delete, entry->key);
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ flags);
+
++ if (!ret)
++ clear_deleted_upstreams(&protected_branch_names,
++ &deletable_branch_names);
++
+ strvec_clear(&branches_to_delete);
++ strset_clear(&protected_branch_names);
+ strset_clear(&deletable_branch_names);
+ ref_array_clear(&candidates);
+ ref_filter_clear(&filter);
@@ builtin/branch.c: int cmd_branch(int argc,
OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
OPT_BOOL(0, "edit-description", &edit_description,
N_("edit the description for the branch")),
-+ OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("branch"),
-+ N_("delete merged branches whose upstream matches <branch> (repeatable)"),
++ OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("pattern"),
++ N_("delete merged branches whose upstream matches <pattern> (repeatable)"),
+ PARSE_OPT_NONEG, parse_opt_strvec),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+create_merged_branch () {
+ (
+ cd repo &&
-+ git checkout -b "$1" origin/next --track &&
++ git checkout -b "$1" --track origin/next &&
+ git commit --allow-empty -m "$1 work" &&
+ git push origin "$1:next"
+ )
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ test_cmp expect actual
+}
+
-+test_expect_success '--delete-merged keeps cloned main without a default push remote' '
++test_expect_success '--delete-merged keeps cloned main without explicit push configuration' '
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
++ test_cmp_config origin branch.main.remote &&
++ test_cmp_config refs/heads/main branch.main.merge &&
+ git checkout --detach &&
+
+ git branch --delete-merged */* &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ create_merged_branch merged &&
+ (
+ cd repo &&
-+ git checkout -b unmerged origin/next --track &&
++ git checkout -b unmerged --track origin/next &&
+ git commit --allow-empty -m "unmerged work" &&
-+ git checkout -b tracks-other other/main --track &&
++ git checkout -b tracks-other --track other/main &&
+ sha=$(git rev-parse --short merged) &&
+
+ git branch --delete-merged origin/next merged >actual 2>&1 &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ (
+ cd repo &&
+ git config remote.pushDefault fork &&
-+ git checkout -b local-to-delete main --track &&
-+ git update-ref refs/remotes/origin/topic refs/remotes/origin/next &&
-+ git branch --set-upstream-to=origin/topic upstream-gone &&
-+ git update-ref -d refs/remotes/origin/topic &&
-+ git checkout -b tracks-other other/main --track &&
++ git checkout -b local-to-delete --track main &&
++ git config branch.upstream-gone.merge refs/heads/topic &&
++ git checkout -b tracks-other --track other/main &&
+ git checkout checked-out &&
+
-+ git branch --delete-merged origin/* \
-+ --delete-merged main &&
++ git branch --delete-merged origin/* --delete-merged main &&
+
+ check_branches <<-\EOF
+ checked-out
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ )
+'
+
++test_expect_success '--delete-merged maps push refspecs to upstreams' '
++ setup_repo_for_delete_merged &&
++ (
++ cd repo &&
++ git checkout -b topic &&
++ git config remote.origin.push "refs/heads/topic:refs/heads/published" &&
++ git push origin &&
++ git branch --set-upstream-to=origin/published topic &&
++ git checkout -b other-topic --track origin/published &&
++ git checkout --detach &&
++
++ git branch --delete-merged origin/published &&
++
++ check_branches <<-\EOF
++ main
++ topic
++ EOF
++ )
++'
++
+test_expect_success '--delete-merged keeps the upstream of a surviving branch' '
+ setup_repo_for_delete_merged &&
+ create_merged_branch feature &&
+ (
+ cd repo &&
-+ git checkout -b topic feature --track &&
++ git checkout -b topic --track feature &&
+ git commit --allow-empty -m "topic work" &&
+
+ git branch --delete-merged origin/next 2>err &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ )
+'
+
-+test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '
++test_expect_success '--delete-merged clears the deleted upstream of a protected branch' '
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
-+ git config remote.pushDefault fork &&
-+ git branch lower origin/next --track &&
-+ git branch mid lower --track &&
-+ git checkout -b tip mid --track &&
++ git branch --track lower origin/next &&
++ git branch --track mid lower &&
++ git checkout -b tip --track mid &&
+ git commit --allow-empty -m "tip work" &&
++ sha=$(git rev-parse --short lower) &&
+
-+ git branch --delete-merged origin/next \
-+ --delete-merged lower >actual 2>&1 &&
-+ test_must_be_empty actual &&
++ git branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&
++ echo "Deleted branch lower (was $sha)." >expect &&
++ test_cmp expect actual &&
+
+ check_branches <<-\EOF &&
-+ lower
+ main
+ mid
+ tip
+ EOF
+
-+ git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual &&
++ git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
+ cat >expect <<-\EOF &&
-+ branch.lower.remote origin
-+ branch.lower.merge refs/heads/next
-+ branch.mid.remote .
-+ branch.mid.merge refs/heads/lower
+ branch.tip.remote .
+ branch.tip.merge refs/heads/mid
+ EOF
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
-+ git branch c-lower origin/next --track &&
-+ git branch b-mid c-lower --track &&
-+ git checkout -b a-tip b-mid --track &&
++ git branch --track c-lower origin/next &&
++ git branch --track b-mid c-lower &&
++ git checkout -b a-tip --track b-mid &&
+ git commit --allow-empty -m "tip work" &&
+
-+ git branch --delete-merged origin/next \
-+ --delete-merged "c-*" &&
++ git branch --delete-merged origin/next --delete-merged "c-*" &&
+
+ check_branches <<-\EOF &&
+ a-tip
+ b-mid
-+ c-lower
+ main
+ EOF
+
-+ git branch --delete-merged origin/next \
-+ --delete-merged "c-*" >actual 2>&1 &&
++ git branch --delete-merged origin/next --delete-merged "c-*" >actual 2>&1 &&
+ test_must_be_empty actual &&
+
+ check_branches <<-\EOF
+ a-tip
+ b-mid
-+ c-lower
+ main
+ EOF
+ ) 6: 6494be7c75 ! 6: aa845096d8 branch: add branch.<name>.deleteMerged opt-out
@@ Documentation/config/branch.adoc: for details).
## Documentation/git-branch.adoc ##
@@ Documentation/git-branch.adoc: A branch is not deleted when:
- +
- --
- * its configured upstream ref no longer exists,
--* it is checked out in any worktree, or
-+* it is checked out in any worktree,
- * pushing it by name to the remote configured by
- branch.<name>.remote would update its upstream, so it cannot be
- distinguished from a branch that just looks "fully merged" right
-- after a pull.
-+ after a pull, or
+ distinguished from a branch that just looks fully merged right
+ after a pull; this is determined by the remote's configured push and
+ fetch refspecs,
+-* it is the local upstream of a branch that is not being deleted. ++* it is the local upstream of a branch that is not being deleted, or
+* branch.<name>.deleteMerged is set to false.
--
+
- A branch whose work has not yet been merged into its upstream is
+ When such a local upstream branch has its own upstream deleted by the
## builtin/branch.c ##
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
- struct ref_array candidates = { 0 };
struct strset deletable_branch_names = STRSET_INIT;
+ struct strset protected_branch_names = STRSET_INIT;
struct strvec branches_to_delete = STRVEC_INIT;
+ struct strbuf key = STRBUF_INIT;
struct hashmap_iter iter;
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
}
@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
- DELETE_BRANCH_NO_HEAD_FALLBACK |
- flags);
+ clear_deleted_upstreams(&protected_branch_names,
+ &deletable_branch_names);
+ strbuf_release(&key);
strvec_clear(&branches_to_delete);
+ strset_clear(&protected_branch_names);
strset_clear(&deletable_branch_names);
- ref_array_clear(&candidates);
## t/t3200-branch.sh ##
@@ t/t3200-branch.sh: test_expect_success '--delete-merged requires a value' '
7: 7102c931e2 ! 7: 96ebfd6ec6 branch: add --dry-run for --delete-merged
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
--git branch (--delete-merged <branch>)... [<pattern>...]
-+git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...] +-git branch (--delete-merged <pattern>)... [<branch-pattern>...] ++git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: A branch that a surviving branch depends on through a chain of local
- upstreams is kept, so a branch is never deleted out from under stacked
- work.
+@@ Documentation/git-branch.adoc: A branch whose work has not yet been merged into its upstream is
+ silently skipped. Delete it with git branch -D if you want to
+ remove it anyway.
+--dry-run::
+ With --delete-merged, print which branches would be
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki
char *refname = name + branch_name_pos;
if (!(flags & DELETE_BRANCH_QUIET))
printf(remote_branch
+@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ flags);
+
+- if (!ret)
++ if (!ret && !(flags & DELETE_BRANCH_DRY_RUN))
+ clear_deleted_upstreams(&protected_branch_names,
+ &deletable_branch_names);
+
@@ builtin/branch.c: int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
@@ builtin/branch.c: int cmd_branch(int argc,
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: int cmd_branch(int argc,
- OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("branch"),
- N_("delete merged branches whose upstream matches <branch> (repeatable)"),
+ OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("pattern"),
+ N_("delete merged branches whose upstream matches <pattern> (repeatable)"),
PARSE_OPT_NONEG, parse_opt_strvec),
+ OPT_BOOL(0, "dry-run", &dry_run,
+ N_("with --delete-merged, only print which branches would be deleted")),
@@ builtin/branch.c: int cmd_branch(int argc,
## t/t3200-branch.sh ##
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected merged branches' '
- git checkout -b tracks-other other/main --track &&
+ git checkout -b tracks-other --track other/main &&
sha=$(git rev-parse --short merged) &&
+ git branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&
@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me
echo "Deleted branch merged (was $sha)." >expect &&
test_cmp expect actual &&
@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a surviving branch' '
- git checkout -b topic feature --track &&
+ git checkout -b topic --track feature &&
git commit --allow-empty -m "topic work" &&
- git branch --delete-merged origin/next 2>err &&
@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a
check_branches <<-\EOF &&
feature
main
-@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream chain of a surviving bra
- git checkout -b tip mid --track &&
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstream of a protected
git commit --allow-empty -m "tip work" &&
+ sha=$(git rev-parse --short lower) &&
-+ git branch --dry-run --delete-merged origin/next \
-+ --delete-merged lower >actual 2>&1 &&
-+ test_must_be_empty actual &&
++ git branch --dry-run --delete-merged origin/next --delete-merged lower >actual 2>&1 &&
++ echo "Would delete branch lower (was $sha)." >expect &&
++ test_cmp expect actual &&
+
+ git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual &&
+ cat >expect <<-\EOF &&
@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream chain
+ EOF
+ test_cmp expect actual &&
+
- git branch --delete-merged origin/next \
- --delete-merged lower >actual 2>&1 &&
- test_must_be_empty actual &&
+ git branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&
+ echo "Deleted branch lower (was $sha)." >expect &&
+ test_cmp expect actual &&
@@ t/t3200-branch.sh: test_expect_success "branch -d still deletes a deleteMerged=false branch" '
)
'
Signature · 1 line
gitgitgadget
PATCH v24 1/7 branch: add --forked filter for --list mode
From: Harald Nordgren <[email protected]>
Add a --forked option to "git branch" list mode that lists only branches whose configured upstream matches <branch>. The argument can be a ref (e.g. "origin/main", "master"), a remote name like "origin" for the branch its origin/HEAD points at, or a shell 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 --delete-merged, which deletes the listed branches once they have landed on their upstream.
--- Documentation/git-branch.adoc | 12 +++- builtin/branch.c | 18 +++++- ref-filter.c | 70 +++++++++++++++++++++ ref-filter.h | 10 +++ t/t3200-branch.sh | 115 ++++++++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 3 deletions(-)
Documentation/git-branch.adoc +11 -1
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current] [--column[=<options>] | --no-column] [--sort=<key>] [--merged [<commit>]] [--no-merged [<commit>]] [--contains [<commit>]] [--no-contains [<commit>]] + [(--forked <branch>)...] [--points-at <object>] [--format=<format>] [(-r|--remotes) | (-a|--all)] [--list] [<pattern>...]
@@ -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 _<commit>_ argument is missing it defaults to `HEAD` (i.e. the tip of the current -branch). +branch). With `--forked`, only branches whose configured upstream matches +the given branch or pattern will be listed. The command's second form creates a new branch head named _<branch-name>_ which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main". Only list branches whose tips are not reachable from _<commit>_ (`HEAD` if not specified). Implies `--list`. +`--forked <branch>`:: + Only list branches whose configured upstream matches + _<branch>_. The argument can be a ref (e.g. `origin/main`, + `master`), a remote name like `origin` for the branch its + `origin/HEAD` points at, or a shell-style glob (e.g. + `'origin/*'`). The option can be repeated to widen the + filter. Implies `--list`. + `--points-at <object>`:: Only list branches of _<object>_.
builtin/branch.c +16 -2
@@ -30,7 +30,7 @@#include "commit-reach.h" static const char *const builtin_branch_usage[] = { -N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged]" ), +N_ ("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]" ),N_ ("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]" ),N_ ("git branch [<options>] [-l] [<pattern>...]" ),N_ ("git branch [<options>] [-r] (-d | -D) <branch-name>..." ),
@@ -674,6 +674,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, intfree_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)
@@ -794,6 +804,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 <branch> (repeatable)" ), +PARSE_OPT_NONEG , parse_opt_forked),OPT_COLUMN (0 ,"column" , &colopts,N_ ("list branches in columns" )),OPT_REF_SORT (&sorting_options), OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -839,7 +852,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 +
ref-filter.c +70 -0
88 lines not on this page · raw message
ref-filter.h +10 -0
@@ -67,6 +67,7 @@ struct ref_filter {const char **name_patterns;const char *start_after;struct strvec exclude; +struct strvec forked;struct oid_array points_at;struct commit_list *with_commit;struct commit_list *no_commit;
@@ -110,6 +111,7 @@ struct ref_format {#define REF_FILTER_INIT { \ .points_at = OID_ARRAY_INIT, \ .exclude = STRVEC_INIT, \ + .forked = STRVEC_INIT, \ }#define REF_FORMAT_INIT { \ .use_color = GIT_COLOR_UNKNOWN, \
@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);struct ref_sorting *ref_sorting_options (struct string_list *);/* Function to parse --merged and --no-merged options */ int parse_opt_merge_filter (const struct option *opt,const char *arg,int unset); +/* +* Register a --forked <branch> pattern on the filter. The argument is +* either a ref, which is resolved to its full refname, or a shell-style +* glob. Branches are kept only when their configured upstream matches +* one of the registered patterns. Returns -1 if the argument is not a +* valid ref or pattern. +*/ +int ref_filter_forked_add (struct ref_filter *filter,const char *arg);/* Get the current HEAD's description */ char *get_head_description (void );/* Set up translated strings in the output. */
t/t3200-branch.sh +115 -0
119 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v24 2/7 branch: convert delete_branches() to a flags argument
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
delete_branches() takes separate force and quiet parameters, while check_branch_commit() takes force. The next commits would grow this collection further. Replace them with a single unsigned flags argument and an enum.
Test the FORCE and QUIET bits directly from flags at each use site so that mutating or forwarding flags cannot leave cached values stale.
No change in behavior.
--- builtin/branch.c | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-)
builtin/branch.c +24 -16
@@ -189,16 +189,22 @@ 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 ), +}; +static int check_branch_commit (const char *branchname,const char *refname,const struct object_id *oid,struct commit *head_rev, -int kinds,int force) +int kinds,unsigned int flags) {struct commit *rev =lookup_commit_reference (the_repository, oid); -if (!force && !rev) { +if (!(flags &DELETE_BRANCH_FORCE ) && !rev) {error (_ ("couldn't look up commit object for '%s'" ), refname);return -1 ; } -if (!force && !branch_merged (kinds, branchname, rev, head_rev)) { +if (!(flags &DELETE_BRANCH_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, "
@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)strbuf_release (&buf); } -static int delete_branches (int argc,const char **argv,int force,int kinds, -int quiet) +static int delete_branches (int argc,const char **argv,int kinds, +unsigned int flags) {struct commit *head_rev =NULL ;struct object_id oid;
@@ -241,7 +247,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 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, } branch_name_pos =strcspn (fmt,"%" ); -if (!force) +if (!(flags &DELETE_BRANCH_FORCE )) head_rev =lookup_commit_reference (the_repository, &head_oid);for (i =0 ; i < argc; i++,strbuf_reset (&bname)) {char *target =NULL ; -int flags =0 ; +int ref_flags =0 ;copy_branchname (the_repository, &bname, argv[i], allowed_interpret);
@@ -280,7 +286,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,RESOLVE_REF_READING |RESOLVE_REF_NO_RECURSE |RESOLVE_REF_ALLOW_BAD_NAME , - &oid, &flags); + &oid, &ref_flags);if (!target) {if (remote_branch) {error (_ ("remote-tracking branch '%s' not found" ), bname.buf);
@@ -292,7 +298,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, | RESOLVE_REF_NO_RECURSE |RESOLVE_REF_ALLOW_BAD_NAME , &oid, - &flags); + &ref_flags);FREE_AND_NULL (virtual_name);if (virtual_target)
@@ -307,16 +313,16 @@ 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)) { + flags)) { 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:
@@ -331,7 +337,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,char *name = item->string;if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos; -if (!quiet) +if (!(flags &DELETE_BRANCH_QUIET )) printf(remote_branch ? _("Deleted remote-tracking branch %s (was %s).\n") : _("Deleted branch %s (was %s).\n"),
@@ -896,7 +902,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 ();
Signature · 1 line
gitgitgadget
PATCH v24 3/7 branch: let delete_branches skip unmerged branches on bulk refusal
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Add a skip-unmerged mode to delete_branches() and check_branch_commit() so a bulk caller can silently skip branches that are not fully merged and carry on, rather than erroring with the "use 'git branch -D'" advice that the plain "git branch -d" path emits. Existing callers are unaffected.
--- builtin/branch.c | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-)
builtin/branch.c +10 -5
@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ), +DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname, }if (!(flags &DELETE_BRANCH_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_SKIP_UNMERGED )) { +error (_ ("the branch '%s' is not fully merged" ), + branchname); +advise_if_enabled (ADVICE_FORCE_DELETE_BRANCH , +_ ("If you are sure you want to delete it, " +"run 'git branch -D %s'" ), branchname); + }return -1 ; }return 0 ;
@@ -316,7 +320,8 @@ static int delete_branches(int argc, const char **argv, int kinds,if (!(ref_flags & (REF_ISSYMREF |REF_ISBROKEN )) &&check_branch_commit (bname.buf, name, &oid, head_rev, kinds, flags)) { - ret =1 ; +if (!(flags &DELETE_BRANCH_SKIP_UNMERGED )) + ret =1 ; goto next; }
Signature · 1 line
gitgitgadget
PATCH v24 4/7 branch: prepare delete_branches for a bulk caller
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Teach delete_branches() a new mode for the upcoming --delete-merged caller that checks whether a branch is merged into its upstream without falling back to HEAD when there is no upstream. Existing callers keep their current behavior.
--- builtin/branch.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-)
builtin/branch.c +9 -4
@@ -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,7 @@ enum delete_branch_flags {DELETE_BRANCH_FORCE = (1 <<0 ),DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ), + DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds, } branch_name_pos =strcspn (fmt,"%" ); -if (!(flags &DELETE_BRANCH_FORCE )) +if (!(flags &DELETE_BRANCH_FORCE ) && + !(flags &DELETE_BRANCH_NO_HEAD_FALLBACK )) head_rev =lookup_commit_reference (the_repository, &head_oid);for (i =0 ; i < argc; i++,strbuf_reset (&bname)) {
Signature · 1 line
gitgitgadget
PATCH v24 5/7 branch: add --delete-merged <pattern>
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
git branch (--delete-merged <pattern>)... [<branch-pattern>...]
deletes local branches matching the optional branch patterns when their configured upstream matches one of the --delete-merged arguments and their tip is reachable from that upstream. The work has already landed on the upstream they track, so the local copy is no longer needed.
Each <pattern> may name a ref, a remote, or a shell glob. The option can be repeated to widen the upstream match. Keeping the candidate patterns as positional arguments lets users bound the set of local branches that may be deleted independently of the upstream selection.
A branch is not deleted when:
* it is checked out in any worktree
* its configured upstream ref no longer exists, since a missing upstream is not by itself a sign of integration
* pushing it to the remote configured by branch.<name>.remote would update its upstream, as determined by that remote's configured push and fetch refspecs. For example, a local "main" that tracks "origin/main" is kept even when remote.pushDefault names a fork. Right after a pull it merely looks fully merged.
* it is the local upstream of a branch that is not being deleted, so no branch is deleted out from under stacked work.
A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep.
Collect protected local upstreams without changing the candidate set during ref iteration, then remove them after iteration. This makes the result independent of ref iteration order. If a protected branch's own upstream is deleted by the same sweep, clear its upstream configuration.
--- Documentation/git-branch.adoc | 33 ++++++ builtin/branch.c | 195 ++++++++++++++++++++++++++++++- t/t3200-branch.sh | 214 ++++++++++++++++++++++++++++++++++ 3 files changed, 440 insertions(+), 2 deletions(-)
Documentation/git-branch.adoc +33 -0
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] +git branch (--delete-merged <pattern>)... [<branch-pattern>...] DESCRIPTION -----------
@@ -201,6 +202,38 @@ This option is only applicable in non-verbose mode. Print the name of the current branch. In detached `HEAD` state, nothing is printed. +`--delete-merged <pattern>`:: + Delete local branches whose configured upstream matches + _<pattern>_, but only when their tip is reachable from that + upstream. In other words, the work on the branch has already + landed on the upstream it tracks, so the local copy is no longer + needed. _<pattern>_ may name a ref, a remote (using the branch its + `HEAD` points at), or a shell-style glob. The option can be + repeated to widen the upstream match. + Optional _<branch-pattern>_ arguments limit which local branches + are considered, e.g. `git branch --delete-merged 'origin/*' + 'topic-*'`. ++ +A branch is not deleted when: ++ +-- +* its configured upstream ref no longer exists, +* it is checked out in any worktree, +* pushing it to the remote configured by + `branch.<name>.remote` would update its upstream, so it cannot be + distinguished from a branch that just looks fully merged right + after a pull; this is determined by the remote's configured push and + fetch refspecs, +* it is the local upstream of a branch that is not being deleted. +-- ++ +When such a local upstream branch has its own upstream deleted by the +same operation, its upstream configuration is cleared. ++ +A branch whose work has not yet been merged into its upstream is +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +193 -2
249 lines not on this page · raw message
t/t3200-branch.sh +214 -0
218 lines not on this page · raw message
Signature · 1 line
gitgitgadget
PATCH v24 6/7 branch: add branch.<name>.deleteMerged opt-out
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
Setting branch.<name>.deleteMerged=false exempts that branch from "git branch --delete-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.
--- Documentation/config/branch.adoc | 7 +++++++ Documentation/git-branch.adoc | 3 ++- builtin/branch.c | 14 +++++++++++++ t/t3200-branch.sh | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-)
Documentation/config/branch.adoc +7 -0
@@ -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.<name>.deleteMerged`:: + If set to `false`, branch _<name>_ is exempt from + `git branch --delete-merged`. Useful for a topic branch you + intend to develop further after an initial round has been + merged upstream. Defaults to true. Explicit deletion via + `git branch -d` is unaffected.
Documentation/git-branch.adoc +2 -1
@@ -224,7 +224,8 @@ A branch is not deleted when: distinguished from a branch that just looks fully merged right after a pull; this is determined by the remote's configured push and fetch refspecs, -* it is the local upstream of a branch that is not being deleted. +* it is the local upstream of a branch that is not being deleted, or +* `branch.<name>.deleteMerged` is set to `false`. -- + When such a local upstream branch has its own upstream deleted by the
builtin/branch.c +14 -0
@@ -818,6 +818,7 @@ static int delete_merged_branches(const struct strvec *upstreams,struct strset deletable_branch_names =STRSET_INIT ;struct strset protected_branch_names =STRSET_INIT ;struct strvec branches_to_delete =STRVEC_INIT ; +struct strbuf key =STRBUF_INIT ;struct hashmap_iter iter;struct strmap_entry *entry;int ret =0 ;
@@ -836,6 +837,7 @@ static int delete_merged_branches(const struct strvec *upstreams,const char *branch_name;struct branch *branch;const char *upstream_refname; +int opt_out;if (!skip_prefix (branch_refname,"refs/heads/" , &branch_name))BUG ("filter returned non-branch ref '%s'" , branch_refname);
@@ -853,6 +855,17 @@ static int delete_merged_branches(const struct strvec *upstreams, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; +strbuf_reset (&key); +strbuf_addf (&key,"branch.%s.deletemerged" , branch_name); +if (!repo_config_get_bool (the_repository, key.buf, &opt_out) && + !opt_out) { +if (!(flags &DELETE_BRANCH_QUIET )) +fprintf (stderr, +_ ("Skipping '%s' (branch.%s.deleteMerged is false)\n" ), + branch_name, branch_name); +continue ; + } +strset_add (&deletable_branch_names, branch_name); }
@@ -873,6 +886,7 @@ static int delete_merged_branches(const struct strvec *upstreams,clear_deleted_upstreams (&protected_branch_names, &deletable_branch_names); +strbuf_release (&key);strvec_clear (&branches_to_delete);strset_clear (&protected_branch_names);strset_clear (&deletable_branch_names);
t/t3200-branch.sh +36 -0
@@ -2084,4 +2084,40 @@ test_expect_success '--delete-merged requires a value' ' test_must_fail git -C forked branch --delete-merged 2>err && test_grep "requires a value" err ' + +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' ' + setup_repo_for_delete_merged && + create_merged_branch deleted && + create_merged_branch kept && + ( + cd repo && + git config branch.kept.deleteMerged false && + git checkout --detach && + + git branch --delete-merged origin/next 2>err && + + test_grep "Skipping .kept." err && + check_branches <<-\EOF + kept + main + EOF + ) +' + +test_expect_success "branch -d still deletes a deleteMerged=false branch" ' + setup_repo_for_delete_merged && + create_merged_branch kept && + ( + cd repo && + git config branch.kept.deleteMerged false && + git checkout --detach && + + git branch -d kept && + + check_branches <<-\EOF + main + EOF + ) +' + test_done
Signature · 1 line
gitgitgadget
PATCH v24 7/7 branch: add --dry-run for --delete-merged
In reply to Harald Nordgren via GitGitGadgetFrom: Harald Nordgren <[email protected]>
"git branch --dry-run --delete-merged ..." prints one line per ref that would be deleted without modifying refs or branch configuration.
--dry-run is only meaningful together with --delete-merged and is rejected otherwise.
--- Documentation/git-branch.adoc | 8 +++++++- builtin/branch.c | 23 +++++++++++++++++---- t/t3200-branch.sh | 38 ++++++++++++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 6 deletions(-)
Documentation/git-branch.adoc +7 -1
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> git branch (-c|-C) [<old-branch>] <new-branch> git branch (-d|-D) [-r] <branch-name>... git branch --edit-description [<branch-name>] -git branch (--delete-merged <pattern>)... [<branch-pattern>...] +git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...] DESCRIPTION -----------
@@ -235,6 +235,12 @@ A branch whose work has not yet been merged into its upstream is silently skipped. Delete it with `git branch -D` if you want to remove it anyway. +`--dry-run`:: + With `--delete-merged`, print which branches would be + deleted and exit without touching any ref. Useful for + sanity-checking a wide pattern like `'origin/*'` before + committing to the deletion. + `-v`:: `-vv`:: `--verbose`::
builtin/branch.c +19 -4
@@ -200,6 +200,7 @@ enum delete_branch_flags {DELETE_BRANCH_QUIET = (1 <<1 ),DELETE_BRANCH_SKIP_UNMERGED = (1 <<2 ),DELETE_BRANCH_NO_HEAD_FALLBACK = (1 <<3 ), + DELETE_BRANCH_DRY_RUN = (1 << 4), }; static int check_branch_commit(const char *branchname, const char *refname,
@@ -342,13 +343,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 (!(flags &DELETE_BRANCH_DRY_RUN ) && +refs_delete_refs (get_main_ref_store (the_repository),NULL , &refs_to_delete,REF_NO_DEREF )) ret =1 ;for_each_string_list_item (item, &refs_to_delete) {char *describe_ref = item->util;char *name = item->string; -if (!refs_ref_exists (get_main_ref_store (the_repository), name)) { +if (flags &DELETE_BRANCH_DRY_RUN ) { +if (!(flags &DELETE_BRANCH_QUIET )) +printf (remote_branch + ?_ ("Would delete remote-tracking branch %s (was %s).\n" ) + :_ ("Would delete branch %s (was %s).\n" ), + name + branch_name_pos, describe_ref); + }else if (!refs_ref_exists (get_main_ref_store (the_repository), name)) {char *refname = name + branch_name_pos;if (!(flags &DELETE_BRANCH_QUIET )) printf(remote_branch
@@ -882,7 +890,7 @@ static int delete_merged_branches(const struct strvec *upstreams,DELETE_BRANCH_NO_HEAD_FALLBACK | flags); -if (!ret) +if (!ret && !(flags &DELETE_BRANCH_DRY_RUN ))clear_deleted_upstreams (&protected_branch_names, &deletable_branch_names);
@@ -960,6 +968,7 @@ int cmd_branch(int argc,int delete =0 , rename =0 , copy =0 , list =0 , unset_upstream =0 , show_current =0 , edit_description =0 ;struct strvec delete_merged =STRVEC_INIT ; +int dry_run =0 ;const char *new_upstream =NULL ;int noncreate_actions =0 ;/* possible options */
@@ -1016,6 +1025,8 @@ int cmd_branch(int argc,OPT_CALLBACK_F (0 ,"delete-merged" , &delete_merged,N_ ("pattern" ),N_ ("delete merged branches whose upstream matches <pattern> (repeatable)" ),PARSE_OPT_NONEG , parse_opt_strvec), +OPT_BOOL (0 ,"dry-run" , &dry_run, +N_ ("with --delete-merged, only print which branches would be deleted" )),OPT__FORCE (&force,N_ ("force creation, move/rename, deletion" ),PARSE_OPT_NOCOMPLETE ),OPT_MERGED (&filter,N_ ("print only branches that are merged" )), OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1078,6 +1089,9 @@ int cmd_branch(int argc,if (noncreate_actions >1 )usage_with_options (builtin_branch_usage, options); +if (dry_run && !delete_merged.nr) +die (_ ("--dry-run requires --delete-merged" )); +if (recurse_submodules_explicit) {if (!submodule_propagate_branches)die (_ ("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled" ));
@@ -1118,7 +1132,8 @@ int cmd_branch(int argc, goto out; }else if (delete_merged.nr) { ret =delete_merged_branches (&delete_merged, argv, - quiet ?DELETE_BRANCH_QUIET :0 ); + (quiet ?DELETE_BRANCH_QUIET :0 ) | + (dry_run ?DELETE_BRANCH_DRY_RUN :0 )); goto out; }else if (show_current) {print_current_branch_name ();
t/t3200-branch.sh +37 -1
@@ -1938,6 +1938,19 @@ test_expect_success '--delete-merged deletes only selected merged branches' ' git checkout -b tracks-other --track other/main && sha=$(git rev-parse --short merged) && + git branch --dry-run --delete-merged origin/next merged >actual 2>&1 && + echo "Would delete branch merged (was $sha)." >expect && + test_cmp expect actual && + git rev-parse --verify refs/heads/merged && + + check_branches <<-\EOF && + also-merged + main + merged + tracks-other + unmerged + EOF + git branch --delete-merged origin/next merged >actual 2>&1 && echo "Deleted branch merged (was $sha)." >expect && test_cmp expect actual &&
@@ -2003,9 +2016,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' ' git checkout -b topic --track feature && git commit --allow-empty -m "topic work" && - git branch --delete-merged origin/next 2>err && + git branch --dry-run --delete-merged origin/next >out && + test_grep ! "feature" out && + git branch --delete-merged origin/next 2>err && test_must_be_empty err && + check_branches <<-\EOF && feature main
@@ -2033,6 +2049,21 @@ test_expect_success '--delete-merged clears the deleted upstream of a protected git commit --allow-empty -m "tip work" && sha=$(git rev-parse --short lower) && + git branch --dry-run --delete-merged origin/next --delete-merged lower >actual 2>&1 && + echo "Would delete branch lower (was $sha)." >expect && + test_cmp expect actual && + + git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual && + cat >expect <<-\EOF && + branch.lower.remote origin + branch.lower.merge refs/heads/next + branch.mid.remote . + branch.mid.merge refs/heads/lower + branch.tip.remote . + branch.tip.merge refs/heads/mid + EOF + test_cmp expect actual && + git branch --delete-merged origin/next --delete-merged lower >actual 2>&1 && echo "Deleted branch lower (was $sha)." >expect && test_cmp expect actual &&
@@ -2120,4 +2151,9 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" ' ) ' +test_expect_success '--dry-run without --delete-merged is rejected' ' + test_must_fail git -C forked branch --dry-run 2>err && + test_grep "requires --delete-merged" err +' + test_done
Signature · 1 line
gitgitgadget
Re: [PATCH v24 0/7] branch: delete-merged
In reply to Harald Nordgren via GitGitGadgetHi Harald
Quoted 10 lines · Harald Nordgren via GitGitGadget
Changes in v24:
* Map branches through the configured push and fetch refspecs before deciding whether pushing would update their upstream.
* Protect only the direct upstream of surviving work, delete deeper merged bases and clear stale upstream configuration.
* Clarify that --delete-merged takes upstream patterns in docs.Range-diff vs v23:
Quoted 8 lines
1: 0c4fe549cb ! 1: 59157142b3 branch: add --forked filter for --list mode
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked composes with --no-merged' '
-+ git -C forked branch --forked "origin/*" --no-merged origin/one \
-+ --format="%(refname:short)" >actual &&
++ git -C forked branch --forked "origin/*" --no-merged origin/one --format="%(refname:short)" >actual &&We wrap our lines at 80 columns. I know I suggested unwrapping some very short lines in another patch, but these are far too long.
Quoted 25 lines
+ echo local-one >expect && + test_cmp expect actual +' @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' ' +' + +test_expect_success '--forked narrows a <pattern> argument' ' -+ git -C forked branch --forked "origin/*" "local-*" \ -+ --format="%(refname:short)" >actual && ++ git -C forked branch --forked "origin/*" "local-*" --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-one + local-two2:
2d20015ba9! 2:5d310da9aabranch: convertdelete_branches()to a flags argument
@@builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
- int flags = 0;
+ intref_flags= 0;-
copy_branchname(&bname, argv[i], allowed_interpret);
- free(name);
+ copy_branchname(the_repository, &bname,
+ argv[i],allowed_interpret);
@@builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
RESOLVE_REF_READING
|RESOLVE_REF_NO_RECURSE
Quoted 4 lines
3: 34b37aeb43 = 3: 263e90f6ed branch: let delete_branches skip unmerged branches on bulk refusal 4: 606ce4082c = 4: 22e31fd35c branch: prepare delete_branches for a bulk caller 5: 5fd74f0050 ! 5: d9f93e66c9 branch: add --delete-merged <branch>[...]
As far as I can see, from here ...
Quoted 101 lines
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+struct stacked_branch_data {
+ struct strset *deletable_branch_names;
+ struct strset *protected_branch_names;
-+ struct strset *visited_branch_names;
+};
+
-+static int collect_stacked_branch_bases(const struct reference *ref,
-+ void *cb_data)
++static int collect_stacked_branch_base(const struct reference *ref,
++ void *cb_data)
+{
+ struct stacked_branch_data *data = cb_data;
+ const char *branch_name;
++ struct branch *branch;
++ const char *upstream_refname;
++ const char *upstream_branch_name;
+
+ if (!skip_prefix(ref->name, "refs/heads/", &branch_name))
+ BUG("expected local branch ref, got '%s'", ref->name);
+ if (strset_contains(data->deletable_branch_names, branch_name))
+ return 0;
+
-+ while (strset_add(data->visited_branch_names, branch_name)) {
-+ struct branch *branch = branch_get(branch_name);
-+ const char *upstream_refname = branch_get_upstream(branch, NULL);
-+ const char *upstream_branch_name;
-+
-+ if (!upstream_refname ||
-+ !skip_prefix(upstream_refname, "refs/heads/",
-+ &upstream_branch_name) ||
-+ !strset_contains(data->deletable_branch_names,
-+ upstream_branch_name))
-+ break;
-+
-+ strset_add(data->protected_branch_names, upstream_branch_name);
-+ branch_name = upstream_branch_name;
-+ }
++ branch = branch_get(branch_name);
++ upstream_refname = branch_get_upstream(branch, NULL);
++ if (!upstream_refname ||
++ !skip_prefix(upstream_refname, "refs/heads/",
++ &upstream_branch_name) ||
++ !strset_contains(data->deletable_branch_names,
++ upstream_branch_name))
++ return 0;
+
++ strset_add(data->protected_branch_names, upstream_branch_name);
+ return 0;
+}
+
+static void protect_stacked_branch_bases(struct ref_store *refs,
-+ struct strset *deletable_branch_names)
++ struct strset *deletable_branch_names,
++ struct strset *protected_branch_names)
+{
-+ struct strset protected_branch_names = STRSET_INIT;
-+ struct strset visited_branch_names = STRSET_INIT;
+ struct stacked_branch_data data = {
+ .deletable_branch_names = deletable_branch_names,
-+ .protected_branch_names = &protected_branch_names,
-+ .visited_branch_names = &visited_branch_names,
++ .protected_branch_names = protected_branch_names,
+ };
+ struct refs_for_each_ref_options opts = {
+ .prefix = "refs/heads/",
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+
-+ refs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);
++ refs_for_each_ref_ext(refs, collect_stacked_branch_base, &data, &opts);
+
-+ strset_for_each_entry(&protected_branch_names, &iter, entry)
++ strset_for_each_entry(protected_branch_names, &iter, entry)
+ strset_remove(deletable_branch_names, entry->key);
++}
+
-+ strset_clear(&visited_branch_names);
-+ strset_clear(&protected_branch_names);
++static void clear_deleted_upstreams(struct strset *protected_branch_names,
++ struct strset *deletable_branch_names)
++{
++ struct strbuf key = STRBUF_INIT;
++ struct hashmap_iter iter;
++ struct strmap_entry *entry;
++
++ strset_for_each_entry(protected_branch_names, &iter, entry) {
++ struct branch *branch = branch_get(entry->key);
++ const char *upstream_refname = branch_get_upstream(branch, NULL);
++ const char *upstream_branch_name;
++
++ if (!upstream_refname ||
++ !skip_prefix(upstream_refname, "refs/heads/",
++ &upstream_branch_name) ||
++ !strset_contains(deletable_branch_names,
++ upstream_branch_name))
++ continue;
++
++ strbuf_addf(&key, "branch.%s.merge", branch->name);
++ repo_config_set_gently(the_repository, key.buf, NULL);Quoted 8 lines
++ strbuf_reset(&key);
++ strbuf_addf(&key, "branch.%s.remote", branch->name);
++ repo_config_set_gently(the_repository, key.buf, NULL);
++ strbuf_reset(&key);
++ }
++
++ strbuf_release(&key);
+}... to here pretty much takes us back to V19 with the difference that we only prune protected branches from the deleteable set after we've visited all the branches - looks good.
Quoted 15 lines
+static int branch_pushes_to_upstream(struct branch *branch,
+ const char *upstream)
+{
+ struct remote *remote = remote_get(remote_for_branch(branch, NULL));
++ char *push_refname = NULL;
+ char *tracking = NULL;
+ int ret = 0;
+
-+ if (remote)
-+ tracking = apply_refspecs(&remote->fetch, branch->refname);
++ if (!remote)
++ return 0;
++ if (remote->push.nr)
++ push_refname = apply_refspecs(&remote->push, branch->refname);
++ elseQuoted 8 lines
++ push_refname = xstrdup(branch->refname);
++ if (push_refname)
++ tracking = apply_refspecs(&remote->fetch, push_refname);
+ if (tracking && !strcmp(tracking, upstream))
+ ret = 1;
++ free(push_refname);> + free(tracking);
+ return ret;
+}This looks good now - we map the branch name with the push refspec if there is one and then with the fetch refspec.
The test changes below look good as well.
Apart from the overlong lines in patch 1 which I'm not sure are worth a re-roll on their own, this looks ready to be merged to me
Thanks
Phillip
Quoted 317 lines
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' ' +create_merged_branch () { + ( + cd repo && -+ git checkout -b "$1" origin/next --track && ++ git checkout -b "$1" --track origin/next && + git commit --allow-empty -m "$1 work" && + git push origin "$1:next" + ) @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' ' + test_cmp expect actual +} + -+test_expect_success '--delete-merged keeps cloned main without a default push remote' ' ++test_expect_success '--delete-merged keeps cloned main without explicit push configuration' ' + setup_repo_for_delete_merged && + ( + cd repo && ++ test_cmp_config origin branch.main.remote && ++ test_cmp_config refs/heads/main branch.main.merge && + git checkout --detach && + + git branch --delete-merged */* && @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' ' + create_merged_branch merged && + ( + cd repo && -+ git checkout -b unmerged origin/next --track && ++ git checkout -b unmerged --track origin/next && + git commit --allow-empty -m "unmerged work" && -+ git checkout -b tracks-other other/main --track && ++ git checkout -b tracks-other --track other/main && + sha=$(git rev-parse --short merged) && + + git branch --delete-merged origin/next merged >actual 2>&1 && @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' ' + ( + cd repo && + git config remote.pushDefault fork && -+ git checkout -b local-to-delete main --track && -+ git update-ref refs/remotes/origin/topic refs/remotes/origin/next && -+ git branch --set-upstream-to=origin/topic upstream-gone && -+ git update-ref -d refs/remotes/origin/topic && -+ git checkout -b tracks-other other/main --track && ++ git checkout -b local-to-delete --track main && ++ git config branch.upstream-gone.merge refs/heads/topic && ++ git checkout -b tracks-other --track other/main && + git checkout checked-out && + -+ git branch --delete-merged origin/* \ -+ --delete-merged main && ++ git branch --delete-merged origin/* --delete-merged main && + + check_branches <<-\EOF + checked-out @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' ' + ) +' + ++test_expect_success '--delete-merged maps push refspecs to upstreams' ' ++ setup_repo_for_delete_merged && ++ ( ++ cd repo && ++ git checkout -b topic && ++ git config remote.origin.push "refs/heads/topic:refs/heads/published" && ++ git push origin && ++ git branch --set-upstream-to=origin/published topic && ++ git checkout -b other-topic --track origin/published && ++ git checkout --detach && ++ ++ git branch --delete-merged origin/published && ++ ++ check_branches <<-\EOF ++ main ++ topic ++ EOF ++ ) ++' ++ +test_expect_success '--delete-merged keeps the upstream of a surviving branch' ' + setup_repo_for_delete_merged && + create_merged_branch feature && + ( + cd repo && -+ git checkout -b topic feature --track && ++ git checkout -b topic --track feature && + git commit --allow-empty -m "topic work" && + + git branch --delete-merged origin/next 2>err && @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' ' + ) +' + -+test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' ' ++test_expect_success '--delete-merged clears the deleted upstream of a protected branch' ' + setup_repo_for_delete_merged && + ( + cd repo && -+ git config remote.pushDefault fork && -+ git branch lower origin/next --track && -+ git branch mid lower --track && -+ git checkout -b tip mid --track && ++ git branch --track lower origin/next && ++ git branch --track mid lower && ++ git checkout -b tip --track mid && + git commit --allow-empty -m "tip work" && ++ sha=$(git rev-parse --short lower) && + -+ git branch --delete-merged origin/next \ -+ --delete-merged lower >actual 2>&1 && -+ test_must_be_empty actual && ++ git branch --delete-merged origin/next --delete-merged lower >actual 2>&1 && ++ echo "Deleted branch lower (was $sha)." >expect && ++ test_cmp expect actual && + + check_branches <<-\EOF && -+ lower + main + mid + tip + EOF + -+ git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual && ++ git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual && + cat >expect <<-\EOF && -+ branch.lower.remote origin -+ branch.lower.merge refs/heads/next -+ branch.mid.remote . -+ branch.mid.merge refs/heads/lower + branch.tip.remote . + branch.tip.merge refs/heads/mid + EOF @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' ' + setup_repo_for_delete_merged && + ( + cd repo && -+ git branch c-lower origin/next --track && -+ git branch b-mid c-lower --track && -+ git checkout -b a-tip b-mid --track && ++ git branch --track c-lower origin/next && ++ git branch --track b-mid c-lower && ++ git checkout -b a-tip --track b-mid && + git commit --allow-empty -m "tip work" && + -+ git branch --delete-merged origin/next \ -+ --delete-merged "c-*" && ++ git branch --delete-merged origin/next --delete-merged "c-*" && + + check_branches <<-\EOF && + a-tip + b-mid -+ c-lower + main + EOF + -+ git branch --delete-merged origin/next \ -+ --delete-merged "c-*" >actual 2>&1 && ++ git branch --delete-merged origin/next --delete-merged "c-*" >actual 2>&1 && + test_must_be_empty actual && + + check_branches <<-\EOF + a-tip + b-mid -+ c-lower + main + EOF + )6:
6494be7c75! 6:aa845096d8branch: add branch.<name>.deleteMerged opt-out
@@Documentation/config/branch.adoc: for details).##
Documentation/git-branch.adoc##
@@Documentation/git-branch.adoc: A branch is not deleted when:
- +
- --
- * its configured upstream ref no longer exists,
--* it is checked out in any worktree, or
-+* it is checked out in any worktree,
- * pushing it by name to the remote configured by
-branch.<name>.remotewould update its upstream, so it cannot be
- distinguished from a branch that just looks "fully merged" right
-- after a pull.
-+ after a pull, or
+ distinguished from a branch that just looks fully merged right
+ after a pull; this is determined by the remote's configured push and
+ fetch refspecs,
+-* it is the local upstream of a branch that is not being deleted. ++* it is the local upstream of a branch that is not being deleted, or
+*branch.<name>.deleteMergedis set tofalse.
--
+
- A branch whose work has not yet been merged into its upstream is
+ When such a local upstream branch has its own upstream deleted by the##
builtin/branch.c##
@@builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
- structref_arraycandidates = { 0 };
struct strsetdeletable_branch_names=STRSET_INIT;
+ struct strsetprotected_branch_names=STRSET_INIT;
struct strvecbranches_to_delete=STRVEC_INIT;
+ struct strbuf key =STRBUF_INIT;
structhashmap_iteriter;
@@builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
}@@
builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
-DELETE_BRANCH_NO_HEAD_FALLBACK|
- flags);
+ clear_deleted_upstreams(&protected_branch_names,
+&deletable_branch_names);+
strbuf_release(&key);
strvec_clear(&branches_to_delete);
+strset_clear(&protected_branch_names);
strset_clear(&deletable_branch_names);
-ref_array_clear(&candidates);##
t/t3200-branch.sh##
@@t/t3200-branch.sh:test_expect_success'--delete-merged requires a value' '
7:7102c931e2! 7:96ebfd6ec6branch: add --dry-run for --delete-merged
@@Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
--git branch (--delete-merged <branch>)... [<pattern>...]
-+git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...] +-git branch (--delete-merged <pattern>)... [<branch-pattern>...] ++git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]DESCRIPTION
-----------
-@@Documentation/git-branch.adoc: A branch that a surviving branch depends on through a chain of local
- upstreams is kept, so a branch is never deleted out from under stacked
- work.
+@@Documentation/git-branch.adoc: A branch whose work has not yet been merged into its upstream is
+ silently skipped. Delete it withgit branch -Dif you want to
+ remove it anyway.+
--dry-run::
+ With--delete-merged, print which branches would be
@@builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki
char *refname = name +branch_name_pos;
if (!(flags &DELETE_BRANCH_QUIET))
printf(remote_branch
+@@builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
+DELETE_BRANCH_NO_HEAD_FALLBACK|
+ flags);
+
+- if (!ret)
++ if (!ret && !(flags &DELETE_BRANCH_DRY_RUN))
+ clear_deleted_upstreams(&protected_branch_names,
+&deletable_branch_names);
+
@@builtin/branch.c: int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream= 0,show_current= 0,edit_description= 0;
@@builtin/branch.c: int cmd_branch(int argc,
intnoncreate_actions= 0;
/* possible options */
@@builtin/branch.c: int cmd_branch(int argc,
- OPT_CALLBACK_F(0, "delete-merged",&delete_merged,N_("branch"),
-N_("delete merged branches whose upstream matches <branch> (repeatable)"),
+ OPT_CALLBACK_F(0, "delete-merged",&delete_merged,N_("pattern"),
+N_("delete merged branches whose upstream matches <pattern> (repeatable)"),
PARSE_OPT_NONEG,parse_opt_strvec),
+ OPT_BOOL(0, "dry-run",&dry_run,
+N_("with --delete-merged, only print which branches would be deleted")),
@@builtin/branch.c: int cmd_branch(int argc,##
t/t3200-branch.sh##
@@t/t3200-branch.sh:test_expect_success'--delete-merged deletes only selected merged branches' '
- git checkout -b tracks-other other/main --track &&
+ git checkout -b tracks-other --track other/main &&
sha=$(git rev-parse --short merged) &&+ git branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&
@@t/t3200-branch.sh:test_expect_success'--delete-merged deletes only selected me
echo "Deleted branch merged (was $sha)." >expect &&
test_cmpexpect actual &&
@@t/t3200-branch.sh:test_expect_success'--delete-merged keeps the upstream of a surviving branch' '
- git checkout -b topic feature --track &&
+ git checkout -b topic --track feature &&
git commit --allow-empty -m "topic work" &&- git branch --delete-merged origin/next 2>err &&
@@t/t3200-branch.sh:test_expect_success'--delete-merged keeps the upstream of a
check_branches<<-\EOF &&
feature
main
-@@t/t3200-branch.sh:test_expect_success'--delete-merged keeps the upstream chain of a surviving bra
- git checkout -b tip mid --track &&
+@@t/t3200-branch.sh:test_expect_success'--delete-merged clears the deleted upstream of a protected
git commit --allow-empty -m "tip work" &&
+ sha=$(git rev-parse --short lower) &&-+ git branch --dry-run --delete-merged origin/next \
-+ --delete-merged lower >actual 2>&1 &&
-+test_must_be_emptyactual &&
++ git branch --dry-run --delete-merged origin/next --delete-merged lower >actual 2>&1 &&
++ echo "Would delete branch lower (was $sha)." >expect &&
++test_cmpexpect actual &&
+
+ git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual &&
+ cat >expect <<-\EOF &&
@@t/t3200-branch.sh:test_expect_success'--delete-merged keeps the upstream chain
+ EOF
+test_cmpexpect actual &&
+
- git branch --delete-merged origin/next \
- --delete-merged lower >actual 2>&1 &&
-test_must_be_emptyactual &&
+ git branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&
+ echo "Deleted branch lower (was $sha)." >expect &&
+test_cmpexpect actual &&
@@t/t3200-branch.sh:test_expect_success"branch -d still deletes a deleteMerged=false branch" '
)
'
Re: [PATCH v24 0/7] branch: delete-merged
Thanks!
Harald
Re: [PATCH v24 0/7] branch: delete-merged
In reply to Phillip WoodQuoted 35 lines · Phillip Wood
As far as I can see, from here ...
Quoted 1 line
@@
builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch...
Quoted 2 lines
++ strbuf_release(&key); +}... to here pretty much takes us back to V19 with the difference that we only prune protected branches from the deleteable set after we've visited all the branches - looks good.
Quoted 15 lines
+static int branch_pushes_to_upstream(struct branch *branch, + const char *upstream) +{ + struct remote *remote = remote_get(remote_for_branch(branch, NULL)); ++ char *push_refname = NULL; + char *tracking = NULL; + int ret = 0; + -+ if (remote) -+ tracking = apply_refspecs(&remote->fetch, branch->refname); ++ if (!remote) ++ return 0; ++ if (remote->push.nr) ++ push_refname = apply_refspecs(&remote->push, branch->refname); ++ elseQuoted 8 lines
++ push_refname = xstrdup(branch->refname); ++ if (push_refname) ++ tracking = apply_refspecs(&remote->fetch, push_refname); + if (tracking && !strcmp(tracking, upstream)) + ret = 1; ++ free(push_refname);> + free(tracking); + return ret; +}
Quoted 4 lines
This looks good now - we map the branch name with the push refspec if there is one and then with the fetch refspec.
The test changes below look good as well.
OK.
I'd mark the topic as "expecting a (hopefully small and final) reroll", perhaps.
Thanks.