From 876029beca8495a44e3b6c335b7fac5208e8da86 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 5 Aug 2026 14:24:31 +0000 Subject: [PATCH 01/25] branch: add --forked filter for --list mode Add a --forked option to "git branch" list mode that lists only branches whose configured upstream matches . The argument can be a ref (e.g. "origin/main", "master"), 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. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-branch.adoc | 12 +++- builtin/branch.c | 18 ++++- ref-filter.c | 70 +++++++++++++++++++ ref-filter.h | 10 +++ t/t3200-branch.sh | 127 ++++++++++++++++++++++++++++++++++ 5 files changed, 234 insertions(+), 3 deletions(-) diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index c0afddc424d610..b0d66a6deb8b8c 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -13,6 +13,7 @@ git branch [--color[=] | --no-color] [--show-current] [--column[=] | --no-column] [--sort=] [--merged []] [--no-merged []] [--contains []] [--no-contains []] + [(--forked )...] [--points-at ] [--format=] [(-r|--remotes) | (-a|--all)] [--list] [...] @@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are reachable from the named commit) will be listed. With `--no-merged` only branches not merged into the named commit will be listed. If the __ argument is missing it defaults to `HEAD` (i.e. the tip of the current -branch). +branch). With `--forked`, only branches whose configured upstream matches +the given branch or pattern will be listed. The command's second form creates a new branch head named __ which points to the current `HEAD`, or __ if given. As a @@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main". Only list branches whose tips are not reachable from __ (`HEAD` if not specified). Implies `--list`. +`--forked `:: + Only list branches whose configured upstream matches + __. The argument can be a ref (e.g. `origin/main`, + `master`), 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 `:: Only list branches of __. diff --git a/builtin/branch.c b/builtin/branch.c index 031a4a9d055558..1ab4356188fc1e 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -30,7 +30,7 @@ #include "commit-reach.h" static const char * const builtin_branch_usage[] = { - N_("git branch [] [-r | -a] [--merged] [--no-merged]"), + N_("git branch [] [-r | -a] [--merged] [--no-merged] [(--forked )...]"), N_("git branch [] [-f] [--recurse-submodules] []"), N_("git branch [] [-l] [...]"), N_("git branch [] [-r] (-d | -D) ..."), @@ -674,6 +674,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int free_worktrees(worktrees); } +static int parse_opt_forked(const struct option *opt, const char *arg, int unset) +{ + struct ref_filter *filter = opt->value; + + BUG_ON_OPT_NEG(unset); + if (ref_filter_forked_add(filter, arg) < 0) + die(_("'%s' is not a valid branch or pattern"), arg); + return 0; +} + static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION") static int edit_branch_description(const char *branch_name) @@ -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 (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 + diff --git a/ref-filter.c b/ref-filter.c index 29aca08ce7b333..bdf54f6f592499 100644 --- a/ref-filter.c +++ b/ref-filter.c @@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname) return match_pattern(filter->exclude.v, refname, filter->ignore_case); } +static const char *short_upstream_name(const char *full_ref) +{ + const char *short_name = full_ref; + + if (!skip_prefix(short_name, "refs/heads/", &short_name)) + skip_prefix(short_name, "refs/remotes/", &short_name); + return short_name; +} + +/* + * Match the configured upstream of a branch against the registered + * --forked patterns. Exact patterns are compared against the full + * upstream refname so they are unambiguous; glob patterns are matched + * against the abbreviated upstream so that a glob such as origin/... + * works as typed. + */ +static int filter_forked_match(struct ref_filter *filter, const char *refname) +{ + const char *short_name; + struct branch *branch; + const char *upstream; + + 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 (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), + WM_PATHNAME)) + return 1; + } else if (!strcmp(pattern, upstream)) { + return 1; + } + } + return 0; +} + +int ref_filter_forked_add(struct ref_filter *filter, const char *arg) +{ + struct object_id oid; + char *full_ref = NULL; + + if (has_glob_specials(arg)) { + strvec_push(&filter->forked, arg); + return 0; + } + + if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid, + &full_ref, 0) == 1 && + (starts_with(full_ref, "refs/heads/") || + starts_with(full_ref, "refs/remotes/"))) { + strvec_push(&filter->forked, full_ref); + free(full_ref); + return 0; + } + free(full_ref); + return -1; +} + /* * We need to seek to the reference right after a given marker but excluding any * matching references. So we seek to the lexicographically next reference. @@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref, if (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name)) return NULL; + if (filter->forked.nr && !filter_forked_match(filter, ref->name)) + return NULL; + /* * A merge filter is applied on refs pointing to commits. Hence * obtain the commit using the 'oid' available and discard all @@ -3764,6 +3833,7 @@ void ref_filter_init(struct ref_filter *filter) void ref_filter_clear(struct ref_filter *filter) { strvec_clear(&filter->exclude); + strvec_clear(&filter->forked); oid_array_clear(&filter->points_at); commit_list_free(filter->with_commit); commit_list_free(filter->no_commit); diff --git a/ref-filter.h b/ref-filter.h index 120221b47fa30d..9361296e2a7440 100644 --- a/ref-filter.h +++ b/ref-filter.h @@ -67,6 +67,7 @@ struct ref_filter { const char **name_patterns; const char *start_after; struct strvec exclude; + struct strvec forked; struct oid_array points_at; struct commit_list *with_commit; struct commit_list *no_commit; @@ -110,6 +111,7 @@ struct ref_format { #define REF_FILTER_INIT { \ .points_at = OID_ARRAY_INIT, \ .exclude = STRVEC_INIT, \ + .forked = STRVEC_INIT, \ } #define REF_FORMAT_INIT { \ .use_color = GIT_COLOR_UNKNOWN, \ @@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *); struct ref_sorting *ref_sorting_options(struct string_list *); /* Function to parse --merged and --no-merged options */ int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset); +/* + * Register a --forked pattern on the filter. The argument is + * either a ref, which is resolved to its full refname, or a shell-style + * glob. Branches are kept only when their configured upstream matches + * one of the registered patterns. Returns -1 if the argument is not a + * valid ref or pattern. + */ +int ref_filter_forked_add(struct ref_filter *filter, const char *arg); /* Get the current HEAD's description */ char *get_head_description(void); /* Set up translated strings in the output. */ diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index 1ecbafbee18e03..84940951651e67 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1755,4 +1755,131 @@ test_expect_success 'errors if given a bad branch name' ' test_cmp expect actual ' +test_expect_success '--forked: setup' ' + test_create_repo forked-upstream && + ( + cd forked-upstream && + test_commit base && + git branch one base && + git branch two base + ) && + + test_create_repo forked-other && + ( + cd forked-other && + test_commit other-base && + git branch foreign other-base + ) && + + git clone forked-upstream forked && + ( + cd forked && + git remote add -f other ../forked-other && + git branch local-base && + git branch --track local-one origin/one && + git branch --track local-two origin/two && + git branch --track local-foreign other/foreign && + git branch --track local-onbase local-base && + + git checkout local-one && + test_commit --no-tag local-one-work local-one.t && + git checkout local-foreign && + test_commit --no-tag local-foreign-work local-foreign.t + ) +' + +test_expect_success '--forked filters by upstream' ' + git -C forked branch --forked origin/one \ + --format="%(refname:short)" >actual && + echo local-one >expect && + test_cmp expect actual +' + +test_expect_success '--forked filters by wildmatch' ' + git -C forked branch --forked "origin/*" \ + --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-one + local-two + main + EOF + test_cmp expect actual +' + +test_expect_success '--forked matches branches with local upstream' ' + git -C forked branch --forked local-base \ + --format="%(refname:short)" >actual && + echo local-onbase >expect && + test_cmp expect actual +' + +test_expect_success '--forked can be repeated to widen the filter' ' + git -C forked branch --forked origin/one \ + --forked other/foreign \ + --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-foreign + local-one + EOF + test_cmp expect actual +' + +test_expect_success '--forked combines literal and glob arguments' ' + git -C forked branch --forked local-base \ + --forked "other/*" \ + --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-foreign + local-onbase + EOF + test_cmp expect actual +' + +test_expect_success '--forked "*/*" covers every remote-tracking upstream' ' + git -C forked branch --forked "*/*" \ + --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-foreign + local-one + local-two + main + EOF + test_cmp expect actual +' + +test_expect_success '--forked composes with --no-merged' ' + git -C forked branch --forked "origin/*" \ + --no-merged origin/one \ + --format="%(refname:short)" >actual && + echo local-one >expect && + test_cmp expect actual +' + +test_expect_success '--forked uses the branch /HEAD points at' ' + git -C forked branch --forked origin \ + --format="%(refname:short)" >actual && + echo main >expect && + test_cmp expect actual +' + +test_expect_success '--forked narrows a argument' ' + git -C forked branch --forked "origin/*" "local-*" \ + --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-one + local-two + EOF + test_cmp expect actual +' + +test_expect_success '--forked rejects unknown branch/pattern' ' + test_must_fail git -C forked branch --forked nope 2>err && + test_grep "not a valid branch or pattern" err +' + +test_expect_success '--forked requires a value' ' + test_must_fail git -C forked branch --forked 2>err && + test_grep "requires a value" err +' + test_done From 7969faaf9b4e6e6cfff85ba9a0c592971264596b Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 5 Aug 2026 14:24:32 +0000 Subject: [PATCH 02/25] branch: convert delete_branches() to a flags argument 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. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/branch.c | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/builtin/branch.c b/builtin/branch.c index 1ab4356188fc1e..db7cb011901a42 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -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(); From cdbcde91be1a4110a6d75b3f24fa2767c06187f6 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 5 Aug 2026 14:24:33 +0000 Subject: [PATCH 03/25] branch: let delete_branches skip unmerged branches on bulk refusal 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. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/branch.c | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/builtin/branch.c b/builtin/branch.c index db7cb011901a42..c44f710a48eb98 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -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; } From 5e528a36d243f6152dbc5e04ab2314b32f30efcb Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 5 Aug 2026 14:24:34 +0000 Subject: [PATCH 04/25] branch: prepare delete_branches for a bulk caller 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. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/branch.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/builtin/branch.c b/builtin/branch.c index c44f710a48eb98..7b0aa685728ea5 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name, * upstream, if any, otherwise with HEAD", we should just * return the result of the repo_in_merge_bases() above without * any of the following code, but during the transition period, - * a gentle reminder is in order. + * a gentle reminder is in order. Callers that opt out of the + * HEAD fallback by passing head_rev=NULL are not interested in + * the reminder either: they have already established that the + * branch has an upstream, so HEAD is irrelevant to the decision. */ - if (head_rev != reference_rev) { - int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0; + if (head_rev && head_rev != reference_rev) { + int expect = repo_in_merge_bases(the_repository, rev, head_rev); if (expect < 0) exit(128); if (expect == merged) @@ -193,6 +196,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)) { From 15bcdf43bbe2206998f7c044bd44479d47749809 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 5 Aug 2026 14:24:35 +0000 Subject: [PATCH 05/25] branch: add --delete-merged git branch (--delete-merged )... [...] 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 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..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. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-branch.adoc | 33 +++++ builtin/branch.c | 195 +++++++++++++++++++++++++++++- t/t3200-branch.sh | 219 ++++++++++++++++++++++++++++++++++ 3 files changed, 445 insertions(+), 2 deletions(-) diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index b0d66a6deb8b8c..47661782045e99 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -25,6 +25,7 @@ git branch (-m|-M) [] git branch (-c|-C) [] git branch (-d|-D) [-r] ... git branch --edit-description [] +git branch (--delete-merged )... [...] 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 `:: + Delete local branches whose configured upstream matches + __, 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. __ 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 __ 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..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`:: diff --git a/builtin/branch.c b/builtin/branch.c index 7b0aa685728ea5..f1a73bcea1cee1 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -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" @@ -38,6 +39,8 @@ static const char * const builtin_branch_usage[] = { N_("git branch [] (-c | -C) [] "), N_("git branch [] [-r | -a] [--points-at]"), N_("git branch [] [-r | -a] [--format]"), + N_("git branch [] (--delete-merged )... " + "[...]"), NULL }; @@ -700,6 +703,184 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset return 0; } +struct stacked_branch_data { + struct strset *deletable_branch_names; + struct strset *protected_branch_names; +}; + +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; + + 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 *protected_branch_names) +{ + struct stacked_branch_data data = { + .deletable_branch_names = deletable_branch_names, + .protected_branch_names = protected_branch_names, + }; + struct refs_for_each_ref_options opts = { + .prefix = "refs/heads/", + }; + struct hashmap_iter iter; + struct strmap_entry *entry; + + refs_for_each_ref_ext(refs, collect_stacked_branch_base, &data, &opts); + + strset_for_each_entry(protected_branch_names, &iter, entry) + strset_remove(deletable_branch_names, entry->key); +} + +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) + 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; +} + +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 strset protected_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)) + 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); + } + + 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); + + 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); + + 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); + return ret; +} + static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION") static int edit_branch_description(const char *branch_name) @@ -764,6 +945,7 @@ int cmd_branch(int argc, /* possible actions */ int delete = 0, rename = 0, copy = 0, list = 0, unset_upstream = 0, show_current = 0, edit_description = 0; + struct strvec delete_merged = STRVEC_INIT; const char *new_upstream = NULL; int noncreate_actions = 0; /* possible options */ @@ -817,6 +999,9 @@ 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_("pattern"), + N_("delete merged branches whose upstream matches (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")), @@ -864,7 +1049,8 @@ int cmd_branch(int argc, 0); if (!delete && !rename && !copy && !edit_description && !new_upstream && - !show_current && !unset_upstream && argc == 0) + !show_current && !unset_upstream && !delete_merged.nr && + argc == 0) list = 1; if (filter.with_commit || filter.no_commit || @@ -874,7 +1060,7 @@ int cmd_branch(int argc, noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream + !!show_current + !!list + !!edit_description + - !!unset_upstream; + !!unset_upstream + !!delete_merged.nr; if (noncreate_actions > 1) usage_with_options(builtin_branch_usage, options); @@ -916,6 +1102,10 @@ int cmd_branch(int argc, (delete > 1 ? DELETE_BRANCH_FORCE : 0) | (quiet ? DELETE_BRANCH_QUIET : 0)); goto out; + } else if (delete_merged.nr) { + ret = delete_merged_branches(&delete_merged, argv, + quiet ? DELETE_BRANCH_QUIET : 0); + goto out; } else if (show_current) { print_current_branch_name(); ret = 0; @@ -1087,6 +1277,7 @@ int cmd_branch(int argc, ret = 0; out: + strvec_clear(&delete_merged); string_list_clear(&sorting_options, 0); return ret; } diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index 84940951651e67..79bb56b1bc2e73 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1882,4 +1882,223 @@ 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" --track origin/next && + git commit --allow-empty -m "$1 work" && + git push origin "$1:next" + ) +} + +check_branches () { + git for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect && + test_cmp expect actual +} + +test_expect_success '--delete-merged keeps cloned main without 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 */* && + + check_branches <<-\EOF + main + EOF + ) +' + +test_expect_success '--delete-merged deletes only selected merged branches' ' + setup_repo_for_delete_merged && + create_merged_branch also-merged && + create_merged_branch merged && + ( + cd repo && + git checkout -b unmerged --track origin/next && + git commit --allow-empty -m "unmerged work" && + git checkout -b tracks-other --track other/main && + 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 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 --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 && + + check_branches <<-\EOF + checked-out + main + tracks-other + upstream-gone + EOF + ) +' + +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 --track feature && + git commit --allow-empty -m "topic work" && + + git branch --delete-merged origin/next 2>err && + + test_must_be_empty err && + check_branches <<-\EOF && + feature + main + topic + EOF + + pattern="branch\\.(feature|topic)\\.(merge|remote)" && + git config --local --get-regexp "$pattern" >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 deleted upstream of a protected branch' ' + setup_repo_for_delete_merged && + ( + cd repo && + 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 && + echo "Deleted branch lower (was $sha)." >expect && + test_cmp expect actual && + + check_branches <<-\EOF && + main + mid + tip + EOF + + pattern="branch\\.(mid|tip)\\.(merge|remote)" && + git config --local --get-regexp "$pattern" >actual && + cat >expect <<-\EOF && + 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 --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-*" && + + check_branches <<-\EOF && + a-tip + b-mid + 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 + 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 From 3d1f0df6e4ebcb8de0e8b3d968763cbbca6967a5 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 5 Aug 2026 14:24:36 +0000 Subject: [PATCH 06/25] branch: add branch..deleteMerged opt-out Setting branch..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. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- 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(-) diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc index a4db9fa5c87eab..d8483acb4f9b98 100644 --- a/Documentation/config/branch.adoc +++ b/Documentation/config/branch.adoc @@ -102,3 +102,10 @@ for details). `git branch --edit-description`. Branch description is automatically added to the `format-patch` cover letter or `request-pull` summary. + +`branch..deleteMerged`:: + If set to `false`, branch __ 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. diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index 47661782045e99..cfaac4b90f9692 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -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..deleteMerged` is set to `false`. -- + When such a local upstream branch has its own upstream deleted by the diff --git a/builtin/branch.c b/builtin/branch.c index f1a73bcea1cee1..2d0c4f51ea49d6 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -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); diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index 79bb56b1bc2e73..829bbfef4e1e05 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -2101,4 +2101,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..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 From 25285a6763543242514f3a58c504fd80e9996df2 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 5 Aug 2026 14:24:37 +0000 Subject: [PATCH 07/25] branch: add --dry-run for --delete-merged "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. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-branch.adoc | 8 ++++++- builtin/branch.c | 23 ++++++++++++++++---- t/t3200-branch.sh | 41 ++++++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index cfaac4b90f9692..bfdf4593298631 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -25,7 +25,7 @@ git branch (-m|-M) [] git branch (-c|-C) [] git branch (-d|-D) [-r] ... git branch --edit-description [] -git branch (--delete-merged )... [...] +git branch [--dry-run] (--delete-merged )... [...] 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`:: diff --git a/builtin/branch.c b/builtin/branch.c index 2d0c4f51ea49d6..57ee384d2320e5 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -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 (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(); diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index 829bbfef4e1e05..0bf6b3e42e0a82 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1950,6 +1950,20 @@ 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 && @@ -2016,9 +2030,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 @@ -2047,6 +2064,23 @@ 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 && + + pattern="branch\\.(lower|mid|tip)\\.(merge|remote)" && + git config --local --get-regexp "$pattern" >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 && @@ -2137,4 +2171,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 From ca571025d86b55933d493e38d6e72824bcf5a80a Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Fri, 7 Aug 2026 05:34:25 +0200 Subject: [PATCH 08/25] loose: load loose object map for the correct source When loading the loose object map via `load_one_loose_object_map()` we pass in both a repository and the corresponding source. We ultimately don't really respect the passed-in source though as we instead always load the map via the common directory. This doesn't make any sense though, as the function is called in a loop through all sources, and as such the expectation is that we'll load the map that belongs to the given source. The consequence is that we'll ignore loose object maps of any configured alternates. Fix this bug by instead loading the map via the loose source's path. Helped-by: Toon Claes Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- loose.c | 18 ++++++++++-------- t/t1016-compatObjectFormat.sh | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/loose.c b/loose.c index bf01d3e42def34..9dad75373b8080 100644 --- a/loose.c +++ b/loose.c @@ -61,9 +61,11 @@ static int insert_loose_map(struct odb_source_loose *loose, return inserted; } -static int load_one_loose_object_map(struct repository *repo, struct odb_source_loose *loose) +static int load_one_loose_object_map(struct odb_source_loose *loose) { - struct strbuf buf = STRBUF_INIT, path = STRBUF_INIT; + struct repository *repo = loose->base.odb->repo; + struct strbuf buf = STRBUF_INIT; + char *path; FILE *fp; int ret = -1; @@ -78,10 +80,10 @@ static int load_one_loose_object_map(struct repository *repo, struct odb_source_ insert_loose_map(loose, repo->hash_algo->empty_blob, repo->compat_hash_algo->empty_blob); insert_loose_map(loose, repo->hash_algo->null_oid, repo->compat_hash_algo->null_oid); - repo_common_path_replace(repo, &path, "objects/loose-object-idx"); - fp = fopen(path.buf, "rb"); + path = xstrfmt("%s/loose-object-idx", loose->base.path); + fp = fopen(path, "rb"); if (!fp) { - strbuf_release(&path); + free(path); return 0; } @@ -102,7 +104,7 @@ static int load_one_loose_object_map(struct repository *repo, struct odb_source_ err: fclose(fp); strbuf_release(&buf); - strbuf_release(&path); + free(path); return ret; } @@ -117,10 +119,10 @@ int repo_read_loose_object_map(struct repository *repo) for (source = repo->objects->sources; source; source = source->next) { struct odb_source_files *files = odb_source_files_downcast(source); - if (load_one_loose_object_map(repo, files->loose) < 0) { + if (load_one_loose_object_map(files->loose) < 0) return -1; - } } + return 0; } diff --git a/t/t1016-compatObjectFormat.sh b/t/t1016-compatObjectFormat.sh index 92d48b96a10932..9cafcee5098692 100755 --- a/t/t1016-compatObjectFormat.sh +++ b/t/t1016-compatObjectFormat.sh @@ -187,6 +187,24 @@ do eval signedtag3_${hash}_oid=$(git hash-object -t tag -w ../${hash}_signedtag3) && eval signedtag4_${hash}_oid=$(git hash-object -t tag -w ../${hash}_signedtag4) ' + + test_expect_success 'rev-parse maps oid of object borrowed from alternate' ' + for repo in alt borrow + do + test_when_finished "rm -rf $repo" && + git init --object-format=$hash $repo && + git -C $repo config set core.repositoryformatversion 1 && + git -C $repo config set extensions.compatObjectFormat $(compat_hash $hash) || exit 1 + done && + + git -C alt commit --allow-empty --message A && + echo "$(pwd)/alt/.git/objects" >borrow/.git/objects/info/alternates && + + oid=$(git -C alt rev-parse HEAD) && + git -C alt rev-parse --output-object-format=$(compat_hash $hash) "$oid" >expect && + git -C borrow rev-parse --output-object-format=$(compat_hash $hash) "$oid" >actual && + test_cmp expect actual + ' done cd "$base" From 8a1ba94eb5863cd7491899bb23a290081e760453 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Fri, 7 Aug 2026 05:34:26 +0200 Subject: [PATCH 09/25] setup: detangle loading of loose object maps When a repository is configured to use a compatibility hash function then we load the loose object map when we initialize the repository. This object map provides the mappings between the canonical object hash and the compatibility object hash. Loading the object map happens in `repo_set_compat_hash_algo()`, which calls `repo_read_loose_object_map()` in case the compatibility object hash is non-zero. This setup sequence has two major downsides: - We assume that the primary object database is the "files" object database and unconditionally downcast it. This will cause us to BUG in case a different object database type was used together with a compat hash algorithm. - We require the object database to already have been initialized when configuring the object database. This means that we must intermix configuration of the repository and initialization of its sub-structures in a weird way. Refactor the logic so that we instead load the loose object map via the "loose" backend, which fixes both of the above issues. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- loose.c | 11 +++++------ loose.h | 1 + odb/source-loose.c | 2 ++ repository.c | 2 -- setup.c | 5 +++-- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/loose.c b/loose.c index 9dad75373b8080..a3b2dcedc23607 100644 --- a/loose.c +++ b/loose.c @@ -61,7 +61,7 @@ static int insert_loose_map(struct odb_source_loose *loose, return inserted; } -static int load_one_loose_object_map(struct odb_source_loose *loose) +int loose_object_map_load(struct odb_source_loose *loose) { struct repository *repo = loose->base.odb->repo; struct strbuf buf = STRBUF_INIT; @@ -69,6 +69,9 @@ static int load_one_loose_object_map(struct odb_source_loose *loose) FILE *fp; int ret = -1; + if (!should_use_loose_object_map(repo)) + return 0; + if (!loose->map) loose_object_map_init(&loose->map); if (!loose->cache) { @@ -112,14 +115,10 @@ int repo_read_loose_object_map(struct repository *repo) { struct odb_source *source; - if (!should_use_loose_object_map(repo)) - return 0; - odb_prepare_alternates(repo->objects); - for (source = repo->objects->sources; source; source = source->next) { struct odb_source_files *files = odb_source_files_downcast(source); - if (load_one_loose_object_map(files->loose) < 0) + if (loose_object_map_load(files->loose) < 0) return -1; } diff --git a/loose.h b/loose.h index 6c9b3f4571602f..ed663ac550fbb7 100644 --- a/loose.h +++ b/loose.h @@ -13,6 +13,7 @@ struct loose_object_map { void loose_object_map_init(struct loose_object_map **map); void loose_object_map_clear(struct loose_object_map **map); +int loose_object_map_load(struct odb_source_loose *loose); int repo_loose_object_map_oid(struct repository *repo, const struct object_id *src, const struct git_hash_algo *dest_algo, diff --git a/odb/source-loose.c b/odb/source-loose.c index 3f7d04a56e36ce..812ca1c1381bab 100644 --- a/odb/source-loose.c +++ b/odb/source-loose.c @@ -727,5 +727,7 @@ struct odb_source_loose *odb_source_loose_new(struct object_database *odb, if (!is_absolute_path(loose->base.path)) chdir_notify_register(NULL, odb_source_loose_reparent, loose); + loose_object_map_load(loose); + return loose; } diff --git a/repository.c b/repository.c index 2ef0778846bcf1..6d633002b4e3c4 100644 --- a/repository.c +++ b/repository.c @@ -201,8 +201,6 @@ void repo_set_compat_hash_algo(struct repository *repo MAYBE_UNUSED, uint32_t al if (hash_algo_by_ptr(repo->hash_algo) == algo) BUG("hash_algo and compat_hash_algo match"); repo->compat_hash_algo = algo ? &hash_algos[algo] : NULL; - if (repo->compat_hash_algo) - repo_read_loose_object_map(repo); #else if (algo) die(_("compatibility hash algorithm support requires Rust")); diff --git a/setup.c b/setup.c index d31808130b47fc..825572f5f1ad06 100644 --- a/setup.c +++ b/setup.c @@ -1788,8 +1788,6 @@ int apply_repository_format(struct repository *repo, repo->bare_cfg = format->is_bare; repo_set_hash_algo(repo, format->hash_algo); - repo->objects = odb_new(repo, object_directory, - alternate_object_directories); repo_set_compat_hash_algo(repo, format->compat_hash_algo); repo_set_ref_storage_format(repo, format->ref_storage_format, @@ -1805,6 +1803,9 @@ int apply_repository_format(struct repository *repo, repo->repository_format_precious_objects = format->precious_objects; + repo->objects = odb_new(repo, object_directory, + alternate_object_directories); + free(alternate_object_directories); free(object_directory); return 0; From 30bc6f0e8c2aef5f9280468fa2ca7c170209603f Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Fri, 7 Aug 2026 05:34:27 +0200 Subject: [PATCH 10/25] setup: handle ODB-related environment variables in `odb_new()` When initializing a repository's object database we have to respect the GIT_OBJECT_DIRECTORY and GIT_ALTERNATE_OBJECT_DIRECTORIES environment variables, which can be set by the user to override the default location of where we write objects to and read objects from. This is handled in `apply_repository_format()`, which is fine. But in a subsequent commit we'll have to defer constructing the object database to a later point in some cases, and that will require a second site where we call `odb_new()`. And of course, that second site would have to handle those environment variables, as well. It would be somewhat awkward to duplicate the logic though. But there's a better alternative: instead of handling this logic in "setup.c", we can easily handle environment variables in `odb_new()` itself. This ensures that object database creation is neatly self-contained, and we don't have to duplicate any of the logic. Another benefit is that in a future patch series we plan to move handling of alternates into the backends themselves [1], and that will require us to also handle those environment variables in the "files" backend itself. So moving the logic into the ODB level already gets us one step closer to that goal. Refactor the logic accordingly. [1]: https://lore.kernel.org/git/amLgMqkqxR8mKIbT@pks.im/ Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- odb.c | 21 ++++++++++++--------- odb.h | 17 +++++++++++++++-- setup.c | 11 ++++------- t/unit-tests/u-odb-inmemory.c | 2 +- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/odb.c b/odb.c index cf6e7938c01e56..ed1d63f4bd3e9e 100644 --- a/odb.c +++ b/odb.c @@ -1004,26 +1004,29 @@ int odb_write_object_stream(struct object_database *odb, } struct object_database *odb_new(struct repository *repo, - const char *primary_source, - const char *secondary_sources) + enum odb_new_flags flags) { - struct object_database *o = xmalloc(sizeof(*o)); - char *to_free = NULL; + char *primary_source = NULL, *secondary_sources = NULL; + struct object_database *o; - memset(o, 0, sizeof(*o)); + CALLOC_ARRAY(o, 1); o->repo = repo; pthread_mutex_init(&o->replace_mutex, NULL); string_list_init_dup(&o->submodule_source_paths); + if (flags & ODB_NEW_HONOR_ENV) { + primary_source = xstrdup_or_null(getenv(DB_ENVIRONMENT)); + secondary_sources = xstrdup_or_null(getenv(ALTERNATE_DB_ENVIRONMENT)); + } if (!primary_source) - primary_source = to_free = xstrfmt("%s/objects", repo->commondir); + primary_source = xstrfmt("%s/objects", repo->commondir); + o->sources = odb_source_new(o, primary_source, true); o->sources_tail = &o->sources->next; - o->alternate_db = xstrdup_or_null(secondary_sources); + o->alternate_db = secondary_sources; o->inmemory_objects = &odb_source_inmemory_new(o)->base; - free(to_free); - + free(primary_source); return o; } diff --git a/odb.h b/odb.h index 7995bed97bc36e..8ec335c7f75a72 100644 --- a/odb.h +++ b/odb.h @@ -100,6 +100,20 @@ struct object_database { struct string_list submodule_source_paths; }; +enum odb_new_flags { + /* + * Honor environment variables when constructing the object database + * sources. This makes us respect the following environment variables: + * + * - GIT_OBJECT_DIRECTORY to override the primary object directory. + * + * - GIT_ALTERNATE_OBJECT_DIRECTORIES to override alternates. + * + * Environment variables may be backend-specific. + */ + ODB_NEW_HONOR_ENV = (1 << 0), +}; + /* * Create a new object database for the given repository. * @@ -112,8 +126,7 @@ struct object_database { * Returns the newly created object database. */ struct object_database *odb_new(struct repository *repo, - const char *primary_source, - const char *alternate_sources); + enum odb_new_flags flags); /* Free the object database and release all resources. */ void odb_free(struct object_database *o); diff --git a/setup.c b/setup.c index 825572f5f1ad06..5dfab3e79e54ba 100644 --- a/setup.c +++ b/setup.c @@ -1765,7 +1765,7 @@ int apply_repository_format(struct repository *repo, enum apply_repository_format_flags flags, struct strbuf *err) { - char *object_directory = NULL, *alternate_object_directories = NULL; + enum odb_new_flags odb_new_flags = 0; if (verify_repository_format(format, err) < 0) return -1; @@ -1779,8 +1779,6 @@ int apply_repository_format(struct repository *repo, if (flags & APPLY_REPOSITORY_FORMAT_HONOR_ENV) { const char *shallow_file; - object_directory = xstrdup_or_null(getenv(DB_ENVIRONMENT)); - alternate_object_directories = xstrdup_or_null(getenv(ALTERNATE_DB_ENVIRONMENT)); shallow_file = getenv(GIT_SHALLOW_FILE_ENVIRONMENT); if (shallow_file) set_alternate_shallow_file(repo, shallow_file); @@ -1803,11 +1801,10 @@ int apply_repository_format(struct repository *repo, repo->repository_format_precious_objects = format->precious_objects; - repo->objects = odb_new(repo, object_directory, - alternate_object_directories); + if (flags & APPLY_REPOSITORY_FORMAT_HONOR_ENV) + odb_new_flags |= ODB_NEW_HONOR_ENV; + repo->objects = odb_new(repo, odb_new_flags); - free(alternate_object_directories); - free(object_directory); return 0; } diff --git a/t/unit-tests/u-odb-inmemory.c b/t/unit-tests/u-odb-inmemory.c index 6844bfc37ccfdc..db323e10fd4b2c 100644 --- a/t/unit-tests/u-odb-inmemory.c +++ b/t/unit-tests/u-odb-inmemory.c @@ -38,7 +38,7 @@ static void cl_assert_object_info(struct odb_source_inmemory *source, void test_odb_inmemory__initialize(void) { - odb = odb_new(&repo, "", ""); + odb = odb_new(&repo, 0); } void test_odb_inmemory__cleanup(void) From c1d233bd3001530042ff097f6aef0a658b7f79cb Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Fri, 7 Aug 2026 05:34:28 +0200 Subject: [PATCH 11/25] setup: defer object database creation In a subsequent commit we'll make the creation of the on-disk data structures of an object database pluggable. This will lead to an in-between state where we have already configured the repository's object database, but it's not usable yet until we eventually call `create_object_directory()`. Lift the call to `odb_new()` out of `apply_repository_format()` so that callers have more wiggle room with when exactly they call it, and adapt them accordingly. The only exception is `init_db()`, where we now defer creating the object database until we call `create_object_database()`. With this change, initializing and creating the object database on disk is now neatly encapsulated in a single function, which will make it easier for a subsequent commit to move creation of the on-disk data structures into the `struct odb_source` backends. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- repository.c | 1 + setup.c | 17 ++++++++--------- setup.h | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/repository.c b/repository.c index 6d633002b4e3c4..5ec264e6072865 100644 --- a/repository.c +++ b/repository.c @@ -294,6 +294,7 @@ int repo_init(struct repository *repo, warning("%s", err.buf); goto error; } + repo->objects = odb_new(repo, 0); if (worktree) repo_set_worktree(repo, worktree); diff --git a/setup.c b/setup.c index 5dfab3e79e54ba..97338cbc51fba9 100644 --- a/setup.c +++ b/setup.c @@ -1765,8 +1765,6 @@ int apply_repository_format(struct repository *repo, enum apply_repository_format_flags flags, struct strbuf *err) { - enum odb_new_flags odb_new_flags = 0; - if (verify_repository_format(format, err) < 0) return -1; @@ -1801,10 +1799,6 @@ int apply_repository_format(struct repository *repo, repo->repository_format_precious_objects = format->precious_objects; - if (flags & APPLY_REPOSITORY_FORMAT_HONOR_ENV) - odb_new_flags |= ODB_NEW_HONOR_ENV; - repo->objects = odb_new(repo, odb_new_flags); - return 0; } @@ -1888,6 +1882,7 @@ const char *enter_repo(struct repository *repo, const char *path, unsigned flags read_and_verify_repository_format(&fmt, ".", NULL); if (apply_repository_format(repo, &fmt, APPLY_REPOSITORY_FORMAT_HONOR_ENV, &err) < 0) die("%s", err.buf); + repo->objects = odb_new(repo, ODB_NEW_HONOR_ENV); startup_info->have_repository = 1; clear_repository_format(&fmt); @@ -2090,6 +2085,7 @@ const char *setup_git_directory_gently(struct repository *repo, int *nongit_ok) if (apply_repository_format(repo, &discovery.format, APPLY_REPOSITORY_FORMAT_HONOR_ENV, &err) < 0) die("%s", err.buf); + repo->objects = odb_new(repo, ODB_NEW_HONOR_ENV); clear_repository_format(&discovery.format); strbuf_release(&err); @@ -2651,11 +2647,13 @@ static int create_default_files(struct repository *repo, return reinit; } -static void create_object_directory(struct repository *repo) +static void create_object_database(struct repository *repo) { struct strbuf path = STRBUF_INIT; size_t baselen; + repo->objects = odb_new(repo, ODB_NEW_HONOR_ENV); + strbuf_addstr(&path, repo_get_object_directory(repo)); baselen = path.len; @@ -2866,7 +2864,6 @@ int init_db(struct repository *repo, repository_format_configure(&repo_fmt, hash, ref_storage_format); if (apply_repository_format(repo, &repo_fmt, APPLY_REPOSITORY_FORMAT_HONOR_ENV, &err) < 0) die("%s", err.buf); - startup_info->have_repository = 1; /* * Ensure `core.hidedotfiles` is processed. This must happen after we @@ -2882,7 +2879,9 @@ int init_db(struct repository *repo, if (!(flags & INIT_DB_SKIP_REFDB)) create_reference_database(repo, initial_branch, flags & INIT_DB_QUIET); - create_object_directory(repo); + create_object_database(repo); + + startup_info->have_repository = 1; if (repo_settings_get_shared_repository(repo)) { char buf[10]; diff --git a/setup.h b/setup.h index 654f10e059b995..763fd384e86c28 100644 --- a/setup.h +++ b/setup.h @@ -245,8 +245,8 @@ enum apply_repository_format_flags { /* * Apply the given repository format to the repo. This initializes extensions - * and basic data structures required for normal operation. Returns 0 on - * success, a negative error code when the format is not valid as determined by + * required for normal operation. Returns 0 on success, a negative error code + * when the format is not valid as determined by * `verify_repository_format()`. */ int apply_repository_format(struct repository *repo, From 335fe2545e4d64b79fc28acc945bc3278739d078 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Fri, 7 Aug 2026 05:34:29 +0200 Subject: [PATCH 12/25] odb/source: introduce function to map source type to name Introduce a new function that maps an object source's type to a human-readable name. Use the function to provide better human-readable error messages for the downcasting functions. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- odb/source-files.h | 4 +++- odb/source-inmemory.h | 4 +++- odb/source-loose.h | 4 +++- odb/source-packed.h | 4 +++- odb/source.c | 19 +++++++++++++++++++ odb/source.h | 6 ++++++ 6 files changed, 37 insertions(+), 4 deletions(-) diff --git a/odb/source-files.h b/odb/source-files.h index d7ac3c1c81d892..6a803afdda3e86 100644 --- a/odb/source-files.h +++ b/odb/source-files.h @@ -28,7 +28,9 @@ struct odb_source_files *odb_source_files_new(struct object_database *odb, static inline struct odb_source_files *odb_source_files_downcast(struct odb_source *source) { if (source->type != ODB_SOURCE_FILES) - BUG("trying to downcast source of type '%d' to files", source->type); + BUG("trying to downcast source of type '%s' to '%s'", + odb_source_type_to_name(source->type), + odb_source_type_to_name(ODB_SOURCE_FILES)); return container_of(source, struct odb_source_files, base); } diff --git a/odb/source-inmemory.h b/odb/source-inmemory.h index a88fc2e320ed5c..adbad23e8b26af 100644 --- a/odb/source-inmemory.h +++ b/odb/source-inmemory.h @@ -26,7 +26,9 @@ struct odb_source_inmemory *odb_source_inmemory_new(struct object_database *odb) static inline struct odb_source_inmemory *odb_source_inmemory_downcast(struct odb_source *source) { if (source->type != ODB_SOURCE_INMEMORY) - BUG("trying to downcast source of type '%d' to in-memory", source->type); + BUG("trying to downcast source of type '%s' to '%s'", + odb_source_type_to_name(source->type), + odb_source_type_to_name(ODB_SOURCE_INMEMORY)); return container_of(source, struct odb_source_inmemory, base); } diff --git a/odb/source-loose.h b/odb/source-loose.h index 6070aaf3ce6ab2..3cf2e1f8f1d5e2 100644 --- a/odb/source-loose.h +++ b/odb/source-loose.h @@ -41,7 +41,9 @@ struct odb_source_loose *odb_source_loose_new(struct object_database *odb, static inline struct odb_source_loose *odb_source_loose_downcast(struct odb_source *source) { if (source->type != ODB_SOURCE_LOOSE) - BUG("trying to downcast source of type '%d' to loose", source->type); + BUG("trying to downcast source of type '%s' to '%s'", + odb_source_type_to_name(source->type), + odb_source_type_to_name(ODB_SOURCE_LOOSE)); return container_of(source, struct odb_source_loose, base); } diff --git a/odb/source-packed.h b/odb/source-packed.h index 77309ddd0932b6..a0f6b5096dcd0f 100644 --- a/odb/source-packed.h +++ b/odb/source-packed.h @@ -78,7 +78,9 @@ struct odb_source_packed *odb_source_packed_new(struct object_database *odb, static inline struct odb_source_packed *odb_source_packed_downcast(struct odb_source *source) { if (source->type != ODB_SOURCE_PACKED) - BUG("trying to downcast source of type '%d' to packed", source->type); + BUG("trying to downcast source of type '%s' to '%s'", + odb_source_type_to_name(source->type), + odb_source_type_to_name(ODB_SOURCE_PACKED)); return container_of(source, struct odb_source_packed, base); } diff --git a/odb/source.c b/odb/source.c index 7993dcbd659399..30188b806d524d 100644 --- a/odb/source.c +++ b/odb/source.c @@ -4,6 +4,25 @@ #include "odb/source.h" #include "packfile.h" +static const char * const odb_source_names_by_type[] = { + [ODB_SOURCE_UNKNOWN] = "unknown", + [ODB_SOURCE_FILES] = "files", + [ODB_SOURCE_LOOSE] = "loose", + [ODB_SOURCE_PACKED] = "packed", + [ODB_SOURCE_INMEMORY] = "in-memory", +}; + +const char *odb_source_type_to_name(enum odb_source_type type) +{ + const char *name; + if (type < 0 || type >= ARRAY_SIZE(odb_source_names_by_type)) + type = ODB_SOURCE_UNKNOWN; + name = odb_source_names_by_type[type]; + if (!name) + BUG("name missing in `odb_source_names_by_type` for '%d'", type); + return name; +} + struct odb_source *odb_source_new(struct object_database *odb, const char *path, bool local) diff --git a/odb/source.h b/odb/source.h index cd63dba91f4e2f..ab16d152f43082 100644 --- a/odb/source.h +++ b/odb/source.h @@ -25,6 +25,12 @@ enum odb_source_type { ODB_SOURCE_INMEMORY, }; +/* + * Convert between the enum and its name. Returns the equivalent of "unknown" + * for unknown types. + */ +const char *odb_source_type_to_name(enum odb_source_type type); + struct object_id; struct odb_read_stream; struct strvec; From e927cfeb21d6a217b708216862deb36f144f064b Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Fri, 7 Aug 2026 05:34:30 +0200 Subject: [PATCH 13/25] odb: make creation of on-disk structures pluggable When creating a new "files" object database source we have to create a couple of directories. These directories are of course specific to this particular backend, and a different backend may require a setup that is completely different. Make the creation of on-disk structures pluggable to accommodate for this. Note that there is one exception though: the "objects" directory must exist in a repository regardless of which backend is in use. If it doesn't exist then the repository is not treated as a Git repository at all. Consequently, we create this directory regardless of the backend. Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- odb/source-files.c | 19 +++++++++++++++++++ odb/source.h | 23 +++++++++++++++++++++++ setup.c | 34 ++++++++++++++++++---------------- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/odb/source-files.c b/odb/source-files.c index 413875851135a5..0db6e681fee9e6 100644 --- a/odb/source-files.c +++ b/odb/source-files.c @@ -9,6 +9,7 @@ #include "odb/source-files.h" #include "odb/source-loose.h" #include "packfile.h" +#include "path.h" #include "strbuf.h" #include "write-or-die.h" @@ -41,6 +42,23 @@ static void odb_source_files_close(struct odb_source *source) odb_source_close(&files->packed->base); } +static int odb_source_files_create_on_disk(struct odb_source *source) +{ + struct strbuf path = STRBUF_INIT; + + safe_create_dir(source->odb->repo, source->path, 1); + + strbuf_addf(&path, "%s/pack", source->path); + safe_create_dir(source->odb->repo, path.buf, 1); + + strbuf_reset(&path); + strbuf_addf(&path, "%s/info", source->path); + safe_create_dir(source->odb->repo, path.buf, 1); + + strbuf_release(&path); + return 0; +} + static void odb_source_files_prepare(struct odb_source *source, enum odb_prepare_flags flags) { @@ -271,6 +289,7 @@ struct odb_source_files *odb_source_files_new(struct object_database *odb, files->base.free = odb_source_files_free; files->base.close = odb_source_files_close; + files->base.create_on_disk = odb_source_files_create_on_disk; files->base.prepare = odb_source_files_prepare; files->base.read_object_info = odb_source_files_read_object_info; files->base.read_object_stream = odb_source_files_read_object_stream; diff --git a/odb/source.h b/odb/source.h index ab16d152f43082..4abc418bdd70fc 100644 --- a/odb/source.h +++ b/odb/source.h @@ -89,6 +89,18 @@ struct odb_source { */ void (*close)(struct odb_source *source); + /* + * This callback is expected to create on-disk data structures that are + * required for this source to operate. + * + * The callback is expected to return 0 on success, a negative error + * code otherwise. + * + * This callback may be NULL in case the source does not need any + * on-disk setup. + */ + int (*create_on_disk)(struct odb_source *source); + /* * This callback is expected to prepare the source so that it becomes * ready for use. It optionally clears underlying caches of the object @@ -316,6 +328,17 @@ static inline void odb_source_close(struct odb_source *source) source->close(source); } +/* + * Create on-disk data structures that are required for this source to operate + * correctly. Returns 0 on success, a negative error code otherwise. + */ +static inline int odb_source_create_on_disk(struct odb_source *source) +{ + if (!source->create_on_disk) + return 0; + return source->create_on_disk(source); +} + /* * Prepare the object database source and clear any caches. Depending on the * backend used this may have the effect that concurrently-written objects diff --git a/setup.c b/setup.c index 97338cbc51fba9..ace3c59d184626 100644 --- a/setup.c +++ b/setup.c @@ -2649,25 +2649,27 @@ static int create_default_files(struct repository *repo, static void create_object_database(struct repository *repo) { - struct strbuf path = STRBUF_INIT; - size_t baselen; + /* + * Create the "objects" directory in the common directory. This is done + * so that the repository can be discovered regardless of the backend + * used. + * + * Note that we only do this in case the object directory wasn't + * overwritten via an environment variable. If it _is_ being overridden + * then we skip this step, as the repository won't be discoverable + * anyway without the environment variable. + */ + if (!getenv(DB_ENVIRONMENT)) { + struct strbuf objects_dir = STRBUF_INIT; + repo_common_path_append(repo, &objects_dir, "objects"); + safe_create_dir(repo, objects_dir.buf, 1); + strbuf_release(&objects_dir); + } repo->objects = odb_new(repo, ODB_NEW_HONOR_ENV); - strbuf_addstr(&path, repo_get_object_directory(repo)); - baselen = path.len; - - safe_create_dir(repo, path.buf, 1); - - strbuf_setlen(&path, baselen); - strbuf_addstr(&path, "/pack"); - safe_create_dir(repo, path.buf, 1); - - strbuf_setlen(&path, baselen); - strbuf_addstr(&path, "/info"); - safe_create_dir(repo, path.buf, 1); - - strbuf_release(&path); + if (odb_source_create_on_disk(repo->objects->sources) < 0) + die(_("failed creating object database")); } static void separate_git_dir(const char *git_dir, const char *git_link) From bc27e75a0e9a75b9543ff4cd2a12443fce558a79 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Sun, 9 Aug 2026 22:06:25 +0200 Subject: [PATCH 14/25] doc: interpret-trailers: stop fixating on RFC 822 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This command handles the trailer metadata format. But the command isn’t introduced as such; it is instead introduced by stating that these trailer lines look similar to RFC 822 email headers. This is overwrought; most people do not deal directly with email headers, and certainly not email RFCs. Trailers are just key–value pairs that, like email headers, use colon as the separator. The format in its simplest form is easy to describe directly without comparing it to anything else; we will do that in the upcoming commit “explain the format after the intro”. For now, let’s: • remove the first mention of email headers; • keep the second, innocuous comparison with email line folding in the middle; and • remove the now-unneeded disclaimer that trailers do not share many of the features of RFC 822 email headers—there is no invitation to speculate that trailers would follow any other email format rules since we do not compare them directly any more. *** Talking about trailers as an RFC 822/2822-like format seems to go back to the `--fixes`/`Fixes:` trailer topic,[1] the thread that precipitated this command and in turn the first trailer support in git(1) beyond adding s-o-b lines. † 1: https://lore.kernel.org/all/20131027071407.GA11683@leaf/ Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index 77b4f63b05cf5b..1878848ad2acb9 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -14,9 +14,9 @@ git interpret-trailers [--in-place] [--trim-empty] DESCRIPTION ----------- -Add or parse _trailer_ lines that look similar to RFC 822 e-mail -headers, at the end of the otherwise free-form part of a commit -message. For example, in the following commit message +Add or parse _trailer_ lines at the end of the otherwise +free-form part of a commit message. For example, in the following commit +message ------------------------------------------------ subject @@ -107,9 +107,6 @@ key: This is a very long value, with spaces and newlines in it. ------------------------------------------------ -Note that trailers do not follow (nor are they intended to follow) many of the -rules for RFC 822 headers. For example they do not follow the encoding rule. - OPTIONS ------- `--in-place`:: From abb0d859f8132634d77f1205e7862b9e7a14b479 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Sun, 9 Aug 2026 22:06:26 +0200 Subject: [PATCH 15/25] =?UTF-8?q?doc:=20interpret-trailers:=20replace=20?= =?UTF-8?q?=E2=80=9Clines=E2=80=9D=20with=20=E2=80=9Cmetadata=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We removed the initial comparison to email headers in the previous commit. Now the introduction paragraph just says “trailer lines”, and the only hint that this is metadata/structured information is the “otherwise free-form” phrase. Let’s replace “lines” with “metadata” since that is their purpose. This also makes the introduction more consistent with how I chose to define trailers in the glossary:[1] “Key-value metadata”. (We will introduce “key–value” in the upcoming commit “explain the format after the intro”.) † 1: 68e3c69e (Documentation/glossary: describe "trailer", 2024-11-17) Let’s not emphasize “trailer” here since we are going to define the term in the upcoming commit “explain the format after the intro”. Let’s call it “trailer metadata” rather than “trailers metadata”. At first it seemed better to use the latter: 1. We’re introducing the jargon, and the format is often discussed as plural “trailers”, with its constituent parts being singular “trailer” 2. What this replaces uses “trailer”, but it rescues the plural mood with “lines” 3. This is very soon going to go into the constituent parts, including each trailer, so we’re contrasting the concept name (trailers) with its parts But: 1. The former reads better (most important) 2. “Trailer *metadata*” suggests plurality, similar to “trailer *lines*” Helped-by: Matt Hunter Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index 1878848ad2acb9..c8950d3babc0f3 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -14,7 +14,7 @@ git interpret-trailers [--in-place] [--trim-empty] DESCRIPTION ----------- -Add or parse _trailer_ lines at the end of the otherwise +Add or parse trailer metadata at the end of the otherwise free-form part of a commit message. For example, in the following commit message From 500257fd2bc6f81181039bc7420383d14ecf980b Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Sun, 9 Aug 2026 22:06:27 +0200 Subject: [PATCH 16/25] =?UTF-8?q?doc:=20interpret-trailers:=20use=20?= =?UTF-8?q?=E2=80=9Cmetadata=E2=80=9D=20in=20Name=20as=20well?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now since the previous commit introduce the format as “trailer metadata”. We can replace “structured information” with “metadata” in the “Name” section to be consistent. While “structured information” does emphasize that the data is not loosely structured, we also say that this command adds to or parses this format. I don’t think that we need to emphasize that it is structured since clearly there is some structure there. Both “metadata” and “structured information” can convey the same information. But “metadata” is shorter and easier to deploy since it’s just one word. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index c8950d3babc0f3..5e776f0059a11d 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -3,7 +3,7 @@ git-interpret-trailers(1) NAME ---- -git-interpret-trailers - Add or parse structured information in commit messages +git-interpret-trailers - Add or parse metadata in commit messages SYNOPSIS -------- From 33691bc9d75560299568ebdf9dfbf38539087be3 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Sun, 9 Aug 2026 22:06:28 +0200 Subject: [PATCH 17/25] doc: interpret-trailers: not just for commit messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This command doesn’t interface with commits directly. You can interpret or modify any kind of text, even though commit messages are the most relevant. The git(1) suite also isn’t restricted to only direct commit support since git-tag(1) learned `--trailer` in 066cef77 (builtin/tag: add --trailer option, 2024-05-05) Now, we already introduce the command in the “Name” section as dealing with commit messages as well. That is fine since that intro line needs to remain pretty short. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index 5e776f0059a11d..ab3627c2cba953 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -15,8 +15,8 @@ git interpret-trailers [--in-place] [--trim-empty] DESCRIPTION ----------- Add or parse trailer metadata at the end of the otherwise -free-form part of a commit message. For example, in the following commit -message +free-form part of a commit message, or any other kind of text. +For example, in the following commit message ------------------------------------------------ subject From c88d60db4438b05ac29d07b5373e6fb7a37d56af Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Sun, 9 Aug 2026 22:06:29 +0200 Subject: [PATCH 18/25] doc: interpret-trailers: explain the format after the intro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You need to read the entire “Description” section in order to understand the full trailer format. But there are many nuances, so that’s fine. As a starter though we have an introductory example.[1] That turns out to be crucial; the rest of this section talks about the mechanics of the command and only incidentally the format itself. Now, although the example might arguably be self-explanatory, we can add a little preamble which defines the format in its simplest form as well as define the most important terms. Note that we name the “blank line” rule since I want to use that term every time it comes up. It gets very mildly obfuscated if you call it a “blank line” in one place[2] and “empty (or whitespace-only) ...” in another one.[3] We will define the format of the *key* in the next commit. † 1: from d57fa7fc (doc: trailer: add more examples in DESCRIPTION, 2023-06-15) † 2: `Documentation/git-interpret-trailers.adoc:86` in 5361983c (The 22nd batch, 2026-03-27) † 3: `Documentation/git-interpret-trailers.adoc:93` in 5361983c (The 22nd batch, 2026-03-27) Suggested-by: D. Ben Knoble Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index ab3627c2cba953..109059f11edc66 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -16,7 +16,12 @@ DESCRIPTION ----------- Add or parse trailer metadata at the end of the otherwise free-form part of a commit message, or any other kind of text. -For example, in the following commit message + +A _trailer_ in its simplest form is a key-value pair with a colon as a +separator. A _trailer block_ consists of one or more trailers. The +trailer block needs to be preceded by a blank line, where a _blank line_ +is either an empty or a whitespace-only line. For example, in the +following commit message ------------------------------------------------ subject From fd39e5a481154cdb0e42a8a89c0062bbf6e3ab2b Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Sun, 9 Aug 2026 22:06:30 +0200 Subject: [PATCH 19/25] doc: interpret-trailers: explain key format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trailer key must consist of ASCII alphanumeric characters and hyphens *only*. Let’s document it explicitly instead of relying on readers being conservative and only basing their trailer keys on the documentation examples.[1] The previous commit provided us with an appropriate paragraph to describe the key format. † 1: Technically they would then miss out on using digits in them since all of the example keys just use letters and hyphens Reported-by: Brendan Jackman Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index 109059f11edc66..fb503cbe9528ce 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -18,7 +18,8 @@ Add or parse trailer metadata at the end of the otherwise free-form part of a commit message, or any other kind of text. A _trailer_ in its simplest form is a key-value pair with a colon as a -separator. A _trailer block_ consists of one or more trailers. The +separator. The _key_ consists of ASCII alphanumeric characters and +hyphens (`-`). A _trailer block_ consists of one or more trailers. The trailer block needs to be preceded by a blank line, where a _blank line_ is either an empty or a whitespace-only line. For example, in the following commit message From fddec1fe112470d35696322de65666a53b5bac5c Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Sun, 9 Aug 2026 22:06:31 +0200 Subject: [PATCH 20/25] doc: interpret-trailers: add key format example All of the examples speak of the Happy Path where everything works as intended. But failure examples can also be instructive. Especially for explaining again, by example, the key format (see previous commit). This also allows us to demonstrate trailer block detection with a concrete example. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index fb503cbe9528ce..a0f7ed6fdd9bd7 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -405,6 +405,29 @@ mv "\$1.new" "\$1" $ chmod +x .git/hooks/commit-msg ------------ +* Here we try to use three different trailer keys. But it fails because + two of them are not recognized as trailer keys. ++ +---- +$ cat msg.txt +subject + +Skapad-på: some-branch +Hash-in-v6.11: 45c12d3269fe48f22834320c782ffe86c3560f2c +Reviewed-by: Alice +$ git interpret-trailers --only-trailers Date: Sun, 9 Aug 2026 22:06:32 +0200 Subject: [PATCH 21/25] doc: interpret-trailers: join new-trailers again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There are three paragraphs that talk about how a new trailer is added. But the first one is separated from the other two by two paragraphs about how `key-alias` can make using `--trailer` more convenient. This short how-to does not follow thematically from the previous paragraph, and can wait until we have fully described how a new trailer is added. So let’s move the three paragraphs about the new-trailer topic together and move the how-to paragraphs after that. *** Let’s now review the history of the document. Even if the document is not quite correct in its current state, just doing the apparently obvious edit without considering the history does not respect the effort that went into changing the document in the past. These three paragraphs were originally next to each other, in the first version of the doc.[1] But extra sentences about this how-to topic was added to the first paragraph nine years later:[2] [...] `': '` (one colon followed by one space). For convenience, the can be a shortened string key (e.g., "sign") instead of the full string which should [...] And then it was split into it’s own paragraph a little later.[3] This evolution shows, in my opinion, that this how-to never followed thematically from the existing topic. Which means that there is nothing that was potentially lost to time that we need to restore or respect. † 1: dfd66ddf (Documentation: add documentation for 'git interpret-trailers', 2014-10-13) † 2: eda2c44c (doc: trailer: mention 'key' in DESCRIPTION, 2023-06-15) † 3: 6ccbc667 (trailer doc: is a or , not both, 2023-09-07) Suggested-by: D. Ben Knoble Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index a0f7ed6fdd9bd7..616f479a3670a0 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -74,19 +74,6 @@ key: value This means that the trimmed __ and __ will be separated by "`:`{nbsp}" (one colon followed by one space). -For convenience, a __ can be configured to make using `--trailer` -shorter to type on the command line. This can be configured using the -`trailer..key` configuration variable. The __ must be a prefix -of the full __ string, although case sensitivity does not matter. For -example, if you have - ------------------------------------------------- -trailer.sign.key "Signed-off-by: " ------------------------------------------------- - -in your configuration, you only need to specify `--trailer="sign: foo"` -on the command line instead of `--trailer="Signed-off-by: foo"`. - By default the new trailer will appear at the end of all the existing trailers. If there is no existing trailer, the new trailer will appear at the end of the input. A blank line will be added before the new @@ -101,6 +88,19 @@ The group must either be at the end of the input or be the last non-whitespace lines before a line that starts with `---` (followed by a space or the end of the line). +For convenience, a __ can be configured to make using `--trailer` +shorter to type on the command line. This can be configured using the +`trailer..key` configuration variable. The __ must be a prefix +of the full __ string, although case sensitivity does not matter. For +example, if you have + +------------------------------------------------ +trailer.sign.key "Signed-off-by: " +------------------------------------------------ + +in your configuration, you only need to specify `--trailer="sign: foo"` +on the command line instead of `--trailer="Signed-off-by: foo"`. + When reading trailers, there can be no whitespace before or inside the __, but any number of regular space and tab characters are allowed between the __ and the separator. There can be whitespaces before, From 4d45e571ae9a8dc47af95a4698cd28b15723bf52 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Sun, 9 Aug 2026 22:06:33 +0200 Subject: [PATCH 22/25] =?UTF-8?q?doc:=20interpret-trailers:=20commit=20to?= =?UTF-8?q?=20=E2=80=9Ctrailer=20block=E2=80=9D=20term?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We chose to introduce the term “trailer block” into the documentation a few commits ago.[1] It is used in the code though, so it is not a newly invented term. That term was useful to explain where the trailers are found (they *trail* the message). But it is also useful here, where we explain how trailers are added to existing messages, how trailer blocks are found (beyond the simple case in the introduction), and how the end of the message is found. Also note that we simplify the “blank line” point. The text says: A blank line will be added before the new trailer if there isn't one already. But this isn’t quite coherent. The previous sentence says “If there is no existing trailer”, so we are in one of these modes: 1. discussing trailer blocks in general; or 2. discussing creating a new trailer block in particular. If (1), then we shouldn’t add a blank line before the new trailer if there exists a trailer block already. And if (2), then the “if there isn’t one already” is redundant.[2] So just talking about the higher- level “trailer block” simplifies the text, since we don’t have to worry about the different contexts that *trailers* can find themselves in. † 1: in commit “explain the format after the intro” † 2: Note that non-trailer lines don’t matter here; if you have a trailer block consisting of `(cherry picked from commit )`, then you still shouldn’t insert a blank line before the new trailer since that would create a new trailer block Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 26 ++++++++++++----------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index 616f479a3670a0..a1adab20fefd61 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -74,19 +74,21 @@ key: value This means that the trimmed __ and __ will be separated by "`:`{nbsp}" (one colon followed by one space). -By default the new trailer will appear at the end of all the existing -trailers. If there is no existing trailer, the new trailer will appear -at the end of the input. A blank line will be added before the new -trailer if there isn't one already. - -Existing trailers are extracted from the input by looking for -a group of one or more lines that (i) is all trailers, or (ii) contains at -least one Git-generated or user-configured trailer and consists of at +By default the new trailer will appear at the end of the trailer block. +A trailer block will be created with only that trailer if a trailer +block does not already exist. Recall that a trailer block needs to be +preceded by a blank line, so a blank line will be inserted before the +new trailer block in that case. + +Existing trailers are extracted from the input by looking for the +trailer block. A trailer block is a group of one or more lines that (i) +is all trailers, or (ii) contains at least one Git-generated or +user-configured trailer and consists of at least 25% trailers. -The group must be preceded by one or more empty (or whitespace-only) lines. -The group must either be at the end of the input or be the last -non-whitespace lines before a line that starts with `---` (followed by a -space or the end of the line). +The trailer block is by definition at the end of the commit message. +The message in turn is either (i) at the end of the input, or (ii) the +last non-whitespace lines before a line that starts with `---` (followed +by a space or the end of the line). For convenience, a __ can be configured to make using `--trailer` shorter to type on the command line. This can be configured using the From cb657364d5c3b82bc32e22fe1eca445d9960032b Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Sun, 9 Aug 2026 22:06:34 +0200 Subject: [PATCH 23/25] doc: interpret-trailers: rewrite new-trailers paragraphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two commits ago we moved new-trailers paragraph next to each other. But there is something curious about two of them: By default the new trailer will appear at the end of the trailer block. [...] Then a source block and a paragraph later: By default, a `=` or `:` argument given using `--trailer` will be appended after the existing trailers only if [...] Why are there two paragraphs that talk about how “By default” a trailer will be appended? We can make these paragraphs flow better, and with a more distinct character each, by dividing the flow like this: 1. Declare that we are about to talk about `--trailer` appending 2. Explain the default behavior 3. Explain how this affects the trailer block 4. Then discuss what each trailer line will look like Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index a1adab20fefd61..ac59ef51f806f5 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -60,10 +60,18 @@ are applied to each input and the way any existing trailer in the input is changed. They also make it possible to automatically add some trailers. -By default, a `=` or `:` argument given -using `--trailer` will be appended after the existing trailers only if -the last trailer has a different (__, __) pair (or if there -is no existing trailer). The __ and __ parts will be trimmed +Let's consider new trailers added with `--trailer`. +By default, the new trailer will appear at the end of the trailer block. +Also by default, this new trailer will only be added +if the last trailer is different to it. +A trailer block will be created with only that trailer if a trailer +block does not already exist. Recall that a trailer block needs to be +preceded by a blank line, so a blank line will be inserted before the +new trailer block in that case. + +This is how the new trailer is added: a `=` or +`:` argument given using `--trailer` will be appended after +the existing trailers. The __ and __ parts will be trimmed to remove starting and trailing whitespace, and the resulting trimmed __ and __ will appear in the output like this: @@ -74,12 +82,6 @@ key: value This means that the trimmed __ and __ will be separated by "`:`{nbsp}" (one colon followed by one space). -By default the new trailer will appear at the end of the trailer block. -A trailer block will be created with only that trailer if a trailer -block does not already exist. Recall that a trailer block needs to be -preceded by a blank line, so a blank line will be inserted before the -new trailer block in that case. - Existing trailers are extracted from the input by looking for the trailer block. A trailer block is a group of one or more lines that (i) is all trailers, or (ii) contains at least one Git-generated or From 4515c86fd95ef8de8a1cbea90bb275538bfc87ee Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Sun, 9 Aug 2026 22:06:35 +0200 Subject: [PATCH 24/25] doc: interpret-trailers: document comment line treatment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment lines have always been ignored but this is not documented. The primary motivation here is to be reasonably complete in the documentation of how trailers are parsed; this is after all the only documentation page that documents this format. However, and going beyond that point, we could imagine that someone would want to use this format outside a commit (or tag) message context, like say in Git notes. On the other hand, it seems far-fetched that someone would be caught off guard by this considering that comment characters/strings are not likely to be alphanumeric,[1] which would mean that these comment lines would be treated as non-trailer lines if they were *not* detected and removed as comment lines. † 1: A notable exception is that Jujutsu VCS uses `JJ:` as the comment string Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index ac59ef51f806f5..b4988d39eab0e9 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -117,6 +117,16 @@ key: This is a very long value, with spaces and newlines in it. ------------------------------------------------ +OTHER RULES +----------- + +What was covered in the previous section are the rules that are relevant +for regular use. The following points are included for completeness. + +This command ignores comment lines (see `core.commentString` in +linkgit:git-config[1]). This is for use with the `prepare-commit-msg` +and `commit-msg` hooks. + OPTIONS ------- `--in-place`:: From 1a3e64c6c4a623626ff0687008732a8e007e2a1c Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Thu, 20 Aug 2026 07:30:30 -0700 Subject: [PATCH 25/25] The 16th batch Signed-off-by: Junio C Hamano --- Documentation/RelNotes/2.56.0.adoc | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Documentation/RelNotes/2.56.0.adoc b/Documentation/RelNotes/2.56.0.adoc index cbbc470215ac98..ff2b126188945b 100644 --- a/Documentation/RelNotes/2.56.0.adoc +++ b/Documentation/RelNotes/2.56.0.adoc @@ -101,6 +101,10 @@ UI, Workflows & Features automatically run 'git bisect reset' to jump back to the original state or to the found culprit. + * The 'git branch' command has been taught the '--delete-merged' option + to remove local branches that are already merged into their tracked + remote-tracking branches. + Performance, Internal Implementation, Development Support etc. -------------------------------------------------------------- @@ -371,6 +375,12 @@ Performance, Internal Implementation, Development Support etc. Calls to write(3p) in send_sideband() and cat_blob() have been refactored to use writev(3p) wrappers to reduce syscall overhead. + * The creation of the on-disk data structures for the object database + has been made pluggable, allowing future backends to customize their + setup. As part of this, the initialization of the object database + has been deferred, and the loading of the loose-object map has been + detangled from repository initialization. + Fixes since v2.55 ----------------- @@ -596,3 +606,9 @@ Fixes since v2.55 * Documentation for 'git replay' has been updated to refer to its configuration variables. (merge 48c0549f5c kh/doc-replay-config later to maint). + + * Documentation for 'git interpret-trailers' has been updated to explain + the format of trailer keys (alphanumeric characters and hyphens), + replace outdated terminology, define key terms upfront, and document + how comment lines in the input are treated. + (merge 4515c86fd9 kh/doc-trailers later to maint).