* [PATCH v5 4/4] history: re-edit a squash with every message
From: Harald Nordgren via GitGitGadget @ 2026-06-24 21:55 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v5.git.git.1782338102.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
By default "git history squash" reuses the oldest commit's message.
When --reedit-message is given it only reopened that one message, so the
messages of the folded-in commits were lost.
Gather the messages of every commit in the range, oldest first, and use
them as the editor template when re-editing, mirroring how "git rebase
-i" presents a squash. The combined message is built before the
descendant walk so it is not disturbed by the flags that walk leaves on
the commits.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-history.adoc | 5 +--
builtin/history.c | 61 +++++++++++++++++++++++++++++++++-
t/t3455-history-squash.sh | 37 +++++++++++++++++++++
3 files changed, 100 insertions(+), 3 deletions(-)
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 6716749cde..df389015aa 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -111,8 +111,9 @@ history squash @~3..` folds the three most recent commits into one, and
`git history squash @~5..@~2` squashes an interior range while leaving
the two newest commits in place.
+
-The oldest commit's message and authorship are preserved by default,
-unless you specify `--reedit-message`. A merge commit inside the range is
+The oldest commit's message and authorship are preserved by default. With
+`--reedit-message`, an editor opens pre-filled with the messages of all the
+folded commits so you can combine them. A merge commit inside the range is
folded like any other, but the range must have a single base, so a range
that reaches more than one entry point (for example a side branch that
forked before the range and was later merged into it) is rejected.
diff --git a/builtin/history.c b/builtin/history.c
index 0acfabed66..e93f8398e6 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1081,6 +1081,56 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)
return 0;
}
+static int build_squash_message(struct repository *repo,
+ struct commit *base,
+ struct commit *tip,
+ struct strbuf *out)
+{
+ struct rev_info revs;
+ struct commit *commit;
+ struct strvec args = STRVEC_INIT;
+ int n = 0, ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
+ oid_to_hex(&tip->object.oid));
+ setup_revisions_from_strvec(&args, &revs, NULL);
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ while ((commit = get_revision(&revs))) {
+ const char *message, *body;
+ struct strbuf one = STRBUF_INIT;
+
+ message = repo_logmsg_reencode(repo, commit, NULL, NULL);
+ find_commit_subject(message, &body);
+ strbuf_addstr(&one, body);
+ strbuf_trim_trailing_newline(&one);
+
+ if (n++)
+ strbuf_addch(out, '\n');
+ strbuf_addbuf(out, &one);
+ strbuf_addch(out, '\n');
+
+ strbuf_release(&one);
+ repo_unuse_commit_buffer(repo, commit, message);
+ }
+
+ ret = 0;
+
+out:
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
static int cmd_history_squash(int argc,
const char **argv,
const char *prefix,
@@ -1105,6 +1155,7 @@ static int cmd_history_squash(int argc,
OPT_END(),
};
struct strbuf reflog_msg = STRBUF_INIT;
+ struct strbuf message = STRBUF_INIT;
struct oidset interior = OIDSET_INIT;
struct commit *base, *oldest, *tip, *rewritten;
const struct object_id *base_tree_oid, *tip_tree_oid;
@@ -1144,6 +1195,12 @@ static int cmd_history_squash(int argc,
}
}
+ if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+ ret = build_squash_message(repo, base, tip, &message);
+ if (ret < 0)
+ goto out;
+ }
+
ret = setup_revwalk(repo, action, tip, &revs);
if (ret < 0)
goto out;
@@ -1152,7 +1209,8 @@ static int cmd_history_squash(int argc,
tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
commit_list_append(base, &parents);
- ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
+ ret = commit_tree_ext(repo, "squash", oldest,
+ message.len ? message.buf : NULL, parents,
base_tree_oid, tip_tree_oid, &rewritten, flags);
if (ret < 0) {
ret = error(_("failed writing squashed commit"));
@@ -1173,6 +1231,7 @@ static int cmd_history_squash(int argc,
out:
strbuf_release(&reflog_msg);
+ strbuf_release(&message);
oidset_clear(&interior);
commit_list_free(parents);
release_revisions(&revs);
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
index 7227c5c90f..af59ddf6e3 100755
--- a/t/t3455-history-squash.sh
+++ b/t/t3455-history-squash.sh
@@ -137,6 +137,43 @@ test_expect_success 'preserves authorship of the oldest commit' '
test_cmp expect actual
'
+test_expect_success '--reedit-message offers every folded-in message' '
+ git reset --hard start &&
+ echo b >file &&
+ git add file &&
+ git commit -m "re-one subject" -m "re-one body line" &&
+ test_commit --no-tag re-two file c &&
+ test_commit re-three file d &&
+
+ write_script editor <<-\EOF &&
+ cp "$1" buffer &&
+ echo combined >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ test_grep "re-one subject" buffer &&
+ test_grep "re-one body line" buffer &&
+ test_grep re-two buffer &&
+ test_grep re-three buffer &&
+ git log --format="%s" -1 >actual &&
+ echo combined >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--reedit-message aborts on an empty message' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ write_script editor <<-\EOF &&
+ >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ test_must_fail git history squash --reedit-message start.. &&
+
+ test_cmp_rev "$head_before" HEAD
+'
+
test_expect_success '--dry-run predicts the rewrite without performing it' '
git reset --hard three &&
head_before=$(git rev-parse HEAD) &&
--
gitgitgadget
^ permalink raw reply related
* [PATCH v18 1/7] branch: add --forked filter for --list mode
From: Harald Nordgren via GitGitGadget @ 2026-06-24 21:55 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master"), a remote name like
"origin" for the branch its origin/HEAD points at, or a shell glob
(e.g. "origin/*"), and may be repeated to widen the filter.
It is an ordinary list filter, so it combines with the others:
git branch --merged origin/main --forked 'origin/*'
lists branches forked from origin that are already merged into
origin/main, and --no-merged inverts the question.
This is the building block for --delete-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 12 +++-
builtin/branch.c | 18 ++++-
ref-filter.c | 70 +++++++++++++++++++
ref-filter.h | 10 +++
t/t3200-branch.sh | 122 ++++++++++++++++++++++++++++++++++
5 files changed, 229 insertions(+), 3 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..b0d66a6deb 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
[--column[=<options>] | --no-column] [--sort=<key>]
[--merged [<commit>]] [--no-merged [<commit>]]
[--contains [<commit>]] [--no-contains [<commit>]]
+ [(--forked <branch>)...]
[--points-at <object>] [--format=<format>]
[(-r|--remotes) | (-a|--all)]
[--list] [<pattern>...]
@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are
reachable from the named commit) will be listed. With `--no-merged` only
branches not merged into the named commit will be listed. If the _<commit>_
argument is missing it defaults to `HEAD` (i.e. the tip of the current
-branch).
+branch). With `--forked`, only branches whose configured upstream matches
+the given branch or pattern will be listed.
The command's second form creates a new branch head named _<branch-name>_
which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main".
Only list branches whose tips are not reachable from
_<commit>_ (`HEAD` if not specified). Implies `--list`.
+`--forked <branch>`::
+ Only list branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
+ `master`), a remote name like `origin` for the branch its
+ `origin/HEAD` points at, or a shell-style glob (e.g.
+ `'origin/*'`). The option can be repeated to widen the
+ filter. Implies `--list`.
+
`--points-at <object>`::
Only list branches of _<object>_.
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..c159f45b4c 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -30,7 +30,7 @@
#include "commit-reach.h"
static const char * const builtin_branch_usage[] = {
- N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
+ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
N_("git branch [<options>] [-l] [<pattern>...]"),
N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
free_worktrees(worktrees);
}
+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)
+{
+ struct ref_filter *filter = opt->value;
+
+ BUG_ON_OPT_NEG(unset);
+ if (ref_filter_forked_add(filter, arg) < 0)
+ die(_("'%s' is not a valid branch or pattern"), arg);
+ return 0;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -770,6 +780,9 @@ int cmd_branch(int argc,
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
+ OPT_CALLBACK_F(0, "forked", &filter, N_("branch"),
+ N_("print only branches whose upstream matches <branch> (repeatable)"),
+ PARSE_OPT_NONEG, parse_opt_forked),
OPT_COLUMN(0, "column", &colopts, N_("list branches in columns")),
OPT_REF_SORT(&sorting_options),
OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -815,7 +828,8 @@ int cmd_branch(int argc,
list = 1;
if (filter.with_commit || filter.no_commit ||
- filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
+ filter.reachable_from || filter.unreachable_from ||
+ filter.points_at.nr || filter.forked.nr)
list = 1;
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
diff --git a/ref-filter.c b/ref-filter.c
index 8ba91c72a1..6ee2328bdb 100644
--- a/ref-filter.c
+++ b/ref-filter.c
@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)
return match_pattern(filter->exclude.v, refname, filter->ignore_case);
}
+static const char *short_upstream_name(const char *full_ref)
+{
+ const char *short_name = full_ref;
+ (void)(skip_prefix(short_name, "refs/heads/", &short_name) ||
+ skip_prefix(short_name, "refs/remotes/", &short_name));
+ return short_name;
+}
+
+/*
+ * Match the configured upstream of a branch against the registered
+ * --forked patterns. Exact patterns are compared against the full
+ * upstream refname so they are unambiguous; glob patterns are matched
+ * against the abbreviated upstream so that a glob such as origin/...
+ * works as typed.
+ */
+static int filter_forked_match(struct ref_filter *filter, const char *refname)
+{
+ const char *short_name;
+ struct branch *branch;
+ const char *upstream;
+ int i;
+
+ if (!skip_prefix(refname, "refs/heads/", &short_name))
+ return 0;
+ branch = branch_get(short_name);
+ if (!branch)
+ return 0;
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream)
+ return 0;
+
+ for (i = 0; i < filter->forked.nr; i++) {
+ const char *pattern = filter->forked.v[i];
+ if (has_glob_specials(pattern)) {
+ if (!wildmatch(pattern, short_upstream_name(upstream),
+ WM_PATHNAME))
+ return 1;
+ } else if (!strcmp(pattern, upstream)) {
+ return 1;
+ }
+ }
+ return 0;
+}
+
+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)
+{
+ struct object_id oid;
+ char *full_ref = NULL;
+
+ if (has_glob_specials(arg)) {
+ strvec_push(&filter->forked, arg);
+ return 0;
+ }
+
+ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
+ &full_ref, 0) == 1 &&
+ (starts_with(full_ref, "refs/heads/") ||
+ starts_with(full_ref, "refs/remotes/"))) {
+ strvec_push(&filter->forked, full_ref);
+ free(full_ref);
+ return 0;
+ }
+ free(full_ref);
+ return -1;
+}
+
/*
* We need to seek to the reference right after a given marker but excluding any
* matching references. So we seek to the lexicographically next reference.
@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,
if (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))
return NULL;
+ if (filter->forked.nr && !filter_forked_match(filter, ref->name))
+ return NULL;
+
/*
* A merge filter is applied on refs pointing to commits. Hence
* obtain the commit using the 'oid' available and discard all
@@ -3765,6 +3834,7 @@ void ref_filter_init(struct ref_filter *filter)
void ref_filter_clear(struct ref_filter *filter)
{
strvec_clear(&filter->exclude);
+ strvec_clear(&filter->forked);
oid_array_clear(&filter->points_at);
commit_list_free(filter->with_commit);
commit_list_free(filter->no_commit);
diff --git a/ref-filter.h b/ref-filter.h
index 120221b47f..9361296e2a 100644
--- a/ref-filter.h
+++ b/ref-filter.h
@@ -67,6 +67,7 @@ struct ref_filter {
const char **name_patterns;
const char *start_after;
struct strvec exclude;
+ struct strvec forked;
struct oid_array points_at;
struct commit_list *with_commit;
struct commit_list *no_commit;
@@ -110,6 +111,7 @@ struct ref_format {
#define REF_FILTER_INIT { \
.points_at = OID_ARRAY_INIT, \
.exclude = STRVEC_INIT, \
+ .forked = STRVEC_INIT, \
}
#define REF_FORMAT_INIT { \
.use_color = GIT_COLOR_UNKNOWN, \
@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);
struct ref_sorting *ref_sorting_options(struct string_list *);
/* Function to parse --merged and --no-merged options */
int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);
+/*
+ * Register a --forked <branch> pattern on the filter. The argument is
+ * either a ref, which is resolved to its full refname, or a shell-style
+ * glob. Branches are kept only when their configured upstream matches
+ * one of the registered patterns. Returns -1 if the argument is not a
+ * valid ref or pattern.
+ */
+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);
/* Get the current HEAD's description */
char *get_head_description(void);
/* Set up translated strings in the output. */
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index e7829c2c4b..3104c555f6 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,126 @@ test_expect_success 'errors if given a bad branch name' '
test_cmp expect actual
'
+test_expect_success '--forked: setup' '
+ test_create_repo forked-upstream &&
+ (
+ cd forked-upstream &&
+ test_commit base &&
+ git branch one base &&
+ git branch two base
+ ) &&
+
+ test_create_repo forked-other &&
+ (
+ cd forked-other &&
+ test_commit other-base &&
+ git branch foreign other-base
+ ) &&
+
+ git clone forked-upstream forked &&
+ (
+ cd forked &&
+ git remote add -f other ../forked-other &&
+ git remote set-head origin one &&
+ git branch local-base &&
+ git branch --track local-one origin/one &&
+ git branch --track local-two origin/two &&
+ git branch --track local-foreign other/foreign &&
+ git branch --track local-onbase local-base &&
+
+ git checkout local-one &&
+ test_commit --no-tag local-one-work local-one.t &&
+ git checkout local-foreign &&
+ test_commit --no-tag local-foreign-work local-foreign.t &&
+ git checkout --detach
+ )
+'
+
+test_expect_success '--forked <upstream-tracking-branch> 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 <glob> 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 <local-branch> 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' '
+ test_when_finished "git -C forked checkout --detach" &&
+ git -C forked checkout local-one &&
+ test_commit -C forked local-only &&
+ git -C forked branch --forked "origin/*" --no-merged origin/one \
+ --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked rejects unknown branch/pattern' '
+ test_must_fail git -C forked branch --forked nope 2>err &&
+ test_grep "not a valid branch or pattern" err
+'
+
+test_expect_success '--forked requires a value' '
+ test_must_fail git -C forked branch --forked 2>err &&
+ test_grep "requires a value" err
+'
+
+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '
+ git -C forked branch --forked origin --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked narrows a <pattern> argument' '
+ git -C forked branch --forked "origin/*" "local-*" \
+ --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ EOF
+ test_cmp expect actual
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v18 0/7] branch: delete-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-24 21:54 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
In-Reply-To: <pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com>
Delete branches that have already been merged on upstream.
Changes in v18:
* Instead of keeping the whole chain of upstream branches, keep only the
ones an unmerged branch still needs. When a kept (merged) branch in turn
tracks a branch that is being deleted, clear its now-stale upstream
config.
* Rework spare_stacked_bases() to record the kept bases and, in a second
pass, clear the upstream of any whose own base is going away. Build the
to-delete list with strset_for_each_entry() instead of re-walking the
candidate array.
Changes in v17:
* Keep a merged branch when another surviving branch still tracks it as its
upstream, so --delete-merged no longer deletes a branch out from under
one stacked on top of it.
* Move the --dry-run and branch.<name>.deleteMerged opt-out fully into
their own commits.
Changes in v16:
* Convert delete_merged_branches() to take an unsigned int flags argument
instead of separate quiet/dry_run booleans, matching delete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,
single strbuf_release after) instead of allocating and freeing it each
time
* Rewrite the --delete-merged tests as integration tests: branches that
land commits upstream, with deletion and the checked-out, upstream-gone,
and push-equals-upstream safety cases exercised together in one run and
output asserted via test_cmp
* Collapse the many per-aspect test repos into a single reused repo set up
by a setup_repo_for_delete_merged helper, and rename helpers off the old
pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of
prefixing every command with -C
Changes in v15:
* Renamed --prune-merged to --delete-merged throughout. Not necessarily
final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of
warning.
* Initialized the delete_branches() flag locals where declared. Only force
stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant
branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the
unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f,
--forked coverage), renamed the misleading trunk fixture, and replaced
the misnamed detached branch with git checkout --detach.
Changes in v14:
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the
remotes path set a local force but not the DELETE_BRANCH_FORCE bit that
check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and
the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other
branches too, hopefully not related
(https://github.com/git/git/pull/2285).
Changes in v13:
* Reworked --forked into a real ref-filter applied in apply_ref_filter()
instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the
abbreviated upstream), and dropped the old helper machinery, forward
declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of
delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its
own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git
branch --prune-merged origin/main 'feature*') instead of repeating the
option.
Changes in v12:
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST
options.
* Dropped the bare-remote-name resolution for --forked, the argument is now
a ref or a glob.
Changes in v11:
* The flags now take a branch, not a remote. --forked and --prune-merged
accept a literal upstream short name like origin/main or a wildmatch
pattern like origin/. The old --all-remotes flag is gone, since origin/
covers that case.
* The prune guard now compares @{push} against @{upstream}. A branch is
spared when these are equal. That is the trunk like case, such as local
main tracking and pushing to origin/main, where "fully merged to
upstream" cannot be told apart from "just pulled". Only branches that
push somewhere other than their upstream, typically fork based topics,
are candidates. The earlier /HEAD by name guard that the reviewer
rejected is gone.
* New --dry-run for --prune-merged.
Changes in v10:
* --forked / --prune-merged now take a branch glob instead of a remote name
— origin, origin/*, origin/release-- all work. This replaces the
remote-only form and subsumes the old --all-remotes flag, which has been
dropped.
* New --dry-run for --prune-merged.
Changes in v9:
* --force no longer has special meaning with --prune-merged; reachability
is always enforced. Use git branch -D to delete an unmerged branch.
Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.
Changes in v8:
* Delete only when the branch's work is actually reachable from its
upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place
Changes in v7:
* --prune-merged now checks if a branch is merged into its own upstream
first. If the upstream is gone, it checks against the remote's default
branch instead. If neither exists, the branch is refused (use --force to
delete anyway).
Changes in v6:
* --prune-merged now measures merged-ness against the remote's default
branch instead of the candidate's upstream — so the decision no longer
depends on which branch happens to be checked out locally.
* delete_branches() / check_branch_commit() gained a per-candidate override
that lets a caller substitute a different "what counts as merged"
reference (or skip the check). branch -d callers pass NULL and keep their
existing semantics.
* prune_merged_branches() resolves each candidate's push-remote HEAD and
threads it through, so --prune-merged --all-remotes measures each
candidate against its own remote rather than a single global reference.
Changes in v5:
* Drop commit 'fetch: add --prune-merged'
Changes in v4:
* Resolve each remote's HEAD and collect the targets into a
protected_default_refs set in collect_forked_set.
* In prune_merged_branches, skip a candidate when its upstream is a
protected default ref and the local branch name matches the default
branch's leaf name (so a local main tracking origin/main is spared, but a
renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default
ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name
(not by upstream alone); spare a branch whose push ref is the remote
default.
Changes in v3:
* s/remote-tracking refs/remote-tracking branches/g
Changes in v2:
* The whole feature moved out of git fetch and into git branch. git fetch
--prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config
options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the
given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their
tip is reachable from the upstream tracking ref; --force skips that
safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across
every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running
topic branch) even with --force; doesn't affect explicit git branch -d.
* delete_branches() got a warn_only mode so bulk deletion prints a one-line
warning per skipped branch instead of the noisy four-line hint that git
branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention
--prune-merged.
* New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh
shrunk since most logic moved.
Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <branch>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 48 ++++-
builtin/branch.c | 266 +++++++++++++++++++++---
ref-filter.c | 70 +++++++
ref-filter.h | 10 +
t/t3200-branch.sh | 342 +++++++++++++++++++++++++++++++
6 files changed, 715 insertions(+), 28 deletions(-)
base-commit: ab776a62a78576513ee121424adb19597fbb7613
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v18
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v18
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v17:
1: d8cc17bd7f = 1: 3e29ff17bd branch: add --forked filter for --list mode
2: d14b0403f0 = 2: cdd4fea4a7 branch: convert delete_branches() to a flags argument
3: ef2719dac3 = 3: a0fd5b4a6c branch: let delete_branches skip unmerged branches on bulk refusal
4: 80518f5d11 = 4: a56d8fe93e branch: prepare delete_branches for a bulk caller
5: 46da7c8140 ! 5: a84c555d99 branch: add --delete-merged <branch>
@@ Commit message
upstream. The work has already landed on the upstream they track,
so the local copy is no longer needed.
- Three kinds of branches are not deleted:
+ A branch is not deleted when:
- * any branch checked out in any worktree
- * any branch whose upstream remote-tracking branch no longer
- exists, since a missing upstream is not by itself a sign of
- integration
- * any branch whose push destination equals its upstream
- (<branch>@{push} is the same as <branch>@{upstream}), such as
- a local "main" that tracks and pushes to "origin/main". Right
- after a pull it just looks "fully merged", so it is kept. Only
- branches that push somewhere other than their upstream,
- typically topics in a fork workflow, are candidates.
+ * it is checked out in any worktree
+ * its upstream remote-tracking branch no longer exists, since a
+ missing upstream is not by itself a sign of integration
+ * its push destination equals its upstream (<branch>@{push} is
+ the same as <branch>@{upstream}), such as a local "main" that
+ tracks and pushes to "origin/main". Right after a pull it just
+ looks "fully merged", so it is kept. Only branches that push
+ somewhere other than their upstream, typically topics in a fork
+ workflow, are candidates.
A branch whose work is not yet merged into its upstream is silently
skipped, so one unmerged topic does not abort the whole sweep.
A branch that another, surviving branch tracks as its upstream is
also kept, so a branch is never deleted out from under one stacked
- on top of it. Sparing such a base can in turn protect its own
- upstream, so the check repeats until the set stops changing.
+ on top of it. Such a kept branch is itself merged, so when its own
+ upstream is being deleted, clear its now-stale upstream config.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
+silently skipped. Delete it with `git branch -D` if you want to
+remove it anyway.
++
-+A branch that another, surviving branch still tracks as its upstream
-+is kept, so a branch is never deleted out from under one stacked on
-+top of it.
++A branch that another, surviving branch tracks as its upstream is
++kept, so a branch is never deleted out from under one stacked on top
++of it. If that kept branch in turn tracks a branch that is being
++deleted, its now-stale upstream configuration is cleared.
+
`-v`::
`-vv`::
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
return 0;
}
-+static int collect_upstream(const struct reference *ref, void *cb_data)
-+{
-+ struct string_list *upstreams = cb_data;
-+ struct branch *branch = branch_get(ref->name);
-+ const char *upstream = branch_get_upstream(branch, NULL);
++struct spare_data {
++ struct strset *deletable;
++ struct strset *spared;
++};
+
-+ string_list_append(upstreams, ref->name)->util =
-+ xstrdup_or_null(upstream);
++/*
++ * A surviving branch stacked on a deletion candidate would lose its
++ * upstream, so drop that candidate from the delete set and remember it
++ * in "spared" so its own upstream can be tidied up afterwards.
++ */
++static int spare_stacked_base(const struct reference *ref, void *cb_data)
++{
++ struct spare_data *data = cb_data;
++ struct branch *branch;
++ const char *upstream, *up_short;
++
++ if (strset_contains(data->deletable, ref->name))
++ return 0;
++ branch = branch_get(ref->name);
++ upstream = branch_get_upstream(branch, NULL);
++ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
++ !strset_contains(data->deletable, up_short))
++ return 0;
++
++ strset_remove(data->deletable, up_short);
++ strset_add(data->spared, up_short);
+ return 0;
+}
+
+/*
-+ * Keep any branch that another, surviving branch tracks as its
-+ * upstream, so we never delete a branch out from under one stacked on
-+ * top of it. Sparing a branch makes it a survivor whose own upstream
-+ * then needs the same protection, so repeat until nothing changes.
++ * Keep any branch that a surviving branch tracks as its upstream, so we
++ * never delete a branch out from under one stacked on top of it. Such a
++ * base is itself merged, so when its own upstream is also going away
++ * (no surviving branch tracks it), clear the base's now-stale upstream.
+ */
+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
+{
-+ struct string_list upstreams = STRING_LIST_INIT_DUP;
-+ struct string_list_item *item;
-+ bool spared;
-+
-+ refs_for_each_branch_ref(refs, collect_upstream, &upstreams);
-+ do {
-+ spared = false;
-+ for_each_string_list_item(item, &upstreams) {
-+ const char *up = item->util, *up_short;
-+
-+ if (!up || strset_contains(deletable, item->string))
-+ continue;
-+ if (!skip_prefix(up, "refs/heads/", &up_short) ||
-+ !strset_contains(deletable, up_short))
-+ continue;
-+
-+ strset_remove(deletable, up_short);
-+ spared = true;
-+ }
-+ } while (spared);
-+
-+ string_list_clear(&upstreams, 1);
++ struct strset spared = STRSET_INIT;
++ struct spare_data data = { .deletable = deletable, .spared = &spared };
++ struct strbuf key = STRBUF_INIT;
++ struct hashmap_iter iter;
++ struct strmap_entry *entry;
++
++ refs_for_each_branch_ref(refs, spare_stacked_base, &data);
++
++ strset_for_each_entry(&spared, &iter, entry) {
++ struct branch *branch = branch_get(entry->key);
++ const char *upstream = branch_get_upstream(branch, NULL);
++ const char *up_short;
++
++ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
++ !strset_contains(deletable, up_short))
++ continue;
++
++ strbuf_reset(&key);
++ strbuf_addf(&key, "branch.%s.merge", branch->name);
++ repo_config_set_gently(the_repository, key.buf, NULL);
++ strbuf_reset(&key);
++ strbuf_addf(&key, "branch.%s.remote", branch->name);
++ repo_config_set_gently(the_repository, key.buf, NULL);
++ }
++
++ strbuf_release(&key);
++ strset_clear(&spared);
+}
+
+static int delete_merged_branches(int argc, const char **argv,
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ struct ref_array candidates = { 0 };
+ struct strset deletable = STRSET_INIT;
+ struct strvec to_delete = STRVEC_INIT;
++ struct hashmap_iter iter;
++ struct strmap_entry *entry;
+ int i, ret = 0;
+
+ if (!argc)
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+
+ spare_stacked_bases(refs, &deletable);
+
-+ for (i = 0; i < candidates.nr; i++) {
-+ const char *short_name;
-+
-+ if (skip_prefix(candidates.items[i]->refname, "refs/heads/",
-+ &short_name) &&
-+ strset_contains(&deletable, short_name))
-+ strvec_push(&to_delete, short_name);
-+ }
++ strset_for_each_entry(&deletable, &iter, entry)
++ strvec_push(&to_delete, entry->key);
+
+ if (to_delete.nr)
+ ret = delete_branches(to_delete.nr, to_delete.v,
@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
+ git checkout --detach
+ ) &&
+
++ git -C repo branch --dry-run --delete-merged origin/next >out &&
++ test_grep ! "feature" out &&
++
+ git -C repo branch --delete-merged origin/next 2>err &&
+
+ test_must_be_empty err &&
+ git -C repo rev-parse --verify refs/heads/feature &&
-+ git -C repo rev-parse --verify refs/heads/topic
++ git -C repo rev-parse --verify refs/heads/topic &&
++ echo origin/next >expect &&
++ git -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&
++ test_cmp expect actual &&
++ echo feature >expect &&
++ git -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&
++ test_cmp expect actual
+'
+
+test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '
@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
+ EOF
+ test_cmp expect actual
+'
++
++test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '
++ test_when_finished "rm -rf repo" &&
++ setup_repo_for_delete_merged &&
++ (
++ cd repo &&
++ git branch lower origin/next &&
++ git branch --set-upstream-to=origin/next lower &&
++ git branch mid origin/next &&
++ git branch --set-upstream-to=lower mid &&
++ git checkout -b tip mid &&
++ git commit --allow-empty -m "tip work" &&
++ git branch --set-upstream-to=mid tip &&
++ git checkout --detach
++ ) &&
++
++ git -C repo branch --delete-merged origin/next lower &&
++
++ test_must_fail git -C repo rev-parse --verify refs/heads/lower &&
++ git -C repo rev-parse --verify refs/heads/mid &&
++ test_must_fail git -C repo rev-parse mid@{upstream} &&
++ echo mid >expect &&
++ git -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&
++ test_cmp expect actual
++'
+
test_done
6: 27903fbb1d ! 6: d52d717b70 branch: add branch.<name>.deleteMerged opt-out
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
struct strset deletable = STRSET_INIT;
struct strvec to_delete = STRVEC_INIT;
+ struct strbuf key = STRBUF_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+ bool quiet = flags & DELETE_BRANCH_QUIET;
int i, ret = 0;
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
ref_array_clear(&candidates);
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the upstream of a kept base whose ow
test_cmp expect actual
'
7: 49c1bcf1fb ! 7: 8d0323f4b3 branch: add --dry-run for --delete-merged
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: A branch that another, surviving branch still tracks as its upstream
- is kept, so a branch is never deleted out from under one stacked on
- top of it.
+@@ Documentation/git-branch.adoc: kept, so a branch is never deleted out from under one stacked on top
+ of it. If that kept branch in turn tracks a branch that is being
+ deleted, its now-stale upstream configuration is cleared.
+`--dry-run`::
+ With `--delete-merged`, print which branches would be
--
gitgitgadget
^ permalink raw reply
* [PATCH v18 2/7] branch: convert delete_branches() to a flags argument
From: Harald Nordgren via GitGitGadget @ 2026-06-24 21:55 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
delete_branches() and check_branch_commit() take a pair of int
booleans (force and quiet) that the next commits would grow further.
Replace them with a single "unsigned int flags" argument and an
enum, splitting the bits back into named bool locals so the body
keeps reading the same named values.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 36 ++++++++++++++++++++++++------------
1 file changed, 24 insertions(+), 12 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index c159f45b4c..a9be980aef 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -189,10 +189,16 @@ static int branch_merged(int kind, const char *name,
return merged;
}
+enum delete_branch_flags {
+ DELETE_BRANCH_FORCE = (1 << 0),
+ DELETE_BRANCH_QUIET = (1 << 1),
+};
+
static int check_branch_commit(const char *branchname, const char *refname,
const struct object_id *oid, struct commit *head_rev,
- int kinds, int force)
+ int kinds, unsigned int flags)
{
+ bool force = flags & DELETE_BRANCH_FORCE;
struct commit *rev = lookup_commit_reference(the_repository, oid);
if (!force && !rev) {
error(_("couldn't look up commit object for '%s'"), refname);
@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)
strbuf_release(&buf);
}
-static int delete_branches(int argc, const char **argv, int force, int kinds,
- int quiet)
+static int delete_branches(int argc, const char **argv, int kinds,
+ unsigned int flags)
{
struct commit *head_rev = NULL;
struct object_id oid;
@@ -227,6 +233,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
int i;
int ret = 0;
int remote_branch = 0;
+ bool force;
+ bool quiet = flags & DELETE_BRANCH_QUIET;
struct strbuf bname = STRBUF_INIT;
enum interpret_branch_kind allowed_interpret;
struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ -241,7 +249,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
remote_branch = 1;
allowed_interpret = INTERPRET_BRANCH_REMOTE;
- force = 1;
+ flags |= DELETE_BRANCH_FORCE;
break;
case FILTER_REFS_BRANCHES:
fmt = "refs/heads/%s";
@@ -252,12 +260,14 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
}
branch_name_pos = strcspn(fmt, "%");
+ force = flags & DELETE_BRANCH_FORCE;
+
if (!force)
head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
char *target = NULL;
- int flags = 0;
+ int ref_flags = 0;
copy_branchname(&bname, argv[i], allowed_interpret);
free(name);
@@ -279,7 +289,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
RESOLVE_REF_READING
| RESOLVE_REF_NO_RECURSE
| RESOLVE_REF_ALLOW_BAD_NAME,
- &oid, &flags);
+ &oid, &ref_flags);
if (!target) {
if (remote_branch) {
error(_("remote-tracking branch '%s' not found"), bname.buf);
@@ -291,7 +301,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
| RESOLVE_REF_NO_RECURSE
| RESOLVE_REF_ALLOW_BAD_NAME,
&oid,
- &flags);
+ &ref_flags);
FREE_AND_NULL(virtual_name);
if (virtual_target)
@@ -306,16 +316,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
continue;
}
- if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
+ if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
- force)) {
+ flags)) {
ret = 1;
goto next;
}
item = string_list_append(&refs_to_delete, name);
- item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
- : (flags & REF_ISSYMREF) ? target
+ item->util = xstrdup((ref_flags & REF_ISBROKEN) ? "broken"
+ : (ref_flags & REF_ISSYMREF) ? target
: repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));
next:
@@ -872,7 +882,9 @@ int cmd_branch(int argc,
if (delete) {
if (!argc)
die(_("branch name required"));
- ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
+ ret = delete_branches(argc, argv, filter.kind,
+ (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
+ (quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
} else if (show_current) {
print_current_branch_name();
--
gitgitgadget
^ permalink raw reply related
* [PATCH v18 3/7] branch: let delete_branches skip unmerged branches on bulk refusal
From: Harald Nordgren via GitGitGadget @ 2026-06-24 21:55 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
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 <haraldnordgren@gmail.com>
---
builtin/branch.c | 17 ++++++++++++-----
1 file changed, 12 insertions(+), 5 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index a9be980aef..4c569d056a 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,
@@ -199,16 +200,20 @@ static int check_branch_commit(const char *branchname, const char *refname,
int kinds, unsigned int flags)
{
bool force = flags & DELETE_BRANCH_FORCE;
+ bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
struct commit *rev = lookup_commit_reference(the_repository, oid);
if (!force && !rev) {
error(_("couldn't look up commit object for '%s'"), refname);
return -1;
}
if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
- error(_("the branch '%s' is not fully merged"), branchname);
- advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
- _("If you are sure you want to delete it, "
- "run 'git branch -D %s'"), branchname);
+ if (!skip_unmerged) {
+ error(_("the branch '%s' is not fully merged"),
+ branchname);
+ advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
+ _("If you are sure you want to delete it, "
+ "run 'git branch -D %s'"), branchname);
+ }
return -1;
}
return 0;
@@ -235,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int kinds,
int remote_branch = 0;
bool force;
bool quiet = flags & DELETE_BRANCH_QUIET;
+ bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
struct strbuf bname = STRBUF_INIT;
enum interpret_branch_kind allowed_interpret;
struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ -319,7 +325,8 @@ static int delete_branches(int argc, const char **argv, int kinds,
if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
flags)) {
- ret = 1;
+ if (!skip_unmerged)
+ ret = 1;
goto next;
}
--
gitgitgadget
^ permalink raw reply related
* [PATCH v18 4/7] branch: prepare delete_branches for a bulk caller
From: Harald Nordgren via GitGitGadget @ 2026-06-24 21:55 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Teach delete_branches() two new modes for the upcoming
--delete-merged: one that asks only whether a branch is merged into
its upstream, without falling back to HEAD when there is no
upstream, and one that rehearses the deletions without removing any
ref. Existing callers keep their current behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index 4c569d056a..01c1f64c73 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,
@@ -241,6 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds,
bool force;
bool quiet = flags & DELETE_BRANCH_QUIET;
bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
+ bool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
struct strbuf bname = STRBUF_INIT;
enum interpret_branch_kind allowed_interpret;
struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ -268,7 +273,7 @@ static int delete_branches(int argc, const char **argv, int kinds,
force = flags & DELETE_BRANCH_FORCE;
- if (!force)
+ if (!force && !no_head_fallback)
head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
--
gitgitgadget
^ permalink raw reply related
* [PATCH v2 0/2] branch/push: suggest intended form when remote/branch slip given
From: Harald Nordgren via GitGitGadget @ 2026-06-24 21:55 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren
In-Reply-To: <pull.2331.git.git.1781262619.gitgitgadget@gmail.com>
When the repository or upstream argument is a slip like "origin/main" or
"origin main", suggest the intended "git push origin main" or "git branch
--set-upstream-to=origin/main" form instead of failing with an unrelated
error.
Changes in v2:
* Rewrote both commit messages to lead with the intended command, the easy
slip, and the resulting error, instead of the terse original.
* Gated each suggestion on advice_enabled() up front, so a user who
silenced the hint pays no remote/ref lookups and falls through to the
original error. Extracted the detection logic into helpers
(die_if_repo_looks_like_ref, die_if_upstream_looks_like_remote) so each
call site reads as a single guarded line.
Harald Nordgren (2):
branch: suggest <remote>/<branch> on upstream slip
push: suggest <remote> <branch> for a slash slip
Documentation/config/advice.adoc | 5 +++++
advice.c | 1 +
advice.h | 1 +
builtin/branch.c | 26 ++++++++++++++++++++++
builtin/push.c | 31 +++++++++++++++++++++++++-
t/t3200-branch.sh | 38 ++++++++++++++++++++++++++++++++
t/t5529-push-errors.sh | 31 ++++++++++++++++++++++++++
7 files changed, 132 insertions(+), 1 deletion(-)
base-commit: ab776a62a78576513ee121424adb19597fbb7613
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2331%2FHaraldNordgren%2Fsuggest-remote-branch-slips-v2
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2331/HaraldNordgren/suggest-remote-branch-slips-v2
Pull-Request: https://github.com/git/git/pull/2331
Range-diff vs v1:
1: 21684539de ! 1: 11bcecebf4 branch: suggest <remote>/<branch> on upstream slip
@@ Metadata
## Commit message ##
branch: suggest <remote>/<branch> on upstream slip
- "git branch --set-upstream-to origin main" reads the trailing word as
- the local branch to operate on and dies with "branch 'main' does not
- exist", pointing at the wrong problem.
+ When setting the upstream of the current branch to the 'main' branch
+ of the remote 'origin', i.e.,
- When that branch is missing and "<remote>/<branch>" names a real
- remote-tracking ref, suggest the intended
- "git branch --set-upstream-to=<remote>/<branch>" form.
+ $ git branch --set-upstream-to origin/main
+
+ it is easy to mistakenly write
+
+ $ git branch --set-upstream-to origin main
+
+ That is parsed as a request to set the upstream of the local branch
+ 'main' to 'origin'. When 'main' does not exist, the command dies
+ with:
+
+ fatal: branch 'main' does not exist
+
+ pointing at a branch the user never meant to name.
+
+ When the operated-on branch is missing and '<remote>/<branch>' names
+ a real remote-tracking ref, suggest the intended form:
+
+ $ git branch --set-upstream-to=origin/main
+
+ The suggestion is gated on '<remote>/<branch>' existing so it only
+ appears when a slipped slash is the likely explanation.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## builtin/branch.c ##
+@@ builtin/branch.c: static int edit_branch_description(const char *branch_name)
+ return 0;
+ }
+
++static void die_if_upstream_looks_like_remote(const char *new_upstream, const char *branch_name)
++{
++ struct strbuf remote_ref = STRBUF_INIT;
++ int code;
++
++ if (strchr(new_upstream, '/') ||
++ !remote_is_configured(remote_get(new_upstream), 0))
++ return;
++
++ strbuf_addf(&remote_ref, "refs/remotes/%s/%s", new_upstream, branch_name);
++ if (!refs_ref_exists(get_main_ref_store(the_repository), remote_ref.buf)) {
++ strbuf_release(&remote_ref);
++ return;
++ }
++
++ code = die_message(_("--set-upstream-to takes a single <remote>/<branch> argument"));
++ advise_if_enabled(ADVICE_SET_UPSTREAM_FAILURE,
++ _("Did you mean to use: git branch --set-upstream-to=%s/%s?"),
++ new_upstream, branch_name);
++ strbuf_release(&remote_ref);
++ exit(code);
++}
++
+ int cmd_branch(int argc,
+ const char **argv,
+ const char *prefix,
@@ builtin/branch.c: int cmd_branch(int argc,
if (!refs_ref_exists(get_main_ref_store(the_repository), branch->refname)) {
if (!argc || branch_checked_out(branch->refname))
die(_("no commit on branch '%s' yet"), branch->name);
-+ if (argc == 1 && !strchr(new_upstream, '/') &&
-+ remote_is_configured(remote_get(new_upstream), 0)) {
-+ struct strbuf remote_ref = STRBUF_INIT;
-+
-+ strbuf_addf(&remote_ref, "refs/remotes/%s/%s",
-+ new_upstream, argv[0]);
-+ if (refs_ref_exists(get_main_ref_store(the_repository),
-+ remote_ref.buf)) {
-+ int code = die_message(_("--set-upstream-to takes a single <remote>/<branch> argument"));
-+ advise_if_enabled(ADVICE_SET_UPSTREAM_FAILURE,
-+ _("Did you mean to use: git branch --set-upstream-to=%s/%s?"),
-+ new_upstream, argv[0]);
-+ strbuf_release(&remote_ref);
-+ exit(code);
-+ }
-+ strbuf_release(&remote_ref);
-+ }
++ if (argc == 1 &&
++ advice_enabled(ADVICE_SET_UPSTREAM_FAILURE))
++ die_if_upstream_looks_like_remote(new_upstream, argv[0]);
die(_("branch '%s' does not exist"), branch->name);
}
2: ea1412b110 ! 2: 49de5a925d push: suggest <remote> <branch> for a slash slip
@@ Metadata
## Commit message ##
push: suggest <remote> <branch> for a slash slip
- "git push origin/main" is treated as a repository and dies with
- "'origin/main' does not appear to be a git repository", with no hint
- that a space was meant instead of a slash.
+ When pushing the 'main' branch to the remote 'origin', i.e.,
- When the argument is not an existing path or configured remote but its
- part before the first slash names one, suggest the intended
- "git push <remote> <branch>" form. The suggestion is shown as advice so
- it can be silenced with advice.pushRepoLooksLikeRef.
+ $ git push origin main
+
+ it is easy to mistakenly write
+
+ $ git push origin/main
+
+ That is parsed as the repository to push to, and since 'origin/main'
+ is neither a configured remote nor a path it dies with:
+
+ fatal: 'origin/main' does not appear to be a git repository
+
+ Often 'origin/main' does not exist as a repository, so the command
+ fails without doing any harm, but it gives no hint that a space was
+ meant instead of a slash and can leave the user puzzled.
+
+ When the argument is not an existing path or configured remote but
+ its part before the first slash names one, suggest the intended
+ '<remote> <branch>' form:
+
+ $ git push origin main
+
+ The suggestion is shown as advice so it can be silenced with
+ advice.pushRepoLooksLikeRef.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ builtin/push.c
#include "environment.h"
#include "gettext.h"
#include "hex.h"
+@@ builtin/push.c: static int push_multiple(struct string_list *list,
+ return result;
+ }
+
++static void die_if_repo_looks_like_ref(const char *repo)
++{
++ const char *slash = strchr(repo, '/');
++ struct strbuf name = STRBUF_INIT;
++ int code;
++
++ if (!slash || !slash[1] || file_exists(repo))
++ return;
++
++ strbuf_add(&name, repo, slash - repo);
++ if (!remote_is_configured(remote_get(name.buf), 0)) {
++ strbuf_release(&name);
++ return;
++ }
++
++ code = die_message(_("'%s' is not a valid push target"), repo);
++ advise_if_enabled(ADVICE_PUSH_REPO_LOOKS_LIKE_REF,
++ _("Did you mean to use: git push %s %s?"),
++ name.buf, slash + 1);
++ strbuf_release(&name);
++ exit(code);
++}
++
+ int cmd_push(int argc,
+ const char **argv,
+ const char *prefix,
@@ builtin/push.c: int cmd_push(int argc,
if (repo) {
if (!add_remote_or_group(repo, &remote_group)) {
-+ const char *slash = strchr(repo, '/');
+ struct remote *r;
+
-+ /*
-+ * A "<remote>/<branch>" argument that does not name
-+ * a path is likely a slip for the separate
-+ * "<remote> <branch>" form, so suggest that instead.
-+ */
-+ if (slash && slash[1] && !file_exists(repo)) {
-+ struct strbuf name = STRBUF_INIT;
-+
-+ strbuf_add(&name, repo, slash - repo);
-+ if (remote_is_configured(remote_get(name.buf), 0)) {
-+ int code = die_message(_("'%s' is not a valid push target"), repo);
-+ advise_if_enabled(ADVICE_PUSH_REPO_LOOKS_LIKE_REF,
-+ _("Did you mean to use: git push %s %s?"),
-+ name.buf, slash + 1);
-+ strbuf_release(&name);
-+ exit(code);
-+ }
-+ strbuf_release(&name);
-+ }
++ if (advice_enabled(ADVICE_PUSH_REPO_LOOKS_LIKE_REF))
++ die_if_repo_looks_like_ref(repo);
+
/*
* Not a configured remote name or group name.
--
gitgitgadget
^ permalink raw reply
* [PATCH v2 1/2] branch: suggest <remote>/<branch> on upstream slip
From: Harald Nordgren via GitGitGadget @ 2026-06-24 21:55 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2331.v2.git.git.1782338114.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
When setting the upstream of the current branch to the 'main' branch
of the remote 'origin', i.e.,
$ git branch --set-upstream-to origin/main
it is easy to mistakenly write
$ git branch --set-upstream-to origin main
That is parsed as a request to set the upstream of the local branch
'main' to 'origin'. When 'main' does not exist, the command dies
with:
fatal: branch 'main' does not exist
pointing at a branch the user never meant to name.
When the operated-on branch is missing and '<remote>/<branch>' names
a real remote-tracking ref, suggest the intended form:
$ git branch --set-upstream-to=origin/main
The suggestion is gated on '<remote>/<branch>' existing so it only
appears when a slipped slash is the likely explanation.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 26 ++++++++++++++++++++++++++
t/t3200-branch.sh | 38 ++++++++++++++++++++++++++++++++++++++
2 files changed, 64 insertions(+)
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..cefc4519a7 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -706,6 +706,29 @@ static int edit_branch_description(const char *branch_name)
return 0;
}
+static void die_if_upstream_looks_like_remote(const char *new_upstream, const char *branch_name)
+{
+ struct strbuf remote_ref = STRBUF_INIT;
+ int code;
+
+ if (strchr(new_upstream, '/') ||
+ !remote_is_configured(remote_get(new_upstream), 0))
+ return;
+
+ strbuf_addf(&remote_ref, "refs/remotes/%s/%s", new_upstream, branch_name);
+ if (!refs_ref_exists(get_main_ref_store(the_repository), remote_ref.buf)) {
+ strbuf_release(&remote_ref);
+ return;
+ }
+
+ code = die_message(_("--set-upstream-to takes a single <remote>/<branch> argument"));
+ advise_if_enabled(ADVICE_SET_UPSTREAM_FAILURE,
+ _("Did you mean to use: git branch --set-upstream-to=%s/%s?"),
+ new_upstream, branch_name);
+ strbuf_release(&remote_ref);
+ exit(code);
+}
+
int cmd_branch(int argc,
const char **argv,
const char *prefix,
@@ -957,6 +980,9 @@ int cmd_branch(int argc,
if (!refs_ref_exists(get_main_ref_store(the_repository), branch->refname)) {
if (!argc || branch_checked_out(branch->refname))
die(_("no commit on branch '%s' yet"), branch->name);
+ if (argc == 1 &&
+ advice_enabled(ADVICE_SET_UPSTREAM_FAILURE))
+ die_if_upstream_looks_like_remote(new_upstream, argv[0]);
die(_("branch '%s' does not exist"), branch->name);
}
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index e7829c2c4b..e2682a83a0 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1022,6 +1022,44 @@ test_expect_success '--set-upstream-to fails on a missing dst branch' '
test_cmp expect err
'
+test_expect_success '--set-upstream-to suggests <remote>/<branch> on slip' '
+ test_when_finished "git remote remove slip-remote" &&
+ git remote add slip-remote . &&
+ git update-ref refs/remotes/slip-remote/slip-feature HEAD &&
+ test_must_fail git branch --set-upstream-to slip-remote slip-feature 2>err &&
+ test_grep "takes a single <remote>/<branch> argument" err &&
+ test_grep "hint: Did you mean to use: git branch --set-upstream-to=slip-remote/slip-feature?" err &&
+ test_must_fail git -c advice.setUpstreamFailure=false \
+ branch --set-upstream-to slip-remote slip-feature 2>err &&
+ test_grep ! "Did you mean" err
+'
+
+test_expect_success '--set-upstream-to does not suggest when no matching remote ref' '
+ test_when_finished "git remote remove slip-remote" &&
+ git remote add slip-remote . &&
+ test_must_fail git branch --set-upstream-to slip-remote no-such-branch 2>err &&
+ test_grep "branch ${SQ}no-such-branch${SQ} does not exist" err &&
+ test_grep ! "Did you mean" err
+'
+
+test_expect_success '--set-upstream-to to a local branch is not mistaken for a slip' '
+ git branch slip-local-upstream &&
+ git branch slip-local-target &&
+ git branch --set-upstream-to=slip-local-upstream slip-local-target 2>err &&
+ test_grep ! "Did you mean" err &&
+ echo refs/heads/slip-local-upstream >expect &&
+ git config branch.slip-local-target.merge >actual &&
+ test_cmp expect actual
+'
+
+test_expect_success '--set-upstream-to slip suggestion keeps a slashed branch name' '
+ test_when_finished "git remote remove slip-remote" &&
+ git remote add slip-remote . &&
+ git update-ref refs/remotes/slip-remote/slip/feature HEAD &&
+ test_must_fail git branch --set-upstream-to slip-remote slip/feature 2>err &&
+ test_grep "hint: Did you mean to use: git branch --set-upstream-to=slip-remote/slip/feature?" err
+'
+
test_expect_success '--set-upstream-to fails on a missing src branch' '
test_must_fail git branch --set-upstream-to does-not-exist main 2>err &&
test_grep "the requested upstream branch '"'"'does-not-exist'"'"' does not exist" err
--
gitgitgadget
^ permalink raw reply related
* [PATCH v18 6/7] branch: add branch.<name>.deleteMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-06-24 21:55 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.deleteMerged=false exempts that branch from
"git branch --delete-merged", which is useful for a topic you want
to keep developing after an early round of it has been merged
upstream. Unless --quiet is given, each skip is reported so the
user knows why their topic was kept.
Explicit deletion with "git branch -d" still uses the normal merge
check and ignores this setting.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/config/branch.adoc | 7 +++++++
Documentation/git-branch.adoc | 5 +++--
builtin/branch.c | 15 +++++++++++++++
t/t3200-branch.sh | 26 ++++++++++++++++++++++++++
4 files changed, 51 insertions(+), 2 deletions(-)
diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index a4db9fa5c8..d8483acb4f 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.<name>.deleteMerged`::
+ If set to `false`, branch _<name>_ is exempt from
+ `git branch --delete-merged`. Useful for a topic branch you
+ intend to develop further after an initial round has been
+ merged upstream. Defaults to true. Explicit deletion via
+ `git branch -d` is unaffected.
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 66b1c87c55..d482cded3d 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -215,10 +215,11 @@ A branch is not deleted when:
+
--
* its upstream remote-tracking branch no longer exists,
-* it is checked out in any worktree, or
+* it is checked out in any worktree,
* its push destination (`<branch>@{push}`) equals its upstream
(`<branch>@{upstream}`), so it cannot be distinguished from a
- branch that just looks "fully merged" right after a pull.
+ branch that just looks "fully merged" right after a pull, or
+* `branch.<name>.deleteMerged` is set to `false`.
--
+
A branch whose work has not yet been merged into its upstream is
diff --git a/builtin/branch.c b/builtin/branch.c
index d12a2f57ea..bce85cb52e 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -781,8 +781,10 @@ static int delete_merged_branches(int argc, const char **argv,
struct ref_array candidates = { 0 };
struct strset deletable = STRSET_INIT;
struct strvec to_delete = STRVEC_INIT;
+ struct strbuf key = STRBUF_INIT;
struct hashmap_iter iter;
struct strmap_entry *entry;
+ bool quiet = flags & DELETE_BRANCH_QUIET;
int i, ret = 0;
if (!argc)
@@ -800,6 +802,7 @@ static int delete_merged_branches(int argc, const char **argv,
const char *short_name;
struct branch *branch;
const char *upstream, *push;
+ int opt_out;
if (!skip_prefix(full_name, "refs/heads/", &short_name))
BUG("filter returned non-branch ref '%s'", full_name);
@@ -818,6 +821,17 @@ static int delete_merged_branches(int argc, const char **argv,
FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
continue;
+ strbuf_reset(&key);
+ strbuf_addf(&key, "branch.%s.deletemerged", short_name);
+ if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+ !opt_out) {
+ if (!quiet)
+ fprintf(stderr,
+ _("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
+ short_name, short_name);
+ continue;
+ }
+
strset_add(&deletable, short_name);
}
@@ -833,6 +847,7 @@ static int delete_merged_branches(int argc, const char **argv,
DELETE_BRANCH_NO_HEAD_FALLBACK |
flags);
+ strbuf_release(&key);
strvec_clear(&to_delete);
strset_clear(&deletable);
ref_array_clear(&candidates);
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 047ba54778..b7595610d9 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2024,4 +2024,30 @@ test_expect_success '--delete-merged clears the upstream of a kept base whose ow
test_cmp expect actual
'
+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '
+ test_when_finished "rm -rf repo" &&
+ setup_repo_for_delete_merged &&
+ merged_branch deleted origin/next &&
+ merged_branch kept origin/next &&
+ git -C repo config branch.kept.deleteMerged false &&
+ git -C repo checkout --detach &&
+
+ git -C repo branch --delete-merged origin/next 2>err &&
+
+ test_grep "Skipping .kept." err &&
+ test_must_fail git -C repo rev-parse --verify refs/heads/deleted &&
+ git -C repo rev-parse --verify refs/heads/kept
+'
+
+test_expect_success "branch -d still deletes a deleteMerged=false branch" '
+ test_when_finished "rm -rf repo" &&
+ setup_repo_for_delete_merged &&
+ merged_branch kept origin/next &&
+ git -C repo config branch.kept.deleteMerged false &&
+ git -C repo checkout --detach &&
+
+ git -C repo branch -d kept &&
+ test_must_fail git -C repo rev-parse --verify refs/heads/kept
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v2 2/2] push: suggest <remote> <branch> for a slash slip
From: Harald Nordgren via GitGitGadget @ 2026-06-24 21:55 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2331.v2.git.git.1782338114.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
When pushing the 'main' branch to the remote 'origin', i.e.,
$ git push origin main
it is easy to mistakenly write
$ git push origin/main
That is parsed as the repository to push to, and since 'origin/main'
is neither a configured remote nor a path it dies with:
fatal: 'origin/main' does not appear to be a git repository
Often 'origin/main' does not exist as a repository, so the command
fails without doing any harm, but it gives no hint that a space was
meant instead of a slash and can leave the user puzzled.
When the argument is not an existing path or configured remote but
its part before the first slash names one, suggest the intended
'<remote> <branch>' form:
$ git push origin main
The suggestion is shown as advice so it can be silenced with
advice.pushRepoLooksLikeRef.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/config/advice.adoc | 5 +++++
advice.c | 1 +
advice.h | 1 +
builtin/push.c | 31 ++++++++++++++++++++++++++++++-
t/t5529-push-errors.sh | 31 +++++++++++++++++++++++++++++++
5 files changed, 68 insertions(+), 1 deletion(-)
diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
index 257db58918..fa77a5110e 100644
--- a/Documentation/config/advice.adoc
+++ b/Documentation/config/advice.adoc
@@ -90,6 +90,11 @@ all advice messages.
Shown when linkgit:git-push[1] rejects a forced update of
a branch when its remote-tracking ref has updates that we
do not have locally.
+ pushRepoLooksLikeRef::
+ Shown when the repository given to linkgit:git-push[1] is not
+ a configured remote but looks like a `<remote>/<branch>` ref,
+ suggesting that the remote and branch be given as separate
+ arguments.
pushUnqualifiedRefname::
Shown when linkgit:git-push[1] gives up trying to
guess based on the source and destination refs what
diff --git a/advice.c b/advice.c
index 0018501b7b..63bf8b0c5f 100644
--- a/advice.c
+++ b/advice.c
@@ -69,6 +69,7 @@ static struct {
[ADVICE_PUSH_NON_FF_CURRENT] = { "pushNonFFCurrent" },
[ADVICE_PUSH_NON_FF_MATCHING] = { "pushNonFFMatching" },
[ADVICE_PUSH_REF_NEEDS_UPDATE] = { "pushRefNeedsUpdate" },
+ [ADVICE_PUSH_REPO_LOOKS_LIKE_REF] = { "pushRepoLooksLikeRef" },
[ADVICE_PUSH_UNQUALIFIED_REF_NAME] = { "pushUnqualifiedRefName" },
[ADVICE_PUSH_UPDATE_REJECTED] = { "pushUpdateRejected" },
[ADVICE_PUSH_UPDATE_REJECTED_ALIAS] = { "pushNonFastForward" }, /* backwards compatibility */
diff --git a/advice.h b/advice.h
index 8def280688..66f6cd6a77 100644
--- a/advice.h
+++ b/advice.h
@@ -36,6 +36,7 @@ enum advice_type {
ADVICE_PUSH_NON_FF_CURRENT,
ADVICE_PUSH_NON_FF_MATCHING,
ADVICE_PUSH_REF_NEEDS_UPDATE,
+ ADVICE_PUSH_REPO_LOOKS_LIKE_REF,
ADVICE_PUSH_UNQUALIFIED_REF_NAME,
ADVICE_PUSH_UPDATE_REJECTED,
ADVICE_PUSH_UPDATE_REJECTED_ALIAS,
diff --git a/builtin/push.c b/builtin/push.c
index 6021b71d66..255556b44d 100644
--- a/builtin/push.c
+++ b/builtin/push.c
@@ -8,6 +8,7 @@
#include "advice.h"
#include "branch.h"
#include "config.h"
+#include "dir.h"
#include "environment.h"
#include "gettext.h"
#include "hex.h"
@@ -662,6 +663,29 @@ static int push_multiple(struct string_list *list,
return result;
}
+static void die_if_repo_looks_like_ref(const char *repo)
+{
+ const char *slash = strchr(repo, '/');
+ struct strbuf name = STRBUF_INIT;
+ int code;
+
+ if (!slash || !slash[1] || file_exists(repo))
+ return;
+
+ strbuf_add(&name, repo, slash - repo);
+ if (!remote_is_configured(remote_get(name.buf), 0)) {
+ strbuf_release(&name);
+ return;
+ }
+
+ code = die_message(_("'%s' is not a valid push target"), repo);
+ advise_if_enabled(ADVICE_PUSH_REPO_LOOKS_LIKE_REF,
+ _("Did you mean to use: git push %s %s?"),
+ name.buf, slash + 1);
+ strbuf_release(&name);
+ exit(code);
+}
+
int cmd_push(int argc,
const char **argv,
const char *prefix,
@@ -744,6 +768,11 @@ int cmd_push(int argc,
if (repo) {
if (!add_remote_or_group(repo, &remote_group)) {
+ struct remote *r;
+
+ if (advice_enabled(ADVICE_PUSH_REPO_LOOKS_LIKE_REF))
+ die_if_repo_looks_like_ref(repo);
+
/*
* Not a configured remote name or group name.
* Try treating it as a direct URL or path, e.g.
@@ -753,7 +782,7 @@ int cmd_push(int argc,
* from the URL so the loop below can handle it
* identically to a named remote.
*/
- struct remote *r = pushremote_get(repo);
+ r = pushremote_get(repo);
if (!r)
die(_("bad repository '%s'"), repo);
string_list_append(&remote_group, r->name);
diff --git a/t/t5529-push-errors.sh b/t/t5529-push-errors.sh
index 80b06a0cd2..cfb294305d 100755
--- a/t/t5529-push-errors.sh
+++ b/t/t5529-push-errors.sh
@@ -54,6 +54,37 @@ test_expect_success 'detect empty remote with targeted refspec' '
grep "fatal: bad repository ${SQ}${SQ}" stderr
'
+test_expect_success 'suggest <remote> <branch> for a <remote>/<branch> slip' '
+ test_must_fail git push origin/main 2>stderr &&
+ grep "${SQ}origin/main${SQ} is not a valid push target" stderr &&
+ grep "hint: Did you mean to use: git push origin main?" stderr &&
+ test_must_fail git -c advice.pushRepoLooksLikeRef=false push origin/main 2>stderr &&
+ ! grep "Did you mean" stderr
+'
+
+test_expect_success 'suggest <remote> <branch> when the branch has slashes' '
+ test_must_fail git push origin/feature/x 2>stderr &&
+ grep "hint: Did you mean to use: git push origin feature/x?" stderr
+'
+
+test_expect_success 'no suggestion when prefix is not a configured remote' '
+ test_must_fail git push not-a-remote/main 2>stderr &&
+ ! grep "Did you mean" stderr
+'
+
+test_expect_success 'no suggestion for a trailing slash with no branch' '
+ test_must_fail git push origin/ 2>stderr &&
+ ! grep "Did you mean" stderr
+'
+
+test_expect_success 'no suggestion when the argument is an existing path' '
+ test_when_finished "rm -rf origin" &&
+ git init --bare origin/main &&
+ git push origin/main HEAD:refs/heads/pushed 2>stderr &&
+ ! grep "Did you mean" stderr &&
+ git -C origin/main rev-parse --verify refs/heads/pushed
+'
+
test_expect_success 'detect ambiguous refs early' '
git branch foo &&
git tag foo &&
--
gitgitgadget
^ permalink raw reply related
* [PATCH v18 7/7] branch: add --dry-run for --delete-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-24 21:55 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
With --dry-run, --delete-merged prints the local branches it would
delete, one "Would delete branch <name>" line each, and exits
without touching any ref. The same filtering applies, so the output
is exactly the set that the real run would delete.
--dry-run is only meaningful together with --delete-merged and is
rejected otherwise.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 8 +++++++-
builtin/branch.c | 22 +++++++++++++++++++---
t/t3200-branch.sh | 11 ++++++++++-
3 files changed, 36 insertions(+), 5 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index d482cded3d..00d6192e6a 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-git branch --delete-merged <branch>...
+git branch [--dry-run] --delete-merged <branch>...
DESCRIPTION
-----------
@@ -231,6 +231,12 @@ kept, so a branch is never deleted out from under one stacked on top
of it. If that kept branch in turn tracks a branch that is being
deleted, its now-stale upstream configuration is cleared.
+`--dry-run`::
+ With `--delete-merged`, print which branches would be
+ deleted and exit without touching any ref. Useful for
+ sanity-checking a wide pattern like `'origin/*'` before
+ committing to the deletion.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index bce85cb52e..e7763437fb 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -199,6 +199,7 @@ enum delete_branch_flags {
DELETE_BRANCH_QUIET = (1 << 1),
DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
+ DELETE_BRANCH_DRY_RUN = (1 << 4),
};
static int check_branch_commit(const char *branchname, const char *refname,
@@ -248,6 +249,7 @@ static int delete_branches(int argc, const char **argv, int kinds,
bool quiet = flags & DELETE_BRANCH_QUIET;
bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
bool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
+ bool dry_run = flags & DELETE_BRANCH_DRY_RUN;
struct strbuf bname = STRBUF_INIT;
enum interpret_branch_kind allowed_interpret;
struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ -346,13 +348,20 @@ static int delete_branches(int argc, const char **argv, int kinds,
free(target);
}
- if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
+ if (!dry_run &&
+ refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
ret = 1;
for_each_string_list_item(item, &refs_to_delete) {
char *describe_ref = item->util;
char *name = item->string;
- if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
+ if (dry_run) {
+ if (!quiet)
+ printf(remote_branch
+ ? _("Would delete remote-tracking branch %s (was %s).\n")
+ : _("Would delete branch %s (was %s).\n"),
+ name + branch_name_pos, describe_ref);
+ } else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
char *refname = name + branch_name_pos;
if (!quiet)
printf(remote_branch
@@ -897,6 +906,7 @@ int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
int delete_merged = 0;
+ int dry_run = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -952,6 +962,8 @@ int cmd_branch(int argc,
N_("edit the description for the branch")),
OPT_BOOL(0, "delete-merged", &delete_merged,
N_("delete local branches whose upstream matches <branch> and are merged")),
+ OPT_BOOL(0, "dry-run", &dry_run,
+ N_("with --delete-merged, only print which branches would be deleted")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1014,6 +1026,9 @@ int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
+ if (dry_run && !delete_merged)
+ die(_("--dry-run requires --delete-merged"));
+
if (recurse_submodules_explicit) {
if (!submodule_propagate_branches)
die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
@@ -1054,7 +1069,8 @@ int cmd_branch(int argc,
goto out;
} else if (delete_merged) {
ret = delete_merged_branches(argc, argv,
- quiet ? DELETE_BRANCH_QUIET : 0);
+ (quiet ? DELETE_BRANCH_QUIET : 0) |
+ (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
goto out;
} else if (show_current) {
print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index b7595610d9..cddcde341d 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1892,8 +1892,12 @@ test_expect_success '--delete-merged deletes merged branches and spares the rest
) &&
sha=$(git -C repo rev-parse --short merged) &&
- git -C repo branch --delete-merged origin/next >actual 2>&1 &&
+ git -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 &&
+ echo "Would delete branch merged (was $sha)." >expect &&
+ test_cmp expect actual &&
+ git -C repo rev-parse --verify refs/heads/merged &&
+ git -C repo branch --delete-merged origin/next >actual 2>&1 &&
echo "Deleted branch merged (was $sha)." >expect &&
test_cmp expect actual &&
git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
@@ -2050,4 +2054,9 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" '
test_must_fail git -C repo rev-parse --verify refs/heads/kept
'
+test_expect_success '--dry-run without --delete-merged is rejected' '
+ test_must_fail git -C forked branch --dry-run 2>err &&
+ test_grep "requires --delete-merged" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v18 5/7] branch: add --delete-merged <branch>
From: Harald Nordgren via GitGitGadget @ 2026-06-24 21:55 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
git branch --delete-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
keeping only those whose tip is reachable from their configured
upstream. The work has already landed on the upstream they track,
so the local copy is no longer needed.
A branch is not deleted when:
* it is checked out in any worktree
* its upstream remote-tracking branch no longer exists, since a
missing upstream is not by itself a sign of integration
* its push destination equals its upstream (<branch>@{push} is
the same as <branch>@{upstream}), such as a local "main" that
tracks and pushes to "origin/main". Right after a pull it just
looks "fully merged", so it is kept. Only branches that push
somewhere other than their upstream, typically topics in a fork
workflow, are candidates.
A branch whose work is not yet merged into its upstream is silently
skipped, so one unmerged topic does not abort the whole sweep.
A branch that another, surviving branch tracks as its upstream is
also kept, so a branch is never deleted out from under one stacked
on top of it. Such a kept branch is itself merged, so when its own
upstream is being deleted, clear its now-stale upstream config.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 29 ++++++
builtin/branch.c | 147 ++++++++++++++++++++++++++-
t/t3200-branch.sh | 185 ++++++++++++++++++++++++++++++++++
3 files changed, 359 insertions(+), 2 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index b0d66a6deb..66b1c87c55 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
+git branch --delete-merged <branch>...
DESCRIPTION
-----------
@@ -201,6 +202,34 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--delete-merged <branch>...`::
+ Delete the local branches that `--forked` would list for the
+ given _<branch>_ arguments, but only those whose tip is
+ reachable from their configured upstream. In other words, the
+ work on the branch has already landed on the upstream it
+ tracks, so the local copy is no longer needed. Several
+ _<branch>_ patterns may be given, e.g. `git branch
+ --delete-merged origin/main 'feature*'`.
++
+A branch is not deleted when:
++
+--
+* its upstream remote-tracking branch no longer exists,
+* it is checked out in any worktree, or
+* its push destination (`<branch>@{push}`) equals its upstream
+ (`<branch>@{upstream}`), so it cannot be distinguished from a
+ branch that just looks "fully merged" right after a pull.
+--
++
+A branch whose work has not yet been merged into its upstream is
+silently skipped. Delete it with `git branch -D` if you want to
+remove it anyway.
++
+A branch that another, surviving branch tracks as its upstream is
+kept, so a branch is never deleted out from under one stacked on top
+of it. If that kept branch in turn tracks a branch that is being
+deleted, its now-stale upstream configuration is cleared.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 01c1f64c73..d12a2f57ea 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,7 @@ static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] --delete-merged <branch>..."),
NULL
};
@@ -705,6 +707,139 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
return 0;
}
+struct spare_data {
+ struct strset *deletable;
+ struct strset *spared;
+};
+
+/*
+ * A surviving branch stacked on a deletion candidate would lose its
+ * upstream, so drop that candidate from the delete set and remember it
+ * in "spared" so its own upstream can be tidied up afterwards.
+ */
+static int spare_stacked_base(const struct reference *ref, void *cb_data)
+{
+ struct spare_data *data = cb_data;
+ struct branch *branch;
+ const char *upstream, *up_short;
+
+ if (strset_contains(data->deletable, ref->name))
+ return 0;
+ branch = branch_get(ref->name);
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
+ !strset_contains(data->deletable, up_short))
+ return 0;
+
+ strset_remove(data->deletable, up_short);
+ strset_add(data->spared, up_short);
+ return 0;
+}
+
+/*
+ * Keep any branch that a surviving branch tracks as its upstream, so we
+ * never delete a branch out from under one stacked on top of it. Such a
+ * base is itself merged, so when its own upstream is also going away
+ * (no surviving branch tracks it), clear the base's now-stale upstream.
+ */
+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
+{
+ struct strset spared = STRSET_INIT;
+ struct spare_data data = { .deletable = deletable, .spared = &spared };
+ struct strbuf key = STRBUF_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+
+ refs_for_each_branch_ref(refs, spare_stacked_base, &data);
+
+ strset_for_each_entry(&spared, &iter, entry) {
+ struct branch *branch = branch_get(entry->key);
+ const char *upstream = branch_get_upstream(branch, NULL);
+ const char *up_short;
+
+ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
+ !strset_contains(deletable, up_short))
+ continue;
+
+ strbuf_reset(&key);
+ strbuf_addf(&key, "branch.%s.merge", branch->name);
+ repo_config_set_gently(the_repository, key.buf, NULL);
+ strbuf_reset(&key);
+ strbuf_addf(&key, "branch.%s.remote", branch->name);
+ repo_config_set_gently(the_repository, key.buf, NULL);
+ }
+
+ strbuf_release(&key);
+ strset_clear(&spared);
+}
+
+static int delete_merged_branches(int argc, const char **argv,
+ 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 = STRSET_INIT;
+ struct strvec to_delete = STRVEC_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+ int i, ret = 0;
+
+ if (!argc)
+ die(_("--delete-merged requires at least one <branch>"));
+
+ for (i = 0; i < argc; i++)
+ if (ref_filter_forked_add(&filter, argv[i]) < 0)
+ die(_("'%s' is not a valid branch or pattern"), argv[i]);
+
+ filter.kind = FILTER_REFS_BRANCHES;
+ filter_refs(&candidates, &filter, filter.kind);
+
+ for (i = 0; i < candidates.nr; i++) {
+ const char *full_name = candidates.items[i]->refname;
+ const char *short_name;
+ struct branch *branch;
+ const char *upstream, *push;
+
+ if (!skip_prefix(full_name, "refs/heads/", &short_name))
+ BUG("filter returned non-branch ref '%s'", full_name);
+ if (branch_checked_out(full_name))
+ continue;
+
+ branch = branch_get(short_name);
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream || !refs_ref_exists(refs, upstream))
+ continue;
+ push = branch_get_push(branch, NULL);
+ if (!push || !strcmp(push, upstream))
+ continue;
+ if (check_branch_commit(short_name, short_name,
+ &candidates.items[i]->objectname, NULL,
+ FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
+ continue;
+
+ strset_add(&deletable, short_name);
+ }
+
+ spare_stacked_bases(refs, &deletable);
+
+ strset_for_each_entry(&deletable, &iter, entry)
+ strvec_push(&to_delete, entry->key);
+
+ if (to_delete.nr)
+ ret = delete_branches(to_delete.nr, to_delete.v,
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_SKIP_UNMERGED |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ flags);
+
+ strvec_clear(&to_delete);
+ strset_clear(&deletable);
+ ref_array_clear(&candidates);
+ ref_filter_clear(&filter);
+ return ret;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -746,6 +881,7 @@ int cmd_branch(int argc,
/* possible actions */
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
+ int delete_merged = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -799,6 +935,8 @@ int cmd_branch(int argc,
OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
OPT_BOOL(0, "edit-description", &edit_description,
N_("edit the description for the branch")),
+ OPT_BOOL(0, "delete-merged", &delete_merged,
+ N_("delete local branches whose upstream matches <branch> and are merged")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -846,7 +984,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 &&
+ argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -856,7 +995,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
+ !!unset_upstream + !!delete_merged;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -898,6 +1037,10 @@ int cmd_branch(int argc,
(delete > 1 ? DELETE_BRANCH_FORCE : 0) |
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
+ } else if (delete_merged) {
+ ret = delete_merged_branches(argc, argv,
+ quiet ? DELETE_BRANCH_QUIET : 0);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 3104c555f6..047ba54778 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1839,4 +1839,189 @@ test_expect_success '--forked narrows a <pattern> argument' '
test_cmp expect actual
'
+test_expect_success '--delete-merged: setup' '
+ git init -b main upstream &&
+ (
+ cd upstream &&
+ test_commit base &&
+ git checkout -b next &&
+ test_commit next-work &&
+ git checkout main
+ ) &&
+ git init -b main other &&
+ test_commit -C other other-base &&
+ git init -b main fork
+'
+
+setup_repo_for_delete_merged () {
+ rm -rf repo &&
+ git clone upstream repo &&
+ (
+ cd repo &&
+ git remote add fork ../fork &&
+ git remote add other ../other &&
+ git config remote.pushDefault fork &&
+ git config push.default current &&
+ git fetch other
+ )
+}
+
+merged_branch () {
+ (
+ cd repo &&
+ git checkout -b "$1" "$2" &&
+ git commit --allow-empty -m "$1 work" &&
+ git push origin "$1:next" &&
+ git fetch origin &&
+ git branch --set-upstream-to="$2" "$1"
+ )
+}
+
+test_expect_success '--delete-merged deletes merged branches and spares the rest' '
+ test_when_finished "rm -rf repo" &&
+ setup_repo_for_delete_merged &&
+ merged_branch merged origin/next &&
+ (
+ cd repo &&
+ git checkout -b unmerged origin/next &&
+ git commit --allow-empty -m "unmerged work" &&
+ git branch --set-upstream-to=origin/next unmerged &&
+ git checkout -b tracks-other other/main &&
+ git branch --set-upstream-to=other/main tracks-other &&
+ git checkout --detach
+ ) &&
+ sha=$(git -C repo rev-parse --short merged) &&
+
+ git -C repo branch --delete-merged origin/next >actual 2>&1 &&
+
+ echo "Deleted branch merged (was $sha)." >expect &&
+ test_cmp expect actual &&
+ git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
+ cat >expect <<-\EOF &&
+ main
+ tracks-other
+ unmerged
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--delete-merged deletes merged branches and spares protected ones' '
+ test_when_finished "rm -rf repo" &&
+ setup_repo_for_delete_merged &&
+ merged_branch on-next origin/next &&
+ merged_branch checked-out origin/next &&
+ merged_branch upstream-gone origin/next &&
+ (
+ cd repo &&
+ git checkout -b mainline main &&
+ git checkout -b on-local mainline &&
+ git branch --set-upstream-to=mainline on-local &&
+ git update-ref refs/remotes/origin/topic refs/remotes/origin/next &&
+ git branch --set-upstream-to=origin/topic upstream-gone &&
+ git update-ref -d refs/remotes/origin/topic &&
+ git branch --set-upstream-to=origin/main main &&
+ git config branch.main.pushRemote origin &&
+ git checkout -b tracks-other other/main &&
+ git branch --set-upstream-to=other/main tracks-other &&
+ git checkout checked-out
+ ) &&
+
+ git -C repo branch --delete-merged origin/next mainline &&
+
+ git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
+ cat >expect <<-\EOF &&
+ checked-out
+ main
+ mainline
+ tracks-other
+ upstream-gone
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--delete-merged requires at least one <branch>' '
+ test_must_fail git -C forked branch --delete-merged 2>err &&
+ test_grep "requires at least one <branch>" err
+'
+
+test_expect_success '--delete-merged keeps a branch that is an upstream' '
+ test_when_finished "rm -rf repo" &&
+ setup_repo_for_delete_merged &&
+ merged_branch feature origin/next &&
+ (
+ cd repo &&
+ git checkout -b topic feature &&
+ git commit --allow-empty -m "topic work" &&
+ git branch --set-upstream-to=feature topic &&
+ git checkout --detach
+ ) &&
+
+ git -C repo branch --dry-run --delete-merged origin/next >out &&
+ test_grep ! "feature" out &&
+
+ git -C repo branch --delete-merged origin/next 2>err &&
+
+ test_must_be_empty err &&
+ git -C repo rev-parse --verify refs/heads/feature &&
+ git -C repo rev-parse --verify refs/heads/topic &&
+ echo origin/next >expect &&
+ git -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&
+ test_cmp expect actual &&
+ echo feature >expect &&
+ git -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&
+ test_cmp expect actual
+'
+
+test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '
+ test_when_finished "rm -rf repo" &&
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
+ git branch b3 origin/next &&
+ git branch --set-upstream-to=origin/next b3 &&
+ git branch b2 origin/next &&
+ git branch --set-upstream-to=b3 b2 &&
+ git checkout -b b1 b2 &&
+ git commit --allow-empty -m "b1 work" &&
+ git branch --set-upstream-to=b2 b1 &&
+ git checkout --detach
+ ) &&
+
+ git -C repo branch --delete-merged origin/next &&
+
+ git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
+ cat >expect <<-\EOF &&
+ b1
+ b2
+ b3
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '
+ test_when_finished "rm -rf repo" &&
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
+ git branch lower origin/next &&
+ git branch --set-upstream-to=origin/next lower &&
+ git branch mid origin/next &&
+ git branch --set-upstream-to=lower mid &&
+ git checkout -b tip mid &&
+ git commit --allow-empty -m "tip work" &&
+ git branch --set-upstream-to=mid tip &&
+ git checkout --detach
+ ) &&
+
+ git -C repo branch --delete-merged origin/next lower &&
+
+ test_must_fail git -C repo rev-parse --verify refs/heads/lower &&
+ git -C repo rev-parse --verify refs/heads/mid &&
+ test_must_fail git -C repo rev-parse mid@{upstream} &&
+ echo mid >expect &&
+ git -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&
+ test_cmp expect actual
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* Re: [PATCH v5 09/11] reftable: split up write options
From: Justin Tobler @ 2026-06-24 22:06 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: git, Karthik Nayak, Jeff King
In-Reply-To: <20260622-b4-pks-refs-avoid-chdir-notify-reparent-v5-9-018475013dbc@pks.im>
On 26/06/22 10:28AM, Patrick Steinhardt wrote:
> When initializing the reftable stack the caller may optionally pass some
> write options. These write options mix up two different concerns though:
>
> - Of course, they allow the caller to configure how new reftables are
> being written.
>
> - But they also allow the caller to configure the stack itself, like
> its hash ID and the `on_reload` callback.
>
> This is somewhat awkward, as it doesn't easily give the caller the
> flexibility to for example write multiple reftables with different
> options. Furthermore, this requires us to eagerly parse relevant
> configuration when initializing the reftable backend.
Naive question: are there any current use cases where callers may want
to write multiple reftables with a different set of options? Can
reftables written with different options pose any correctness issues?
> Refactor the code by splitting out those options that configure the
> stack itself. Creating a new stack will thus only require this limited
> set of options, whereas the caller is expected to pass write options to
> all functions that end up writing tables.
Splitting this up sounds reasonable.
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
> refs/reftable-backend.c | 29 +++---
> reftable/reftable-stack.h | 30 +++++-
> reftable/reftable-writer.h | 17 +---
> reftable/stack.c | 100 ++++++++++++-------
> reftable/stack.h | 2 +-
> reftable/writer.c | 21 ++--
> reftable/writer.h | 1 +
> t/helper/test-reftable.c | 2 +-
> t/unit-tests/lib-reftable.c | 8 +-
> t/unit-tests/lib-reftable.h | 2 +
> t/unit-tests/u-reftable-merged.c | 9 +-
> t/unit-tests/u-reftable-readwrite.c | 38 ++++++--
> t/unit-tests/u-reftable-stack.c | 189 ++++++++++++++++--------------------
> t/unit-tests/u-reftable-table.c | 8 +-
> 14 files changed, 258 insertions(+), 198 deletions(-)
>
> diff --git a/refs/reftable-backend.c b/refs/reftable-backend.c
> index 5115a3f4ce..608d71cf10 100644
> --- a/refs/reftable-backend.c
> +++ b/refs/reftable-backend.c
> @@ -48,9 +48,9 @@ static void reftable_backend_on_reload(void *payload)
>
> static int reftable_backend_init(struct reftable_backend *be,
> const char *path,
> - const struct reftable_write_options *_opts)
Ok so now during init we only care about `struct
reftable_stack_options`. The `struct reftable_write_options` are only
needed during reftable writes.
[snip]
> +/* Options related to opening a stack. */
> +struct reftable_stack_options {
> + /*
> + * 4-byte identifier ("sha1", "s256") of the hash. Defaults to SHA1 if
> + * unset.
> + */
> + enum reftable_hash hash_id;
> +
> + /*
> + * Callback function to execute whenever the stack is being reloaded.
> + * This can be used e.g. to discard cached information that relies on
> + * the old stack's data. The payload data will be passed as argument to
> + * the callback.
> + */
> + void (*on_reload)(void *payload);
> + void *on_reload_payload;
> +};
These are the options split out from `struct reftable_write_options` and
are the options used at initialization and expected to remain consistent
across reftable writes. I assume these also won't depend on reading the
config prior to the ref store being initialzed.
[snip]
> diff --git a/reftable/reftable-writer.h b/reftable/reftable-writer.h
> index a66db415c8..6ff4ddfc60 100644
> --- a/reftable/reftable-writer.h
> +++ b/reftable/reftable-writer.h
> @@ -28,11 +28,6 @@ struct reftable_write_options {
> /* how often to write complete keys in each block. */
> uint16_t restart_interval;
>
> - /* 4-byte identifier ("sha1", "s256") of the hash.
> - * Defaults to SHA1 if unset
> - */
> - enum reftable_hash hash_id;
> -
> /* Default mode for creating files. If unset, use 0666 (+umask) */
> unsigned int default_permissions;
>
> @@ -60,15 +55,6 @@ struct reftable_write_options {
> * negative value will cause us to block indefinitely.
> */
> long lock_timeout_ms;
> -
> - /*
> - * Callback function to execute whenever the stack is being reloaded.
> - * This can be used e.g. to discard cached information that relies on
> - * the old stack's data. The payload data will be passed as argument to
> - * the callback.
> - */
> - void (*on_reload)(void *payload);
> - void *on_reload_payload;
> };
These write options are explicitly passed around during write
operations. I assume some of these options must be parsed from the
config and thus will need to be lazy-loaded to avoid "onbranch"
conditions prior to the ref store being initialzed.
The rest of this patch looks to be adjusting call sites to wire these
options through as needed and looks correct. I don't see any changes to
lazy-load write option configuration yet, but I suppose that will happen
in a subsequent patch.
-Justin
^ permalink raw reply
* Re: [PATCH v5 10/11] refs/reftable: lazy-load configuration to fix chicken-and-egg
From: Justin Tobler @ 2026-06-24 22:18 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: git, Karthik Nayak, Jeff King
In-Reply-To: <20260622-b4-pks-refs-avoid-chdir-notify-reparent-v5-10-018475013dbc@pks.im>
On 26/06/22 10:28AM, Patrick Steinhardt wrote:
> Same as with the "files" backend, the "reftable" backend also has a
> chicken-and-egg problem with "onbranch" conditions. Fix this issue the
> same as we did with the "files" backend by lazy-loading configuration.
Makes sense.
> Now that both the "files" and the "reftable" backend handle this
> properly, add a generic test to t1400 that verifies that the user can
> configure "core.logAllRefUpdates" via an "onbranch" condition. This is
> mostly a nonsensical thing to do in the first place, but it serves as a
> good sanity chekc.
s/chekc/check
> Note that we had to move `should_write_log()` around so that it can
> access the new `reftable_be_write_options()` function.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
> refs/reftable-backend.c | 146 ++++++++++++++++++++++----------------
> t/t0613-reftable-write-options.sh | 19 +++++
> t/t1400-update-ref.sh | 12 ++++
> 3 files changed, 116 insertions(+), 61 deletions(-)
>
> diff --git a/refs/reftable-backend.c b/refs/reftable-backend.c
> index 608d71cf10..d74131a5ae 100644
> --- a/refs/reftable-backend.c
> +++ b/refs/reftable-backend.c
> @@ -141,10 +141,21 @@ struct reftable_ref_store {
> */
> struct strmap worktree_backends;
> struct reftable_stack_options stack_options;
> - struct reftable_write_options write_options;
> +
> + /*
> + * Options used when writing to or compacting the reftable stacks.
> + * These are parsed from the configuration lazily on first use via
> + * `reftable_be_write_options()` so that we don't have to access the
> + * configuration when initializing the ref store. Do not access these
> + * fields directly, but use the accessor instead.
> + */
> + struct reftable_be_write_options {
> + struct reftable_write_options opts;
> + enum log_refs_config log_all_ref_updates;
Any reason in particular that `log_all_ref_updates` is the only option
outside of `struct reftlable_write_options` here? Isn't it also only
used during writes?
-Justin
^ permalink raw reply
* Re: [PATCH v2 1/2] branch: suggest <remote>/<branch> on upstream slip
From: Junio C Hamano @ 2026-06-24 22:33 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren
In-Reply-To: <11bcecebf43797a889f08e79401370f43b2917a8.1782338114.git.gitgitgadget@gmail.com>
"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> When setting the upstream of the current branch to the 'main' branch
> of the remote 'origin', i.e.,
>
> $ git branch --set-upstream-to origin/main
>
> it is easy to mistakenly write
>
> $ git branch --set-upstream-to origin main
>
> That is parsed as a request to set the upstream of the local branch
> 'main' to 'origin'. When 'main' does not exist, the command dies
> with:
>
> fatal: branch 'main' does not exist
>
> pointing at a branch the user never meant to name.
It is more complete to add the other case here, something along the
lines of ...
And then when 'main' does exist, the command would die with
fatal: the requested upstream branch 'origin' does not exist
leaving the user equally confused.
... no? In any case, this is much more nicely described than the
previous round. I see no room for confusion.
> When the operated-on branch is missing and '<remote>/<branch>' names
> a real remote-tracking ref, suggest the intended form:
>
> $ git branch --set-upstream-to=origin/main
>
> The suggestion is gated on '<remote>/<branch>' existing so it only
> appears when a slipped slash is the likely explanation.
Makes sense.
Do we want to do anything on a case where the operated-on branch
does exist but '<remote>' is not a name suitable for an upstream,
but '<remote>/<branch>' is?
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 1572a4f9ef..cefc4519a7 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -706,6 +706,29 @@ static int edit_branch_description(const char *branch_name)
> return 0;
> }
>
> +static void die_if_upstream_looks_like_remote(const char *new_upstream, const char *branch_name)
> +{
> + struct strbuf remote_ref = STRBUF_INIT;
> + int code;
> +
> + if (strchr(new_upstream, '/') ||
> + !remote_is_configured(remote_get(new_upstream), 0))
> + return;
> +
> + strbuf_addf(&remote_ref, "refs/remotes/%s/%s", new_upstream, branch_name);
> + if (!refs_ref_exists(get_main_ref_store(the_repository), remote_ref.buf)) {
> + strbuf_release(&remote_ref);
> + return;
> + }
> +
> + code = die_message(_("--set-upstream-to takes a single <remote>/<branch> argument"));
> + advise_if_enabled(ADVICE_SET_UPSTREAM_FAILURE,
> + _("Did you mean to use: git branch --set-upstream-to=%s/%s?"),
> + new_upstream, branch_name);
Do we still need the _if_enabled() thing here? Isn't the caller
gated with the same condition in this version?
> + strbuf_release(&remote_ref);
> + exit(code);
> +}
> +
> int cmd_branch(int argc,
> const char **argv,
> const char *prefix,
> @@ -957,6 +980,9 @@ int cmd_branch(int argc,
> if (!refs_ref_exists(get_main_ref_store(the_repository), branch->refname)) {
> if (!argc || branch_checked_out(branch->refname))
> die(_("no commit on branch '%s' yet"), branch->name);
> + if (argc == 1 &&
> + advice_enabled(ADVICE_SET_UPSTREAM_FAILURE))
> + die_if_upstream_looks_like_remote(new_upstream, argv[0]);
> die(_("branch '%s' does not exist"), branch->name);
> }
This is totally a tangent, but has anybody noticed that the web
interface to the lore archive seems to be constipated? I am reading
over nntp and subscribers are reading from their inbox, so no real
harm done, but from time to time we get reminded how heavily our
development process relies on the services like kernel.org and feel
grateful to have them.
^ permalink raw reply
* Re: [PATCH v2 2/2] push: suggest <remote> <branch> for a slash slip
From: Junio C Hamano @ 2026-06-24 22:42 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren
In-Reply-To: <49de5a925de506ed9a141eb72927b2548b73af22.1782338114.git.gitgitgadget@gmail.com>
"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> diff --git a/t/t5529-push-errors.sh b/t/t5529-push-errors.sh
> index 80b06a0cd2..cfb294305d 100755
> --- a/t/t5529-push-errors.sh
> +++ b/t/t5529-push-errors.sh
> @@ -54,6 +54,37 @@ test_expect_success 'detect empty remote with targeted refspec' '
> grep "fatal: bad repository ${SQ}${SQ}" stderr
> '
>
> +test_expect_success 'suggest <remote> <branch> for a <remote>/<branch> slip' '
> + test_must_fail git push origin/main 2>stderr &&
> + grep "${SQ}origin/main${SQ} is not a valid push target" stderr &&
> + grep "hint: Did you mean to use: git push origin main?" stderr &&
> + test_must_fail git -c advice.pushRepoLooksLikeRef=false push origin/main 2>stderr &&
> + ! grep "Did you mean" stderr
> +'
> +
> +test_expect_success 'suggest <remote> <branch> when the branch has slashes' '
> + test_must_fail git push origin/feature/x 2>stderr &&
> + grep "hint: Did you mean to use: git push origin feature/x?" stderr
> +'
> +
> +test_expect_success 'no suggestion when prefix is not a configured remote' '
> + test_must_fail git push not-a-remote/main 2>stderr &&
> + ! grep "Did you mean" stderr
> +'
> +
> +test_expect_success 'no suggestion for a trailing slash with no branch' '
> + test_must_fail git push origin/ 2>stderr &&
> + ! grep "Did you mean" stderr
> +'
t5529-push-errors.sh:59: error: bare grep outside pipeline (use test_grep)
t5529-push-errors.sh:60: error: bare grep outside pipeline (use test_grep)
t5529-push-errors.sh:62: error: bare grep outside pipeline (use test_grep)
t5529-push-errors.sh:67: error: bare grep outside pipeline (use test_grep)
t5529-push-errors.sh:72: error: bare grep outside pipeline (use test_grep)
t5529-push-errors.sh:77: error: bare grep outside pipeline (use test_grep)
t5529-push-errors.sh:84: error: bare grep outside pipeline (use test_grep)
^ permalink raw reply
* Re: [PATCH v14 2/2] checkout: extend --track with a "fetch" mode to refresh start-point
From: Junio C Hamano @ 2026-06-24 23:20 UTC (permalink / raw)
To: Harald Nordgren
Cc: phillip.wood, Harald Nordgren via GitGitGadget, git, Ramsay Jones,
D. Ben Knoble, Kristoffer Haugsbakk, Marc Branchaud
In-Reply-To: <CAHwyqnWwyPHiaOW+rz-Z9ZvRf=OjXWw2T+rB3cSsxXWXkeRm=Q@mail.gmail.com>
Harald Nordgren <haraldnordgren@gmail.com> writes:
> Ok, let's focus on the need for the feature before talking code:
>
> In an active project, forking from "origin/master" without refreshing
> first often has consequences: you start work that has already been
> done, or you build on an old version of the code which causes big
> conflicts only later when you pull. The fix is simple ...
The above only argues that contributors should not start work on top
of a stale codebase without looking at reasonably recent codebase.
I am not sure if automated fetch immediately before forking to start
work will be a good fix for that, especially if the fork of a new
branch is done blindly _without_ looking at what the updated
upstream contains.
> ... ("git fetch
> origin master && git checkout -b topic origin/master"), but it is
> still a mouthful. Other tools exist because this is annoying enough
> that people automate it.
And to actually look at the recent codebase, one would probably need
git fetch
git log [-p] ..origin -- your-area-of-interest/
... other inspection of the recent changes to refresh your
... understanding of the base code comes here
git checkout -b topic origin
or something like that. Wouldn't folding the first and the third
step into one operation encourage omitting the second step? In a
sense, having a tool to let people blindly fetch and fork without
looking at what changed recently (i.e., they had a reason to think
that what they had was stale, so has a fetch actually resolved that
staleness? what new things did the fetch bring in?) may encourage
a bad workflow.
An obvious complaint against "update and always inspect and
understand" would be "it would slow us down!", but that is why
projects encourage forking your topic at a well known release tags,
not from a random "tip of the tree of the day".
I think most of the above has already been communicated earlier in
discussions before we got to v14, but I may be wrong. Are there any
new arguments in support of the feature?
^ permalink raw reply
* Re: [PATCH v14 2/2] checkout: extend --track with a "fetch" mode to refresh start-point
From: Ben Knoble @ 2026-06-25 1:17 UTC (permalink / raw)
To: Junio C Hamano
Cc: Harald Nordgren, phillip.wood, Harald Nordgren via GitGitGadget,
git, Ramsay Jones, Kristoffer Haugsbakk, Marc Branchaud
In-Reply-To: <xmqq5x37h6fj.fsf@gitster.g>
> Le 24 juin 2026 à 19:20, Junio C Hamano <gitster@pobox.com> a écrit :
>
> Harald Nordgren <haraldnordgren@gmail.com> writes:
>
>> Ok, let's focus on the need for the feature before talking code:
>>
>> In an active project, forking from "origin/master" without refreshing
>> first often has consequences: you start work that has already been
>> done, or you build on an old version of the code which causes big
>> conflicts only later when you pull. The fix is simple ...
>
> The above only argues that contributors should not start work on top
> of a stale codebase without looking at reasonably recent codebase.
>
> I am not sure if automated fetch immediately before forking to start
> work will be a good fix for that, especially if the fork of a new
> branch is done blindly _without_ looking at what the updated
> upstream contains.
>
>> ... ("git fetch
>> origin master && git checkout -b topic origin/master"), but it is
>> still a mouthful. Other tools exist because this is annoying enough
>> that people automate it.
>
> And to actually look at the recent codebase, one would probably need
>
> git fetch
> git log [-p] ..origin -- your-area-of-interest/
> ... other inspection of the recent changes to refresh your
> ... understanding of the base code comes here
> git checkout -b topic origin
>
> or something like that. Wouldn't folding the first and the third
> step into one operation encourage omitting the second step? In a
> sense, having a tool to let people blindly fetch and fork without
> looking at what changed recently (i.e., they had a reason to think
> that what they had was stale, so has a fetch actually resolved that
> staleness? what new things did the fetch bring in?) may encourage
> a bad workflow.
I think I remain overall ambivalent, but as anecdata: when I’m working on my employer’scode, it is the default (90%+?) for folks to omit step 2 and have the kind of blind fetch + branch that this would facilitate.
I myself do this when I’m reasonably sure the other changes can’t be of interest (common), or when I suspect the change I’m going to start on will conflict with recent changes I haven’t fetched. At other times I’m more interested in step 2, but generally I omit it at work.
Now, as to why: I spend a lot more time reviewing PRs at work (and making sure they merge quickly when they are in the right direction), and so I’m usually fairly confident of what a fetch is going to bring! [I also fetch several times a day to keep up to date locally, to facilitate various maintenance, admin, and archaeology tasks.] Contrast with distributed open source projects, where I might not have fetched for weeks and can’t predict what might fall out (let alone how it might it interact with local WIP).
So « bad workflow » I agree with, but am plenty guilty of :)
To wrap up, I wonder if the convenience of this proposal is especially aimed at folks like my corporate environment (where « build near the tip and integrate quickly » is the norm), but less than useful for those same folks in a different situation?
(OTOH, I suppose I might use something similar when starting a new topic branch from Git’s master branch, since there’s probably no harm in working on a recent copy of master unless I already have older code that needs updated?)
> An obvious complaint against "update and always inspect and
> understand" would be "it would slow us down!", but that is why
> projects encourage forking your topic at a well known release tags,
> not from a random "tip of the tree of the day".
>
> I think most of the above has already been communicated earlier in
> discussions before we got to v14, but I may be wrong. Are there any
> new arguments in support of the feature?
^ permalink raw reply
* Re: [PATCH v2 2/2] push: suggest <remote> <branch> for a slash slip
From: Junio C Hamano @ 2026-06-25 3:36 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren
In-Reply-To: <xmqqa4sjh85o.fsf@gitster.g>
Junio C Hamano <gitster@pobox.com> writes:
> "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
>
>> diff --git a/t/t5529-push-errors.sh b/t/t5529-push-errors.sh
>> index 80b06a0cd2..cfb294305d 100755
>> --- a/t/t5529-push-errors.sh
>> +++ b/t/t5529-push-errors.sh
>> @@ -54,6 +54,37 @@ test_expect_success 'detect empty remote with targeted refspec' '
>> grep "fatal: bad repository ${SQ}${SQ}" stderr
>> '
> t5529-push-errors.sh:59: error: bare grep outside pipeline (use test_grep)
> t5529-push-errors.sh:60: error: bare grep outside pipeline (use test_grep)
> t5529-push-errors.sh:62: error: bare grep outside pipeline (use test_grep)
> t5529-push-errors.sh:67: error: bare grep outside pipeline (use test_grep)
> t5529-push-errors.sh:72: error: bare grep outside pipeline (use test_grep)
> t5529-push-errors.sh:77: error: bare grep outside pipeline (use test_grep)
> t5529-push-errors.sh:84: error: bare grep outside pipeline (use test_grep)
I've queued this squashable? fix on top of the branch before merging
the result to 'seen' for tonight's push-out.
Thanks.
--- >8 ---
Subject: [PATCH] SQUASH??? use test_grep
---
t/t5529-push-errors.sh | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/t/t5529-push-errors.sh b/t/t5529-push-errors.sh
index cfb294305d..2294645902 100755
--- a/t/t5529-push-errors.sh
+++ b/t/t5529-push-errors.sh
@@ -56,32 +56,32 @@ test_expect_success 'detect empty remote with targeted refspec' '
test_expect_success 'suggest <remote> <branch> for a <remote>/<branch> slip' '
test_must_fail git push origin/main 2>stderr &&
- grep "${SQ}origin/main${SQ} is not a valid push target" stderr &&
- grep "hint: Did you mean to use: git push origin main?" stderr &&
+ test_grep "${SQ}origin/main${SQ} is not a valid push target" stderr &&
+ test_grep "hint: Did you mean to use: git push origin main?" stderr &&
test_must_fail git -c advice.pushRepoLooksLikeRef=false push origin/main 2>stderr &&
- ! grep "Did you mean" stderr
+ test_grep ! "Did you mean" stderr
'
test_expect_success 'suggest <remote> <branch> when the branch has slashes' '
test_must_fail git push origin/feature/x 2>stderr &&
- grep "hint: Did you mean to use: git push origin feature/x?" stderr
+ test_grep "hint: Did you mean to use: git push origin feature/x?" stderr
'
test_expect_success 'no suggestion when prefix is not a configured remote' '
test_must_fail git push not-a-remote/main 2>stderr &&
- ! grep "Did you mean" stderr
+ test_grep ! "Did you mean" stderr
'
test_expect_success 'no suggestion for a trailing slash with no branch' '
test_must_fail git push origin/ 2>stderr &&
- ! grep "Did you mean" stderr
+ test_grep ! "Did you mean" stderr
'
test_expect_success 'no suggestion when the argument is an existing path' '
test_when_finished "rm -rf origin" &&
git init --bare origin/main &&
git push origin/main HEAD:refs/heads/pushed 2>stderr &&
- ! grep "Did you mean" stderr &&
+ test_grep ! "Did you mean" stderr &&
git -C origin/main rev-parse --verify refs/heads/pushed
'
--
2.55.0-rc2-165-g3249676ba5
^ permalink raw reply related
* Re: [PATCH v2 2/4] odb/source-packed: support flags when iterating an object prefix
From: Patrick Steinhardt @ 2026-06-25 5:52 UTC (permalink / raw)
To: Christian Couder; +Cc: git, Junio C Hamano, Christian Couder
In-Reply-To: <CAP8UFD1sJNJbAAu9ZUanB8gJV-Vb64pLVkNULm3onSFZirdKxA@mail.gmail.com>
On Wed, Jun 24, 2026 at 07:02:48PM +0200, Christian Couder wrote:
> On Wed, Jun 24, 2026 at 12:37 PM Patrick Steinhardt <ps@pks.im> wrote:
> > diff --git a/odb/source-packed.c b/odb/source-packed.c
> > index 3afc4bf01f..6f31f0ff94 100644
> > --- a/odb/source-packed.c
> > +++ b/odb/source-packed.c
> > @@ -171,6 +172,20 @@ static int for_each_prefixed_object_in_midx(
> > const struct object_id *current = NULL;
> > struct object_id oid;
> >
> > + if (opts->flags) {
> > + uint32_t pack_id = nth_midxed_pack_int_id(m, i);
> > + struct packed_git *pack;
> > +
> > + if (prepare_midx_pack(m, pack_id)) {
> > + pack_errors = true;
> > + continue;
> > + }
> > +
> > + pack = nth_midxed_pack(m, pack_id);
> > + if (should_exclude_pack(pack, opts->flags))
> > + continue;
> > + }
> > +
> > current = nth_midxed_object_oid(&oid, m, i);
> >
> > if (!match_hash(len, opts->prefix->hash, current->hash))
>
> It looks like this is:
>
> if (!match_hash(len, opts->prefix->hash, current->hash))
> break;
>
> and I wonder if the `if (opts->flags) { ... }` block would be better
> after that prefix check rather than before it.
>
> Putting it after the prefix check would make sure we don't continue
> when the prefix doesn't match.
Hm, that's a good point indeed. Will adapt, thanks!
Patrick
^ permalink raw reply
* Re: [PATCH v2 4/4] connected: search promisor objects generically
From: Patrick Steinhardt @ 2026-06-25 5:54 UTC (permalink / raw)
To: Junio C Hamano; +Cc: git, Christian Couder
In-Reply-To: <xmqqjyrnkinn.fsf@gitster.g>
On Wed, Jun 24, 2026 at 09:27:56AM -0700, Junio C Hamano wrote:
> Patrick Steinhardt <ps@pks.im> writes:
> > diff --git a/connected.c b/connected.c
> > index d2b334173f..b557ff5db9 100644
> > --- a/connected.c
> > +++ b/connected.c
> > @@ -11,6 +11,13 @@
> > #include "packfile.h"
> > #include "promisor-remote.h"
> >
> > +static int promised_object_cb(const struct object_id *oid UNUSED,
> > + struct object_info *oi UNUSED,
> > + void *payload UNUSED)
> > +{
> > + return 1;
> > +}
> > +
> > /*
> > * For partial clones, we don't want to have to do a regular connectivity check
> > * because we have to enumerate and exclude all promisor objects (slow), and
> > @@ -30,25 +37,28 @@ static int check_connected_promisor(oid_iterate_fn fn,
> > void *cb_data,
> > const struct object_id **oid)
> > {
> > + struct odb_for_each_object_options opts = {
> > + .flags = ODB_FOR_EACH_OBJECT_PROMISOR_ONLY,
> > + .prefix_hex_len = the_repository->hash_algo->hexsz,
> > + };
> > + int err;
> > +
> > odb_reprepare(the_repository->objects);
> > do {
> > - struct packed_git *p;
> > + opts.prefix = *oid;
> >
> > - repo_for_each_pack(the_repository, p) {
> > - if (!p->pack_promisor)
> > - continue;
> > - if (find_pack_entry_one(*oid, p))
> > - goto promisor_pack_found;
> > - }
> > + err = odb_for_each_object_ext(the_repository->objects,
> > + NULL, promised_object_cb,
> > + NULL, &opts);
>
> promised_object_cb() returns 1 without any computation since we are
> only interested in learning ODB_FOR_EACH_OBJECT_PROMISOR_ONLY finds
> any such object.
>
> odb_for_each_object_ext() returns 0 (if it iterates all the sources
> to the end), but if its call to odb_source_for_each_object() yields
> non-zero value, the returned value comes back as "err" here,
> terminating the for-each iteration immediately.
>
> odb_source_for_each_object() is implemented differently per the
> source backend, but taking an example of "packfile" backend,
> packfile_loose_for_each_object() ends up calling cb (wrapped in
> packfile_store_for_each_object_wrapper_data) via
> for_each_object_in_pack(), which stops immediately when cb returns
> non-zero and the value returned from there is the value given by cb,
> i.e., 1. So we will have err==1 when we find any object.
>
> > + if (err < 0)
> > + return err;
>
> And err presumably is 1 in such a case, so this does not trigger.
>
> > /*
> > * We have found an object that is not part of a promisor pack,
> > * and thus we cannot skip the full connectivity check.
> > */
> > - return 0;
> > -
> > -promisor_pack_found:
> > - ;
> > + if (err > 0)
> > + return 0;
>
> And this does.
>
> I may be misreading the patch, but as we return 0 from here, do we
> cause the caller to fall back to full connectivity check? The
> caller, check_connected(), sees a zero returned from here.
You're right, this is a result of the refactor. Previously we had it
like this:
err = odb_for_each_object_ext(the_repository->objects,
NULL, promised_object_cb,
NULL, &opts);
if (err < 0)
break;
if (err > 0) {
err = 0;
continue;
}
But that made us correctly skip to the next object. Now though we have
to check for `if (!err) return 0;` in the refactored code. Makes me
wonder whether the logic would be easier to follow like this:
diff --git a/connected.c b/connected.c
index b557ff5db9..b5a9b0543d 100644
--- a/connected.c
+++ b/connected.c
@@ -13,8 +13,10 @@
static int promised_object_cb(const struct object_id *oid UNUSED,
struct object_info *oi UNUSED,
- void *payload UNUSED)
+ void *payload)
{
+ bool *found = payload;
+ *found = true;
return 1;
}
@@ -45,11 +47,13 @@ static int check_connected_promisor(oid_iterate_fn fn,
odb_reprepare(the_repository->objects);
do {
+ bool found = false;
+
opts.prefix = *oid;
err = odb_for_each_object_ext(the_repository->objects,
NULL, promised_object_cb,
- NULL, &opts);
+ &found, &opts);
if (err < 0)
return err;
@@ -57,7 +61,7 @@ static int check_connected_promisor(oid_iterate_fn fn,
* We have found an object that is not part of a promisor pack,
* and thus we cannot skip the full connectivity check.
*/
- if (err > 0)
+ if (!found)
return 0;
} while ((*oid = fn(cb_data)) != NULL);
It's also a bit concerning that this doesn't cause any tests to fail.
I'll try to figure out whether I can add one.
Thanks!
Patrick
^ permalink raw reply related
* Re: [PATCH v5 07/11] refs: move parsing of "core.logAllRefUpdates" back into ref stores
From: Patrick Steinhardt @ 2026-06-25 6:35 UTC (permalink / raw)
To: Justin Tobler; +Cc: git, Karthik Nayak, Jeff King
In-Reply-To: <ajxEXMTBmii01dVP@denethor>
On Wed, Jun 24, 2026 at 04:22:07PM -0500, Justin Tobler wrote:
> On 26/06/22 10:28AM, Patrick Steinhardt wrote:
> > diff --git a/setup.c b/setup.c
> > index 79125db565..0c6efb0560 100644
> > --- a/setup.c
> > +++ b/setup.c
> > @@ -2584,10 +2584,15 @@ static int create_default_files(struct repository *repo,
> > if (is_bare_repository())
> > repo_config_set(repo, "core.bare", "true");
> > else {
> > + const char *value;
> > +
> > repo_config_set(repo, "core.bare", "false");
> > +
> > /* allow template config file to override the default */
> > - if (repo_settings_get_log_all_ref_updates(repo) == LOG_REFS_UNSET)
> > + if (repo_config_get_string_tmp(repo, "core.logallrefupdates", &value) ||
> > + refs_parse_log_all_ref_updates_config(value) == LOG_REFS_UNSET)
>
> Huh, can `refs_parse_log_all_ref_updates_config()` even return
> LOG_REFS_UNSET?
It can't, so the second statement is really redundant. All that we care
about there is that the configuration isn't already set, which is
already covered by the first statement.
Will adapt.
Patrick
^ permalink raw reply
* Re: [PATCH v5 08/11] refs/files: lazy-load configuration to fix chicken-and-egg
From: Patrick Steinhardt @ 2026-06-25 6:35 UTC (permalink / raw)
To: Justin Tobler; +Cc: git, Karthik Nayak, Jeff King
In-Reply-To: <ajxKh-IrC2EPWJnW@denethor>
On Wed, Jun 24, 2026 at 04:36:28PM -0500, Justin Tobler wrote:
> On 26/06/22 10:28AM, Patrick Steinhardt wrote:
> > diff --git a/refs/files-backend.c b/refs/files-backend.c
> > index 79fb6735e1..d0f379dcd6 100644
> > --- a/refs/files-backend.c
> > +++ b/refs/files-backend.c
> > @@ -84,12 +84,14 @@ struct files_ref_store {
> > unsigned int store_flags;
> >
> > char *gitcommondir;
> > - enum log_refs_config log_all_ref_updates;
> > - int prefer_symlink_refs;
> > -
> > struct ref_cache *loose;
> > -
> > struct ref_store *packed_ref_store;
> > +
> > + struct files_ref_store_write_options {
> > + enum log_refs_config log_all_ref_updates;
> > + int prefer_symlink_refs;
> > + bool initialized;
> > + } write_opts_lazy_loaded;
>
> It might be nice to leave some sort of breadcrumb comment to future
> readers to explain why we lazy load this configuration.
Fair, will do.
Patrick
^ permalink raw reply
* Re: [PATCH v5 09/11] reftable: split up write options
From: Patrick Steinhardt @ 2026-06-25 6:35 UTC (permalink / raw)
To: Justin Tobler; +Cc: git, Karthik Nayak, Jeff King
In-Reply-To: <ajxR2fLRsIvNYFtz@denethor>
On Wed, Jun 24, 2026 at 05:06:32PM -0500, Justin Tobler wrote:
> On 26/06/22 10:28AM, Patrick Steinhardt wrote:
> > When initializing the reftable stack the caller may optionally pass some
> > write options. These write options mix up two different concerns though:
> >
> > - Of course, they allow the caller to configure how new reftables are
> > being written.
> >
> > - But they also allow the caller to configure the stack itself, like
> > its hash ID and the `on_reload` callback.
> >
> > This is somewhat awkward, as it doesn't easily give the caller the
> > flexibility to for example write multiple reftables with different
> > options. Furthermore, this requires us to eagerly parse relevant
> > configuration when initializing the reftable backend.
>
> Naive question: are there any current use cases where callers may want
> to write multiple reftables with a different set of options? Can
> reftables written with different options pose any correctness issues?
There aren't, but in theory it's totally fine to do it. One could for
example imagine that a large reference transaction wants to use a larger
block size with a different restart interval.
The only thing that of course shouldn't happen is that the different
tables use different hashes.
Patrick
^ permalink raw reply
* Re: [PATCH v5 10/11] refs/reftable: lazy-load configuration to fix chicken-and-egg
From: Patrick Steinhardt @ 2026-06-25 6:36 UTC (permalink / raw)
To: Justin Tobler; +Cc: git, Karthik Nayak, Jeff King
In-Reply-To: <ajxU-McoGrfkeKTs@denethor>
On Wed, Jun 24, 2026 at 05:18:21PM -0500, Justin Tobler wrote:
> On 26/06/22 10:28AM, Patrick Steinhardt wrote:
> > diff --git a/refs/reftable-backend.c b/refs/reftable-backend.c
> > index 608d71cf10..d74131a5ae 100644
> > --- a/refs/reftable-backend.c
> > +++ b/refs/reftable-backend.c
> > @@ -141,10 +141,21 @@ struct reftable_ref_store {
> > */
> > struct strmap worktree_backends;
> > struct reftable_stack_options stack_options;
> > - struct reftable_write_options write_options;
> > +
> > + /*
> > + * Options used when writing to or compacting the reftable stacks.
> > + * These are parsed from the configuration lazily on first use via
> > + * `reftable_be_write_options()` so that we don't have to access the
> > + * configuration when initializing the ref store. Do not access these
> > + * fields directly, but use the accessor instead.
> > + */
> > + struct reftable_be_write_options {
> > + struct reftable_write_options opts;
> > + enum log_refs_config log_all_ref_updates;
>
> Any reason in particular that `log_all_ref_updates` is the only option
> outside of `struct reftlable_write_options` here? Isn't it also only
> used during writes?
`log_all_ref_updates` is part of the backend's logic, whereas the
`struct reftable_write_options` is part of the reftable library's logic.
So they have different scopes, and the former cannot be handled in the
library.
Patrick
^ permalink raw reply
page: next (older) | prev (newer) | latest
- recent:[subjects (threaded)|topics (new)|topics (active)]
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox