From 9c82ba86a0c5e630d5defbe8118612fd5c5e4612 Mon Sep 17 00:00:00 2001 From: Son Luong Ngoc Date: Wed, 22 Jul 2026 08:15:05 +0000 Subject: [PATCH 1/2] rebase: skip branch symref aliases git rebase --update-refs can finish rewriting the current branch and then fail while updating a local branch that is a symbolic ref. This can happen during a default-branch rename where refs/heads/main points at refs/heads/master while users migrate. The problem is a partially applied ref update: the main rebase has already succeeded when the later ref update fails. The sequencer queues updates from local branch decorations. Commit 106b6885c7 (rebase: ignore non-branch update-refs) filters out decorations such as HEAD and tags. A branch symref is still a local branch decoration, but refs_update_ref() dereferences it, so an alias to another branch duplicates the concrete branch update. Resolve local branch decorations before queuing them. Skip symrefs whose targets are under refs/heads/ so that only the concrete branch update is queued. Keep an owned copy of the resolved HEAD and skip the current branch before checked-out handling so later ref resolution cannot change the comparison. This prevents a successful rebase from being followed by a failed, partially applied ref update while preserving each alias as a symref. Signed-off-by: Son Luong Ngoc Signed-off-by: Junio C Hamano --- sequencer.c | 44 +++++++++++++++++++++++++---------- t/t3400-rebase.sh | 2 +- t/t3404-rebase-interactive.sh | 16 +++++++++++++ 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/sequencer.c b/sequencer.c index 1ee4b2875b..17f5baab62 100644 --- a/sequencer.c +++ b/sequencer.c @@ -6445,32 +6445,50 @@ static int add_decorations_to_list(const struct commit *commit, struct todo_add_branch_context *ctx) { const struct name_decoration *decoration = get_name_decoration(&commit->object); - const char *head_ref = refs_resolve_ref_unsafe(get_main_ref_store(the_repository), - "HEAD", - RESOLVE_REF_READING, - NULL, - NULL); + struct ref_store *refs = get_main_ref_store(the_repository); + char *head_ref = refs_resolve_refdup(refs, "HEAD", + RESOLVE_REF_READING, + NULL, NULL); while (decoration) { struct todo_item *item; const char *path; + char *resolved_ref; + int flags = 0; size_t base_offset = ctx->buf->len; /* - * If the branch is the current HEAD, then it will be - * updated by the default rebase behavior. - * Exclude it from the list of refs to update, - * as well as any non-branch decorations. * Non-branch decorations may be present if the pretty format * includes "%d", which would have loaded all refs * into the global decoration table. */ - if ((head_ref && !strcmp(head_ref, decoration->name)) || - (decoration->type != DECORATION_REF_LOCAL)) { + if (decoration->type != DECORATION_REF_LOCAL) { decoration = decoration->next; continue; } + resolved_ref = refs_resolve_refdup(refs, decoration->name, + RESOLVE_REF_READING, + NULL, &flags); + if (resolved_ref && (flags & REF_ISSYMREF) && + starts_with(resolved_ref, "refs/heads/")) { + free(resolved_ref); + decoration = decoration->next; + continue; + } + + /* + * If the branch is the current HEAD, then it will be + * updated by the default rebase behavior. + */ + if (head_ref && !strcmp(head_ref, decoration->name)) { + free(resolved_ref); + decoration = decoration->next; + continue; + } + + path = branch_checked_out(decoration->name); + ALLOC_GROW(ctx->items, ctx->items_nr + 1, ctx->items_alloc); @@ -6478,7 +6496,7 @@ static int add_decorations_to_list(const struct commit *commit, memset(item, 0, sizeof(*item)); /* If the branch is checked out, then leave a comment instead. */ - if ((path = branch_checked_out(decoration->name))) { + if (path) { item->command = TODO_COMMENT; strbuf_commented_addf(ctx->buf, comment_line_str, "Ref %s checked out at '%s'\n", @@ -6498,9 +6516,11 @@ static int add_decorations_to_list(const struct commit *commit, item->arg_len = ctx->buf->len - base_offset; ctx->items_nr++; + free(resolved_ref); decoration = decoration->next; } + free(head_ref); return 0; } diff --git a/t/t3400-rebase.sh b/t/t3400-rebase.sh index c0c00fbb7b..be120bdcd1 100755 --- a/t/t3400-rebase.sh +++ b/t/t3400-rebase.sh @@ -471,7 +471,7 @@ test_expect_success 'git rebase --update-ref with core.commentChar and branch on GIT_SEQUENCE_EDITOR="cat >actual" git -c core.commentChar=% \ rebase -i --update-refs base && test_grep "% Ref refs/heads/wt-topic checked out at" actual && - test_grep "% Ref refs/heads/topic2 checked out at" actual + test_grep ! "% Ref refs/heads/topic2 checked out at" actual ' test_done diff --git a/t/t3404-rebase-interactive.sh b/t/t3404-rebase-interactive.sh index 58b3bb0c27..b05d938846 100755 --- a/t/t3404-rebase-interactive.sh +++ b/t/t3404-rebase-interactive.sh @@ -1975,15 +1975,23 @@ test_expect_success '--update-refs ignores non-branch decorations' ' ) && grep ^update-ref todo >actual && test_write_lines "update-ref refs/heads/no-conflict-branch" >expect && + test_grep ! "^# Ref refs/heads/update-refs checked out" todo && test_cmp expect actual ' test_expect_success '--update-refs updates refs correctly' ' + test_when_finished " + test_might_fail git symbolic-ref -d refs/heads/no-conflict-branch-alias && + test_might_fail git symbolic-ref -d refs/heads/second-alias + " && git checkout -B update-refs no-conflict-branch && git branch -f base HEAD~4 && git branch -f first HEAD~3 && git branch -f second HEAD~3 && git branch -f third HEAD~1 && + git symbolic-ref refs/heads/no-conflict-branch-alias \ + refs/heads/no-conflict-branch && + git symbolic-ref refs/heads/second-alias refs/heads/second && test_commit extra2 fileX && git commit --amend --fixup=L && @@ -1991,8 +1999,16 @@ test_expect_success '--update-refs updates refs correctly' ' test_cmp_rev HEAD~3 refs/heads/first && test_cmp_rev HEAD~3 refs/heads/second && + test_cmp_rev HEAD~3 refs/heads/second-alias && test_cmp_rev HEAD~1 refs/heads/third && test_cmp_rev HEAD refs/heads/no-conflict-branch && + test_cmp_rev HEAD refs/heads/no-conflict-branch-alias && + test_write_lines refs/heads/no-conflict-branch >expect && + git symbolic-ref refs/heads/no-conflict-branch-alias >actual && + test_cmp expect actual && + test_write_lines refs/heads/second >expect && + git symbolic-ref refs/heads/second-alias >actual && + test_cmp expect actual && q_to_tab >expect <<-\EOF && Successfully rebased and updated refs/heads/update-refs. From 056eed4ba7c60dfbb220e2ecc49313c919a22221 Mon Sep 17 00:00:00 2001 From: Son Luong Ngoc Date: Wed, 22 Jul 2026 08:15:06 +0000 Subject: [PATCH 2/2] rebase: guard non-branch symref targets A local branch symbolic ref may point outside refs/heads/. Such an alias cannot be skipped like a branch-to-branch alias because its concrete target ref is absent from the local branch decoration list. However, queuing each alias independently can update the same target ref more than once and make the second compare-and-swap fail. A reservation from another worktree can also name either an alias or its resolved target ref, so checking only one form can miss an in-progress update. Fix these cases by checking both the literal alias and its resolved target ref against checked-out reservations. Deduplicate updates by target ref. Also reserve both forms when loading another worktree's update-refs state. This makes different aliases honor the same in-progress update. This keeps non-branch symrefs supported without allowing duplicate or cross-worktree ref updates. Signed-off-by: Son Luong Ngoc Signed-off-by: Junio C Hamano --- branch.c | 15 ++++++++ sequencer.c | 19 +++++++++ t/t3404-rebase-interactive.sh | 72 +++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/branch.c b/branch.c index 243db7d0fc..98a50d8368 100644 --- a/branch.c +++ b/branch.c @@ -442,10 +442,25 @@ static void prepare_checked_out_branches(void) &update_refs)) { struct string_list_item *item; for_each_string_list_item(item, &update_refs) { + char *resolved_ref; + int flags = 0; + old = strmap_put(¤t_checked_out_branches, item->string, xstrdup(wt->path)); free(old); + + resolved_ref = refs_resolve_refdup( + get_main_ref_store(the_repository), + item->string, RESOLVE_REF_READING, + NULL, &flags); + if (resolved_ref && (flags & REF_ISSYMREF)) { + old = strmap_put( + ¤t_checked_out_branches, + resolved_ref, xstrdup(wt->path)); + free(old); + } + free(resolved_ref); } string_list_clear(&update_refs, 1); } diff --git a/sequencer.c b/sequencer.c index 17f5baab62..73c7d7f0c7 100644 --- a/sequencer.c +++ b/sequencer.c @@ -6439,6 +6439,7 @@ struct todo_add_branch_context { size_t items_alloc; struct strbuf *buf; struct string_list refs_to_oids; + struct string_list symref_update_targets; }; static int add_decorations_to_list(const struct commit *commit, @@ -6453,6 +6454,7 @@ static int add_decorations_to_list(const struct commit *commit, while (decoration) { struct todo_item *item; const char *path; + const char *checked_ref; char *resolved_ref; int flags = 0; size_t base_offset = ctx->buf->len; @@ -6488,6 +6490,17 @@ static int add_decorations_to_list(const struct commit *commit, } path = branch_checked_out(decoration->name); + if (!path && resolved_ref && (flags & REF_ISSYMREF)) { + checked_ref = resolved_ref; + path = branch_checked_out(checked_ref); + } + if (!path && resolved_ref && (flags & REF_ISSYMREF) && + string_list_has_string(&ctx->symref_update_targets, + resolved_ref)) { + free(resolved_ref); + decoration = decoration->next; + continue; + } ALLOC_GROW(ctx->items, ctx->items_nr + 1, @@ -6503,6 +6516,10 @@ static int add_decorations_to_list(const struct commit *commit, decoration->name, path); } else { struct string_list_item *sti; + + if (resolved_ref && (flags & REF_ISSYMREF)) + string_list_insert(&ctx->symref_update_targets, + resolved_ref); item->command = TODO_UPDATE_REF; strbuf_addf(ctx->buf, "%s\n", decoration->name); @@ -6534,6 +6551,7 @@ static int todo_list_add_update_ref_commands(struct todo_list *todo_list) struct todo_add_branch_context ctx = { .buf = &todo_list->buf, .refs_to_oids = STRING_LIST_INIT_DUP, + .symref_update_targets = STRING_LIST_INIT_DUP, }; ctx.items_alloc = 2 * todo_list->nr + 1; @@ -6559,6 +6577,7 @@ static int todo_list_add_update_ref_commands(struct todo_list *todo_list) res = write_update_refs_state(&ctx.refs_to_oids); string_list_clear(&ctx.refs_to_oids, 1); + string_list_clear(&ctx.symref_update_targets, 0); if (res) { /* we failed, so clean up the new list. */ diff --git a/t/t3404-rebase-interactive.sh b/t/t3404-rebase-interactive.sh index b05d938846..54981c909b 100755 --- a/t/t3404-rebase-interactive.sh +++ b/t/t3404-rebase-interactive.sh @@ -2024,6 +2024,78 @@ test_expect_success '--update-refs updates refs correctly' ' test_cmp expect err.trimmed ' +test_expect_success '--update-refs checks resolved non-branch symref target' ' + test_when_finished " + git worktree remove --force checked-out-target-wt && + git symbolic-ref -d refs/heads/non-branch-alias && + git tag -d checked-out-target + " && + git tag checked-out-target HEAD~1 && + git symbolic-ref refs/heads/non-branch-alias refs/tags/checked-out-target && + git worktree add --detach checked-out-target-wt checked-out-target && + git -C checked-out-target-wt symbolic-ref HEAD refs/tags/checked-out-target && + + GIT_SEQUENCE_EDITOR="cat >todo" git rebase -i --update-refs HEAD~2 && + + test_grep "^# Ref refs/heads/non-branch-alias checked out at" todo && + test_write_lines refs/tags/checked-out-target >expect && + git symbolic-ref refs/heads/non-branch-alias >actual && + test_cmp expect actual +' + +test_expect_success '--update-refs deduplicates non-branch symref targets' ' + test_when_finished " + git symbolic-ref -d refs/heads/non-branch-alias-one && + git symbolic-ref -d refs/heads/non-branch-alias-two && + git tag -d shared-non-branch-target + " && + git tag shared-non-branch-target HEAD~1 && + git symbolic-ref refs/heads/non-branch-alias-one \ + refs/tags/shared-non-branch-target && + git symbolic-ref refs/heads/non-branch-alias-two \ + refs/tags/shared-non-branch-target && + + GIT_SEQUENCE_EDITOR=: git rebase -i --force-rebase --update-refs HEAD~2 && + + test_cmp_rev HEAD~1 refs/heads/non-branch-alias-one && + test_cmp_rev HEAD~1 refs/heads/non-branch-alias-two && + test_write_lines refs/tags/shared-non-branch-target >expect && + git symbolic-ref refs/heads/non-branch-alias-one >actual && + test_cmp expect actual && + git symbolic-ref refs/heads/non-branch-alias-two >actual && + test_cmp expect actual +' + +test_expect_success '--update-refs honors non-branch symref reservations' ' + test_when_finished " + test_might_fail git worktree remove --force reserved-target-wt && + test_might_fail git symbolic-ref -d \ + refs/heads/reserved-non-branch-alias-one && + test_might_fail git symbolic-ref -d \ + refs/heads/reserved-non-branch-alias-two && + test_might_fail git tag -d reserved-non-branch-target + " && + git tag reserved-non-branch-target HEAD~1 && + git symbolic-ref refs/heads/reserved-non-branch-alias-one \ + refs/tags/reserved-non-branch-target && + git symbolic-ref refs/heads/reserved-non-branch-alias-two \ + refs/tags/reserved-non-branch-target && + git worktree add --detach reserved-target-wt HEAD && + wt_gitdir=$(git -C reserved-target-wt rev-parse --absolute-git-dir) && + mkdir -p "$wt_gitdir/rebase-merge" && + old_oid=$(git rev-parse refs/heads/reserved-non-branch-alias-one) && + test_write_lines refs/heads/reserved-non-branch-alias-one \ + "$old_oid" "$old_oid" >"$wt_gitdir/rebase-merge/update-refs" && + + GIT_SEQUENCE_EDITOR="cat >todo" git rebase -i --update-refs HEAD~2 && + + test_grep "^# Ref refs/heads/reserved-non-branch-alias-one checked out at" \ + todo && + test_grep "^# Ref refs/heads/reserved-non-branch-alias-two checked out at" \ + todo && + test_grep ! "^update-ref refs/heads/reserved-non-branch-alias" todo +' + test_expect_success 'respect user edits to update-ref steps' ' git checkout -B update-refs-break no-conflict-branch && git branch -f base HEAD~4 &&