history: protect branches when squashing a range

A local branch that descends from the selected graph without containing
its tip cannot be replayed as a descendant of the squashed commit. Find
those branches with ref-filter before creating any replacement objects
and refuse the operation unless --update-refs=head was requested.

Limit this protection to local branches, matching the refs that the
default history rewrite mode updates; tags and remote-tracking refs
remain untouched. Sort the blocking refs and print their short branch
names so the user can decide whether to move them or leave them behind.

Add advice.historyUpdateRefs for the hint that points to
--update-refs=head.

Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
This commit is contained in:
Harald Nordgren
2026-08-07 07:39:29 +00:00
committed by Junio C Hamano
parent 044a87ed3f
commit 8a709ef6d2
5 changed files with 111 additions and 4 deletions

View File

@@ -59,6 +59,10 @@ all advice messages.
forceDeleteBranch::
Shown when the user tries to delete a not fully merged
branch without the force option set.
historyUpdateRefs::
Shown when `git history squash` refuses because a local branch
cannot be rewritten as a descendant of the squashed commit, to
tell the user about `--update-refs=head`.
ignoredHook::
Shown when a hook is ignored because the hook is not
set as executable.

View File

@@ -58,6 +58,7 @@ static struct {
[ADVICE_FETCH_SHOW_FORCED_UPDATES] = { "fetchShowForcedUpdates" },
[ADVICE_FORCE_DELETE_BRANCH] = { "forceDeleteBranch" },
[ADVICE_GRAFT_FILE_DEPRECATED] = { "graftFileDeprecated" },
[ADVICE_HISTORY_UPDATE_REFS] = { "historyUpdateRefs" },
[ADVICE_IGNORED_HOOK] = { "ignoredHook" },
[ADVICE_IMPLICIT_IDENTITY] = { "implicitIdentity" },
[ADVICE_MERGE_CONFLICT] = { "mergeConflict" },

View File

@@ -25,6 +25,7 @@ enum advice_type {
ADVICE_FETCH_SHOW_FORCED_UPDATES,
ADVICE_FORCE_DELETE_BRANCH,
ADVICE_GRAFT_FILE_DEPRECATED,
ADVICE_HISTORY_UPDATE_REFS,
ADVICE_IGNORED_HOOK,
ADVICE_IMPLICIT_IDENTITY,
ADVICE_MERGE_CONFLICT,

View File

@@ -1,6 +1,7 @@
#define USE_THE_REPOSITORY_VARIABLE
#include "builtin.h"
#include "advice.h"
#include "cache-tree.h"
#include "commit.h"
#include "commit-reach.h"
@@ -16,10 +17,12 @@
#include "path.h"
#include "read-cache.h"
#include "refs.h"
#include "ref-filter.h"
#include "replay.h"
#include "reset.h"
#include "revision.h"
#include "sequencer.h"
#include "string-list.h"
#include "strvec.h"
#include "tree.h"
#include "tree-walk.h"
@@ -1061,6 +1064,7 @@ static int setup_squash_revisions(struct repository *repo,
* of the oldest commit.
*/
static int resolve_squash_range(struct repository *repo,
bool update_branches,
int argc, const char **argv,
struct commit **oldest_out,
struct commit **tip_out)
@@ -1069,6 +1073,8 @@ static int resolve_squash_range(struct repository *repo,
struct commit *commit, *oldest = NULL, *tip = NULL;
int ret, tip_count = 0;
bool walk_started = false;
struct ref_filter filter = REF_FILTER_INIT;
struct ref_array refs = { 0 };
ret = setup_squash_revisions(repo, argc, argv, &revs);
if (ret < 0)
@@ -1101,9 +1107,12 @@ static int resolve_squash_range(struct repository *repo,
* Allow parents that match the parents of the
* squashed commit.
*/
for (q = oldest->parents; !seen && q; q = q->next)
if (p->item == q->item)
for (q = oldest->parents; !seen && q; q = q->next) {
if (p->item == q->item) {
seen = true;
commit_list_insert(commit, &filter.with_commit);
}
}
if (!seen) {
ret = error(_("parent %s of commit %s is "
"outside the revision range"),
@@ -1119,12 +1128,17 @@ static int resolve_squash_range(struct repository *repo,
o->flags &= ~SQUASH_TIP;
}
}
if (!oldest)
if (!oldest) {
commit_list_insert(commit, &filter.with_commit);
oldest = commit;
}
tip = commit;
tip->object.flags |= SQUASH_SEEN | SQUASH_TIP;
tip_count++;
}
clear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);
reset_revision_walk();
walk_started = false;
if (!tip_count) {
ret = error(_("the revision range is empty"));
@@ -1141,6 +1155,49 @@ static int resolve_squash_range(struct repository *repo,
BUG("an in-range commit must have a parent");
}
commit_list_insert(tip, &filter.no_commit);
filter.kind = FILTER_REFS_BRANCHES;
if (update_branches &&
filter_refs(&refs, &filter, filter.kind)) {
ret = error(_("could not filter refs"));
goto out;
}
if (refs.nr) {
struct ref_format format = REF_FORMAT_INIT;
struct ref_sorting *sorting;
struct string_list sorting_options = STRING_LIST_INIT_DUP;
struct strbuf branches = STRBUF_INIT;
struct strbuf err = STRBUF_INIT;
format.format = "%(refname:short)";
if (verify_ref_format(&format))
BUG("invalid branch format");
string_list_append(&sorting_options, "refname");
sorting = ref_sorting_options(&sorting_options);
ref_array_sort(sorting, &refs);
for (int i = 0; i < refs.nr; i++) {
strbuf_reset(&err);
strbuf_addstr(&branches, "\n ");
if (format_ref_array_item(refs.items[i], &format,
&branches, &err))
BUG("could not format branch name: %s", err.buf);
}
/*
* TODO: also check HEADS from other worktrees.
*/
ret = error(_("the following branches cannot be rewritten as "
"descendants of the squashed commit:%s"), branches.buf);
advise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,
_("Use --update-refs=head to rewrite only "
"the current branch and leave such branches "
"untouched."));
strbuf_release(&err);
strbuf_release(&branches);
ref_sorting_release(sorting);
string_list_clear(&sorting_options, 0);
goto out;
}
*oldest_out = oldest;
*tip_out = tip;
ret = 0;
@@ -1150,6 +1207,8 @@ out:
if (walk_started)
reset_revision_walk();
release_revisions(&revs);
ref_filter_clear(&filter);
ref_array_clear(&refs);
return ret;
}
@@ -1183,8 +1242,11 @@ static int cmd_history_squash(int argc,
if (argc < 2)
return error(_("command expects a revision range"));
repo_config(repo, git_default_config, NULL);
if (action == REF_ACTION_DEFAULT)
action = REF_ACTION_BRANCHES;
ret = resolve_squash_range(repo, argc, argv, &oldest, &tip);
ret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,
argc, argv, &oldest, &tip);
if (ret < 0)
return ret;

View File

@@ -62,4 +62,43 @@ test_expect_success 'rejects a merge parent outside the range' '
test_grep "parent .* of commit .* is outside the revision range" err
'
test_expect_success 'prints branches that cannot follow the squash' '
test_when_finished \
"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \
git branch -D feature" &&
git checkout -f -b feature start &&
test_commit C1 &&
test_commit C2 &&
git checkout -b topic-1 start &&
test_commit C3 &&
test_commit C4 &&
git checkout C3 &&
test_commit C5 &&
git checkout feature &&
git merge C5 &&
test_commit C6 &&
git checkout -b topic-2 C2 &&
test_commit C7 &&
git checkout feature &&
test_must_fail git history squash start.. 2>err &&
test_grep "^error: the following branches cannot be rewritten" err &&
test_grep "^ topic-1$" err &&
test_grep "^ topic-2$" err &&
test_grep "^hint: .* --update-refs=head" err
'
test_expect_success 'advice.historyUpdateRefs silences the hint' '
git reset --hard three &&
git branch -f mid HEAD~1 &&
test_must_fail git -c advice.historyUpdateRefs=false \
history squash start.. 2>err &&
test_grep "^error: the following branches cannot be rewritten" err &&
test_grep "^ mid$" err &&
test_grep ! "hint:" err &&
git branch -D mid
'
test_done