{"thread":{"id":"66351","subject":"[BUG] reference-transaction reports zero OIDs for branch and tag deletion","startedAt":"2026-09-19T13:34:28Z","lastAt":"2026-10-01T17:37:19Z","messageCount":52,"participants":["Maciej Ciemborowicz","D. Ben Knoble","Karthik Nayak","Junio C Hamano","Patrick Steinhardt"],"isPatch":false,"patchVersion":null,"patchTotal":null},"messages":[{"id":"552877","messageId":"CACQ=SRGTTdQ+dHXhN6F52dBv5KxZBRfk_Em2fvmEmGJDoB6oTg@mail.gmail.com","threadId":"66351","inReplyTo":null,"subject":"[BUG] reference-transaction reports zero OIDs for branch and tag deletion","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-19T13:34:15Z","receivedAt":"2026-09-19T13:34:28Z","isPatch":false,"body":"Since Git 2.31, the reference-transaction hook receives all-zero old and\nnew object IDs when a branch or tag is deleted with a high-level command.\n\nFor example, `git branch -d topic` produces:\n\n    0000000000000000000000000000000000000000 \\\n    0000000000000000000000000000000000000000 \\\n    refs/heads/topic\n\nThe same happens with `git branch -D`, `git tag -d`, and deletion of a\nremote-tracking ref by `git remote prune`.\n\nGit 2.28 through 2.30 reported the previous object ID followed by the\nall-zero object ID. Starting with Git 2.31, that information is lost.\nThe behavior is still present in Git 2.55 with both the files and\nreftable backends.\n\nA direct deletion with:\n\n    git update-ref -d refs/heads/topic \"$old_oid\"\n\ncontinues to report the useful payload:\n\n    <old-oid> 0000000000000000000000000000000000000000 refs/heads/topic\n\nMinimal reproducer:\n\n    #!/bin/sh\n    set -eu\n\n    root=$(mktemp -d)\n    trap 'rm -rf \"$root\"' EXIT\n\n    repo=$root/repo\n    hooks=$root/hooks\n    log=$root/transactions\n\n    git init -q \"$repo\"\n    git -C \"$repo\" config user.name Reproducer\n    git -C \"$repo\" config user.email repro@example.com\n    git -C \"$repo\" commit --allow-empty -qm initial\n\n    git -C \"$repo\" branch topic\n    git -C \"$repo\" tag v1\n\n    mkdir \"$hooks\"\n    cat >\"$hooks/reference-transaction\" <<'HOOK'\n    #!/bin/sh\n    printf '%s\\n' \"--- $1\" >>\"$HOOK_LOG\"\n    cat >>\"$HOOK_LOG\"\n    HOOK\n    chmod +x \"$hooks/reference-transaction\"\n\n    git -C \"$repo\" config core.hooksPath \"$hooks\"\n    export HOOK_LOG=$log\n    : >\"$log\"\n\n    git -C \"$repo\" branch -d topic\n    git -C \"$repo\" tag -d v1\n\n    cat \"$log\"\n\nActual output for the committed transactions is equivalent to:\n\n    0000000000000000000000000000000000000000 \\\n    0000000000000000000000000000000000000000 \\\n    refs/heads/topic\n    0000000000000000000000000000000000000000 \\\n    0000000000000000000000000000000000000000 \\\n    refs/tags/v1\n\nI expected:\n\n    <old-oid> 0000000000000000000000000000000000000000 refs/heads/topic\n    <old-oid> 0000000000000000000000000000000000000000 refs/tags/v1\n\nI understand that the reference-transaction documentation permits an\nall-zero old value when a ref is force-updated without checking its\ncurrent value. However, `git branch -d` is a safety-checked deletion,\nGit has already resolved the branch being deleted, and Git 2.28–2.30\nprovided its old object ID.\n\nWas this loss of information intentional? If not, could the previous\nobject ID be restored for these deletion paths? If it is intentional,\nthe documentation may need to clarify that high-level deletion commands\ncan provide a zero-to-zero record.\n\nThe behavior was tested across Git 2.28–2.55. The compatibility results\nand test implementation are available here:\n\nhttps://github.com/ciembor/git-hooks-ext/blob/v0.2.0/tests/compat/README.md\nhttps://github.com/ciembor/git-hooks-ext/blob/v0.2.0/tests/compat/e2e.sh\n\nThanks,\nMaciej Ciemborowicz\n"},{"id":"552882","messageId":"CALnO6CBUr3=Cj57ikytiPxU-1hZkYu1Z3fRPydhFLqJHprbDew@mail.gmail.com","threadId":"66351","inReplyTo":"CACQ=SRGTTdQ+dHXhN6F52dBv5KxZBRfk_Em2fvmEmGJDoB6oTg@mail.gmail.com","subject":"Re: [BUG] reference-transaction reports zero OIDs for branch and tag deletion","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-09-19T14:59:33Z","receivedAt":"2026-09-19T14:59:46Z","isPatch":false,"body":"On Sat, Sep 19, 2026 at 9:34 AM Maciej Ciemborowicz\n<maciej.ciemborowicz@gmail.com> wrote:\n>\n> Since Git 2.31, the reference-transaction hook receives all-zero old and\n> new object IDs when a branch or tag is deleted with a high-level command.\n\n[snip]\n\n> Was this loss of information intentional? If not, could the previous\n> object ID be restored for these deletion paths? If it is intentional,\n> the documentation may need to clarify that high-level deletion commands\n> can provide a zero-to-zero record.\n\nSince you seem to have identified a \"good\" and \"bad\" version with a\nreproduction script, I suspect \"git bisect\" is a good way to answer\nyour questions about intent.\n\n-- \nD. Ben Knoble\n"},{"id":"552886","messageId":"CACQ=SRHthWOLVXmY6wgknOPgpQ+oB1vV-Q0AL=mK9mXb2Xy9Nw@mail.gmail.com","threadId":"66351","inReplyTo":"CALnO6CBUr3=Cj57ikytiPxU-1hZkYu1Z3fRPydhFLqJHprbDew@mail.gmail.com","subject":"Re: [BUG] reference-transaction reports zero OIDs for branch and tag deletion","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-19T15:42:59Z","receivedAt":"2026-09-19T15:43:13Z","isPatch":false,"body":"Commit `6754159767` introduced `reference-transaction` in Git 2.28. In\nGit 2.28-2.30, `git branch -D` and `git tag -d` used `delete_ref()`\nwith the known old OID. Commit `8198907795`, before Git 2.31, replaced\nsingle-ref deletion with `delete_refs()`. The new function preserved\nonly the ref names and called:\n\n```c\nref_transaction_delete(transaction, refname, NULL, NULL, ...)\n```\n\nWithout the old OID, the `REF_HAVE_OLD` flag is not set, so the hook receives:\n\n```text\n000000... 000000... refs/heads/topic\n```\n\nThe change was intended to speed up deletion of 24,000 tags from\nroughly 30 minutes to 5 seconds. The loss of information exposed to\nthe hook appears to have been a side effect. So I assume this is a bug\nintroduced by that optimization, and it should be fixed in a way that\npreserves the performance improvement. That seems feasible.\n\nCheers,\nMaciej Ciemborowicz\n\n\nOn Sat, Sep 19, 2026 at 4:59 PM D. Ben Knoble <ben.knoble@gmail.com> wrote:\n>\n> On Sat, Sep 19, 2026 at 9:34 AM Maciej Ciemborowicz\n> <maciej.ciemborowicz@gmail.com> wrote:\n> >\n> > Since Git 2.31, the reference-transaction hook receives all-zero old and\n> > new object IDs when a branch or tag is deleted with a high-level command.\n>\n> [snip]\n>\n> > Was this loss of information intentional? If not, could the previous\n> > object ID be restored for these deletion paths? If it is intentional,\n> > the documentation may need to clarify that high-level deletion commands\n> > can provide a zero-to-zero record.\n>\n> Since you seem to have identified a \"good\" and \"bad\" version with a\n> reproduction script, I suspect \"git bisect\" is a good way to answer\n> your questions about intent.\n>\n> --\n> D. Ben Knoble\n"},{"id":"552894","messageId":"20260919201158.43415-1-maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"CACQ=SRHthWOLVXmY6wgknOPgpQ+oB1vV-Q0AL=mK9mXb2Xy9Nw@mail.gmail.com","subject":"[PATCH 0/3] refs: report old OIDs for batched deletions","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-19T20:11:55Z","receivedAt":"2026-09-19T20:12:46Z","isPatch":true,"body":"This follows up on the reference-transaction bug report at [1].\n\nThe reference-transaction hook reports an all-zero update when `git branch\n-d`, `git tag -d`, `git remote prune`, or `git fetch --prune` deletes a\nref. This prevents hook consumers from identifying the object that the ref\npointed to.\n\nFor branch and tag deletion, this is a regression caused by 8198907795 (use\ndelete_refs when deleting tags or branches, 2021-01-21). That change made\nlarge deletions much faster by batching them, but the batch helper did not\naccept the old OIDs that both callers had already resolved. The pruning paths\nhave the same omission.\n\nTeach refs_delete_refs() to accept optional old OIDs, then pass the values\nalready available to the branch, tag, fetch, and remote callers. This keeps a\nsingle batched transaction and does not add any ref reads.\n\nDeleting 10,000 packed tags took a median of 0.86 seconds before this series\nand 0.85 seconds after it across five runs, which is within measurement noise.\n\nThe regression tests use packed refs and cover the files and reftable\nbackends, distinct old OIDs, and regular and atomic fetch pruning. The full\ntest suite passes with DEVELOPER=1. Additional checks covered SHA-1, SHA-256,\nbroken and symbolic refs, and refs shadowed between loose and packed storage.\n\n[1] https://lore.kernel.org/git/CACQ=SRGTTdQ+dHXhN6F52dBv5KxZBRfk_Em2fvmEmGJDoB6oTg@mail.gmail.com/\n\nMaciej Ciemborowicz (3):\n  refs: allow callers to supply old OIDs for batch deletion\n  branch, tag: retain old OIDs in batched deletions\n  fetch, remote: retain old OIDs when pruning refs\n\n bisect.c                         |  2 +-\n builtin/branch.c                 |  7 +++-\n builtin/fetch.c                  | 11 +++--\n builtin/remote.c                 | 33 ++++++++++++---\n builtin/tag.c                    |  7 +++-\n refs.c                           | 26 +++++++-----\n refs.h                           | 12 +++++-\n t/helper/test-ref-store.c        |  2 +-\n t/t1416-ref-transaction-hooks.sh | 70 ++++++++++++++++++++++++++++++++\n 9 files changed, 144 insertions(+), 26 deletions(-)\n\n\nbase-commit: e9019fcafe0040228b8631c30f97ae1adb61bcdc\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"552895","messageId":"20260919201158.43415-2-maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"20260919201158.43415-1-maciej.ciemborowicz@gmail.com","subject":"[PATCH 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-19T20:11:56Z","receivedAt":"2026-09-19T20:12:48Z","isPatch":true,"body":"refs_delete_refs() currently performs unconditional deletions. Thus callers\ncannot preserve old values that they have already resolved, and\nreference-transaction hooks consequently see a null old OID.\n\nAdd an optional oid_array whose entries correspond to the refnames. Pass each\nnon-null OID to ref_transaction_delete(). Existing callers retain the\nunconditional behavior for now.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n bisect.c                  |  2 +-\n builtin/branch.c          |  3 ++-\n builtin/fetch.c           |  2 +-\n builtin/remote.c          |  5 +++--\n builtin/tag.c             |  3 ++-\n refs.c                    | 26 +++++++++++++++-----------\n refs.h                    | 12 ++++++++++--\n t/helper/test-ref-store.c |  2 +-\n 8 files changed, 35 insertions(+), 20 deletions(-)\n\ndiff --git a/bisect.c b/bisect.c\nindex 94c7028d2a..9aa3bace9f 100644\n--- a/bisect.c\n+++ b/bisect.c\n@@ -1203,7 +1203,7 @@ int bisect_clean_state(void)\n \tstring_list_append(&refs_for_removal, \"BISECT_EXPECTED_REV\");\n \tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t  \"bisect: remove\", &refs_for_removal,\n-\t\t\t\t  REF_NO_DEREF);\n+\t\t\t\t  NULL, REF_NO_DEREF);\n \tstring_list_clear(&refs_for_removal, 0);\n \tunlink_or_warn(git_path_bisect_ancestors_ok());\n \tunlink_or_warn(git_path_bisect_log());\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..f1abeb681d 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -322,7 +322,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n+\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex c1d7c672f4..d202147b21 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -1467,7 +1467,7 @@ static int prune_refs(struct display_state *display_state,\n \t\t} else {\n \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n-\t\t\t\t\t\t  0);\n+\t\t\t\t\t\t  NULL, 0);\n \t\t}\n \t}\n \ndiff --git a/builtin/remote.c b/builtin/remote.c\nindex de989ea3ba..13d3cc52dd 100644\n--- a/builtin/remote.c\n+++ b/builtin/remote.c\n@@ -1073,7 +1073,7 @@ static int rm(int argc, const char **argv, const char *prefix,\n \tif (!result)\n \t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t  \"remote: remove\", &branches,\n-\t\t\t\t\t  REF_NO_DEREF);\n+\t\t\t\t\t  NULL, REF_NO_DEREF);\n \tstring_list_clear(&branches, 0);\n \n \tif (skipped.nr) {\n@@ -1645,7 +1645,8 @@ static int prune_remote(const char *remote, int dry_run)\n \n \tif (!dry_run)\n \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n-\t\t\t\t\t   \"remote: prune\", &refs_to_prune, 0);\n+\t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n+\t\t\t\t\t   NULL, 0);\n \n \tfor_each_string_list_item(item, &states.stale) {\n \t\tconst char *refname = item->util;\ndiff --git a/builtin/tag.c b/builtin/tag.c\nindex 06c125b53c..40874a2923 100644\n--- a/builtin/tag.c\n+++ b/builtin/tag.c\n@@ -122,7 +122,8 @@ static int delete_tags(const char **argv)\n \tstruct string_list_item *item;\n \n \tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n+\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n \t\tresult = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\ndiff --git a/refs.c b/refs.c\nindex d3caa9a633..a9c5397fd7 100644\n--- a/refs.c\n+++ b/refs.c\n@@ -18,6 +18,7 @@\n #include \"refs/refs-internal.h\"\n #include \"hook.h\"\n #include \"object-name.h\"\n+#include \"oid-array.h\"\n #include \"odb.h\"\n #include \"object.h\"\n #include \"path.h\"\n@@ -3056,36 +3057,39 @@ void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n }\n \n int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n-\t\t     struct string_list *refnames, unsigned int flags)\n+\t\t     struct string_list *refnames,\n+\t\t     const struct oid_array *old_oids,\n+\t\t     unsigned int flags)\n {\n \tstruct ref_transaction *transaction;\n \tstruct strbuf err = STRBUF_INIT;\n-\tstruct string_list_item *item;\n+\tsize_t i;\n \tint ret = 0, failures = 0;\n \tchar *msg;\n \n+\tif (old_oids && old_oids->nr != refnames->nr)\n+\t\tBUG(\"refname and old OID counts do not match\");\n \tif (!refnames->nr)\n \t\treturn 0;\n \n \tmsg = normalize_reflog_message(logmsg);\n \n-\t/*\n-\t * Since we don't check the references' old_oids, the\n-\t * individual updates can't fail, so we can pack all of the\n-\t * updates into a single transaction.\n-\t */\n \ttransaction = ref_store_transaction_begin(refs, 0, &err);\n \tif (!transaction) {\n \t\tret = error(\"%s\", err.buf);\n \t\tgoto out;\n \t}\n \n-\tfor_each_string_list_item(item, refnames) {\n-\t\tret = ref_transaction_delete(transaction, item->string,\n-\t\t\t\t\t     NULL, NULL, flags, msg, &err);\n+\tfor (i = 0; i < refnames->nr; i++) {\n+\t\tconst struct object_id *old_oid = old_oids ? &old_oids->oid[i] : NULL;\n+\n+\t\tif (old_oid && is_null_oid(old_oid))\n+\t\t\told_oid = NULL;\n+\t\tret = ref_transaction_delete(transaction, refnames->items[i].string,\n+\t\t\t\t\t     old_oid, NULL, flags, msg, &err);\n \t\tif (ret) {\n \t\t\twarning(_(\"could not delete reference %s: %s\"),\n-\t\t\t\titem->string, err.buf);\n+\t\t\t\trefnames->items[i].string, err.buf);\n \t\t\tstrbuf_reset(&err);\n \t\t\tfailures = 1;\n \t\t}\ndiff --git a/refs.h b/refs.h\nindex 71d5c186d0..b76b556cf7 100644\n--- a/refs.h\n+++ b/refs.h\n@@ -9,6 +9,7 @@\n struct fsck_options;\n struct object_id;\n struct ref_store;\n+struct oid_array;\n struct strbuf;\n struct string_list;\n struct string_list_item;\n@@ -613,13 +614,20 @@ int refs_delete_ref(struct ref_store *refs, const char *msg,\n \t\t    unsigned int flags);\n \n /*\n- * Delete the specified references. If there are any problems, emit\n+ * Delete the specified references. If old_oids is non-NULL, it must contain\n+ * an entry for each refname, in the same order. Each non-null entry is used\n+ * to verify the current value of the corresponding reference before deleting\n+ * it. A null entry disables verification for that reference.\n+ *\n+ * If there are any problems, emit\n  * errors but attempt to keep going (i.e., the deletes are not done in\n  * an all-or-nothing transaction). msg and flags are passed through to\n  * ref_transaction_delete().\n  */\n int refs_delete_refs(struct ref_store *refs, const char *msg,\n-\t\t     struct string_list *refnames, unsigned int flags);\n+\t\t     struct string_list *refnames,\n+\t\t     const struct oid_array *old_oids,\n+\t\t     unsigned int flags);\n \n /** Delete a reflog */\n int refs_delete_reflog(struct ref_store *refs, const char *refname);\ndiff --git a/t/helper/test-ref-store.c b/t/helper/test-ref-store.c\nindex 3866d0aca4..c2c7dfb065 100644\n--- a/t/helper/test-ref-store.c\n+++ b/t/helper/test-ref-store.c\n@@ -140,7 +140,7 @@ static int cmd_delete_refs(struct ref_store *refs, const char **argv)\n \twhile (*argv)\n \t\tstring_list_append(&refnames, *argv++);\n \n-\tresult = refs_delete_refs(refs, msg, &refnames, flags);\n+\tresult = refs_delete_refs(refs, msg, &refnames, NULL, flags);\n \tstring_list_clear(&refnames, 0);\n \treturn result;\n }\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"552896","messageId":"20260919201158.43415-3-maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"20260919201158.43415-1-maciej.ciemborowicz@gmail.com","subject":"[PATCH 2/3] branch, tag: retain old OIDs in batched deletions","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-19T20:11:57Z","receivedAt":"2026-09-19T20:12:49Z","isPatch":true,"body":"Since 8198907795 (use delete_refs when deleting tags or branches,\n2021-01-21), branch and tag deletion pass no old OIDs to the ref transaction.\nAs a result, reference-transaction hooks report zero as both the old and new\nOID.\n\nBoth commands already resolve the old OIDs before starting the deletion. Pass\nthose values to refs_delete_refs() so hooks receive useful old values without\nadding any ref reads.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n builtin/branch.c                 |  6 +++++-\n builtin/tag.c                    |  6 +++++-\n t/t1416-ref-transaction-hooks.sh | 28 ++++++++++++++++++++++++++++\n 3 files changed, 38 insertions(+), 2 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex f1abeb681d..9f03ebc095 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -16,6 +16,7 @@\n #include \"commit.h\"\n #include \"gettext.h\"\n #include \"object-name.h\"\n+#include \"oid-array.h\"\n #include \"remote.h\"\n #include \"parse-options.h\"\n #include \"branch.h\"\n@@ -230,6 +231,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \tstruct string_list_item *item;\n \tint branch_name_pos;\n \tconst char *fmt_remotes = \"refs/remotes/%s\";\n@@ -314,6 +316,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n+\t\toid_array_append(&old_oids, &oid);\n \t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n \t\t\t\t    : (flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n@@ -323,7 +326,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \n \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n-\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n+\t\t\t     &refs_to_delete, &old_oids, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n@@ -342,6 +345,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tfree(describe_ref);\n \t}\n \tstring_list_clear(&refs_to_delete, 0);\n+\toid_array_clear(&old_oids);\n \n \tfree(name);\n \tstrbuf_release(&bname);\ndiff --git a/builtin/tag.c b/builtin/tag.c\nindex 40874a2923..0a3eb70faf 100644\n--- a/builtin/tag.c\n+++ b/builtin/tag.c\n@@ -119,11 +119,14 @@ static int delete_tags(const char **argv)\n {\n \tint result;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \tstruct string_list_item *item;\n \n \tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n+\tfor_each_string_list_item(item, &refs_to_delete)\n+\t\toid_array_append(&old_oids, item->util);\n \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n-\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n+\t\t\t     &refs_to_delete, &old_oids, REF_NO_DEREF))\n \t\tresult = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n@@ -137,6 +140,7 @@ static int delete_tags(const char **argv)\n \t\tfree(oid);\n \t}\n \tstring_list_clear(&refs_to_delete, 0);\n+\toid_array_clear(&old_oids);\n \treturn result;\n }\n \ndiff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\nindex 4fe9d9b234..8d400cd7ac 100755\n--- a/t/t1416-ref-transaction-hooks.sh\n+++ b/t/t1416-ref-transaction-hooks.sh\n@@ -14,6 +14,34 @@ test_expect_success setup '\n \tPOST_OID=$(git rev-parse POST)\n '\n \n+test_expect_success 'hook gets old values for batched branch/tag deletion' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\tgit branch to-delete PRE &&\n+\tgit tag delete-tag POST &&\n+\tgit pack-refs --all &&\n+\ttest_hook reference-transaction <<-\\EOF &&\n+\t\tif test \"$1\" = committed\n+\t\tthen\n+\t\t\t# Ignore backend-internal zero-to-zero records.\n+\t\t\twhile read -r old new ref\n+\t\t\tdo\n+\t\t\t\tcase \"$old\" in\n+\t\t\t\t*[!0]*)\n+\t\t\t\t\techo \"$old $new $ref\"\n+\t\t\t\t\t;;\n+\t\t\t\tesac\n+\t\t\tdone >>actual\n+\t\tfi\n+\tEOF\n+\tcat >expect <<-EOF &&\n+\t\t$PRE_OID $ZERO_OID refs/heads/to-delete\n+\t\t$POST_OID $ZERO_OID refs/tags/delete-tag\n+\tEOF\n+\tgit branch -D to-delete &&\n+\tgit tag -d delete-tag &&\n+\ttest_cmp expect actual\n+'\n+\n test_expect_success 'hook allows updating ref if successful' '\n \tgit reset --hard PRE &&\n \ttest_hook reference-transaction <<-\\EOF &&\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"552897","messageId":"20260919201158.43415-4-maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"20260919201158.43415-1-maciej.ciemborowicz@gmail.com","subject":"[PATCH 3/3] fetch, remote: retain old OIDs when pruning refs","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-19T20:11:58Z","receivedAt":"2026-09-19T20:12:52Z","isPatch":true,"body":"get_stale_heads() records the current value of each stale local ref in its\nnew_oid member. The pruning paths discard that value and request unconditional\ndeletion, so reference-transaction hooks receive a null old OID.\n\nCarry the recorded values into the deletion transactions. This reuses data\ncollected while finding stale refs and therefore requires no additional ref\nreads.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n builtin/fetch.c                  | 11 ++++++---\n builtin/remote.c                 | 30 +++++++++++++++++++----\n t/t1416-ref-transaction-hooks.sh | 42 ++++++++++++++++++++++++++++++++\n 3 files changed, 75 insertions(+), 8 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex d202147b21..a982d7541f 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -1452,14 +1452,18 @@ static int prune_refs(struct display_state *display_state,\n \tstruct ref *ref, *stale_refs = get_stale_heads(rs, ref_map);\n \tstruct strbuf err = STRBUF_INIT;\n \tstruct string_list refnames = STRING_LIST_INIT_NODUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \n-\tfor (ref = stale_refs; ref; ref = ref->next)\n+\tfor (ref = stale_refs; ref; ref = ref->next) {\n \t\tstring_list_append(&refnames, ref->name);\n+\t\toid_array_append(&old_oids, &ref->new_oid);\n+\t}\n \n \tif (!dry_run) {\n \t\tif (transaction) {\n \t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n-\t\t\t\tresult = ref_transaction_delete(transaction, ref->name, NULL,\n+\t\t\t\tresult = ref_transaction_delete(transaction, ref->name,\n+\t\t\t\t\t\t\t&ref->new_oid,\n \t\t\t\t\t\t\t\tNULL, 0, \"fetch: prune\", &err);\n \t\t\t\tif (result)\n \t\t\t\t\tgoto cleanup;\n@@ -1467,7 +1471,7 @@ static int prune_refs(struct display_state *display_state,\n \t\t} else {\n \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n-\t\t\t\t\t\t  NULL, 0);\n+\t\t\t\t\t\t  &old_oids, 0);\n \t\t}\n \t}\n \n@@ -1487,6 +1491,7 @@ static int prune_refs(struct display_state *display_state,\n \n cleanup:\n \tstring_list_clear(&refnames, 0);\n+\toid_array_clear(&old_oids);\n \tstrbuf_release(&err);\n \tfree_refs(stale_refs);\n \treturn result;\ndiff --git a/builtin/remote.c b/builtin/remote.c\nindex 13d3cc52dd..7b0ad13342 100644\n--- a/builtin/remote.c\n+++ b/builtin/remote.c\n@@ -17,6 +17,7 @@\n #include \"refs.h\"\n #include \"refspec.h\"\n #include \"odb.h\"\n+#include \"oid-array.h\"\n #include \"strvec.h\"\n #include \"commit-reach.h\"\n #include \"progress.h\"\n@@ -380,6 +381,11 @@ struct ref_states {\n \tint queried;\n };\n \n+struct stale_ref {\n+\tstruct object_id oid;\n+\tchar name[FLEX_ARRAY];\n+};\n+\n #define REF_STATES_INIT { \\\n \t.new_refs = STRING_LIST_INIT_DUP, \\\n \t.skipped = STRING_LIST_INIT_DUP, \\\n@@ -410,9 +416,13 @@ static int get_ref_states(const struct ref *remote_refs, struct ref_states *stat\n \t}\n \tstale_refs = get_stale_heads(&states->remote->fetch, fetch_map);\n \tfor (ref = stale_refs; ref; ref = ref->next) {\n+\t\tstruct stale_ref *stale_ref;\n \t\tstruct string_list_item *item =\n \t\t\tstring_list_append(&states->stale, abbrev_branch(ref->name));\n-\t\titem->util = xstrdup(ref->name);\n+\n+\t\tFLEX_ALLOC_STR(stale_ref, name, ref->name);\n+\t\toidcpy(&stale_ref->oid, &ref->new_oid);\n+\t\titem->util = stale_ref;\n \t}\n \tfree_refs(stale_refs);\n \tfree_refs(fetch_map);\n@@ -1627,6 +1637,7 @@ static int prune_remote(const char *remote, int dry_run)\n \tint result = 0;\n \tstruct ref_states states = REF_STATES_INIT;\n \tstruct string_list refs_to_prune = STRING_LIST_INIT_NODUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \tstruct string_list_item *item;\n \n \tget_remote_ref_states(remote, &states, GET_REF_STATES);\n@@ -1639,17 +1650,25 @@ static int prune_remote(const char *remote, int dry_run)\n \tprintf_ln(_(\"Pruning %s\"), remote);\n \tprintf_ln(_(\"URL: %s\"), states.remote->url.v[0]);\n \n-\tfor_each_string_list_item(item, &states.stale)\n-\t\tstring_list_append(&refs_to_prune, item->util);\n+\tfor_each_string_list_item(item, &states.stale) {\n+\t\tstruct stale_ref *stale_ref = item->util;\n+\t\tstruct string_list_item *to_prune;\n+\n+\t\tto_prune = string_list_append(&refs_to_prune, stale_ref->name);\n+\t\tto_prune->util = &stale_ref->oid;\n+\t}\n \tstring_list_sort(&refs_to_prune);\n+\tfor_each_string_list_item(item, &refs_to_prune)\n+\t\toid_array_append(&old_oids, item->util);\n \n \tif (!dry_run)\n \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n-\t\t\t\t\t   NULL, 0);\n+\t\t\t\t\t   &old_oids, 0);\n \n \tfor_each_string_list_item(item, &states.stale) {\n-\t\tconst char *refname = item->util;\n+\t\tstruct stale_ref *stale_ref = item->util;\n+\t\tconst char *refname = stale_ref->name;\n \n \t\tif (dry_run)\n \t\t\tprintf_ln(_(\" * [would prune] %s\"),\n@@ -1663,6 +1682,7 @@ static int prune_remote(const char *remote, int dry_run)\n \t\t\t\t   stdout, \" \", dry_run, &refs_to_prune);\n \n \tstring_list_clear(&refs_to_prune, 0);\n+\toid_array_clear(&old_oids);\n \tfree_remote_ref_states(&states);\n \treturn result;\n }\ndiff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\nindex 8d400cd7ac..39bdc1bc26 100755\n--- a/t/t1416-ref-transaction-hooks.sh\n+++ b/t/t1416-ref-transaction-hooks.sh\n@@ -42,6 +42,48 @@ test_expect_success 'hook gets old values for batched branch/tag deletion' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success 'hook gets old values when pruning remote refs' '\n+\ttest_create_repo empty.git --bare &&\n+\ttest_create_repo prune &&\n+\tgit -C prune remote add origin ../empty.git &&\n+\ttest_commit -C prune one &&\n+\tone=$(git -C prune rev-parse HEAD) &&\n+\ttest_commit -C prune two &&\n+\ttwo=$(git -C prune rev-parse HEAD) &&\n+\tgit -C prune update-ref refs/remotes/origin/remote-prune-z \"$one\" &&\n+\tgit -C prune update-ref refs/remotes/origin/remote-prune-a \"$two\" &&\n+\tgit -C prune pack-refs --all &&\n+\ttest_hook -C prune reference-transaction <<-\\EOF &&\n+\t\tif test \"$1\" = committed\n+\t\tthen\n+\t\t\t# Ignore backend-internal zero-to-zero records.\n+\t\t\twhile read -r old new ref\n+\t\t\tdo\n+\t\t\t\tcase \"$old\" in\n+\t\t\t\t*[!0]*)\n+\t\t\t\t\techo \"$old $new $ref\"\n+\t\t\t\t\t;;\n+\t\t\t\tesac\n+\t\t\tdone >>actual\n+\t\tfi\n+\tEOF\n+\t(\n+\t\tcd prune &&\n+\t\tgit remote prune origin &&\n+\t\tgit update-ref refs/remotes/origin/fetch-prune \"$one\" &&\n+\t\tgit fetch --prune origin &&\n+\t\tgit update-ref refs/remotes/origin/atomic-prune \"$one\" &&\n+\t\tgit fetch --atomic --prune origin &&\n+\t\tcat >expect <<-EOF &&\n+\t\t\t$two $ZERO_OID refs/remotes/origin/remote-prune-a\n+\t\t\t$one $ZERO_OID refs/remotes/origin/remote-prune-z\n+\t\t\t$one $ZERO_OID refs/remotes/origin/fetch-prune\n+\t\t\t$one $ZERO_OID refs/remotes/origin/atomic-prune\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n test_expect_success 'hook allows updating ref if successful' '\n \tgit reset --hard PRE &&\n \ttest_hook reference-transaction <<-\\EOF &&\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"552898","messageId":"CAOLa=ZTWGJZCmZnPLt5az_w-6YkGuQhQUKyJq6X=VFQL1T_6ZQ@mail.gmail.com","threadId":"66351","inReplyTo":"20260919201158.43415-2-maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-19T20:41:43Z","receivedAt":"2026-09-19T20:41:46Z","isPatch":true,"body":"Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n\n> refs_delete_refs() currently performs unconditional deletions. Thus callers\n> cannot preserve old values that they have already resolved, and\n> reference-transaction hooks consequently see a null old OID.\n>\n> Add an optional oid_array whose entries correspond to the refnames. Pass each\n> non-null OID to ref_transaction_delete(). Existing callers retain the\n> unconditional behavior for now.\n>\n> Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n> ---\n>  bisect.c                  |  2 +-\n>  builtin/branch.c          |  3 ++-\n>  builtin/fetch.c           |  2 +-\n>  builtin/remote.c          |  5 +++--\n>  builtin/tag.c             |  3 ++-\n>  refs.c                    | 26 +++++++++++++++-----------\n>  refs.h                    | 12 ++++++++++--\n>  t/helper/test-ref-store.c |  2 +-\n>  8 files changed, 35 insertions(+), 20 deletions(-)\n>\n\n[snip]\n\n\n> diff --git a/refs.c b/refs.c\n> index d3caa9a633..a9c5397fd7 100644\n> --- a/refs.c\n> +++ b/refs.c\n> @@ -18,6 +18,7 @@\n>  #include \"refs/refs-internal.h\"\n>  #include \"hook.h\"\n>  #include \"object-name.h\"\n> +#include \"oid-array.h\"\n>  #include \"odb.h\"\n>  #include \"object.h\"\n>  #include \"path.h\"\n> @@ -3056,36 +3057,39 @@ void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n>  }\n>\n>  int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n> -\t\t     struct string_list *refnames, unsigned int flags)\n> +\t\t     struct string_list *refnames,\n> +\t\t     const struct oid_array *old_oids,\n> +\t\t     unsigned int flags)\n>  {\n>  \tstruct ref_transaction *transaction;\n>  \tstruct strbuf err = STRBUF_INIT;\n> -\tstruct string_list_item *item;\n> +\tsize_t i;\n>  \tint ret = 0, failures = 0;\n>  \tchar *msg;\n>\n> +\tif (old_oids && old_oids->nr != refnames->nr)\n> +\t\tBUG(\"refname and old OID counts do not match\");\n>  \tif (!refnames->nr)\n>  \t\treturn 0;\n>\n>  \tmsg = normalize_reflog_message(logmsg);\n>\n> -\t/*\n> -\t * Since we don't check the references' old_oids, the\n> -\t * individual updates can't fail, so we can pack all of the\n> -\t * updates into a single transaction.\n> -\t */\n\nI understand that this is intended to fix a bug. With this change,\n`refs_delete_refs()`'s behavior has changed from unconditionally\ndeleting all refs to now only deleting the refs if all old OIDs match.\n\nDoesn't this introduce possibility of a race since callees of the\nfunction who checked the ref's OID before can now expect a failure when\nthe transaction re-checks the old_oid?\n\n\n>  \ttransaction = ref_store_transaction_begin(refs, 0, &err);\n>  \tif (!transaction) {\n>  \t\tret = error(\"%s\", err.buf);\n>  \t\tgoto out;\n>  \t}\n>\n> -\tfor_each_string_list_item(item, refnames) {\n> -\t\tret = ref_transaction_delete(transaction, item->string,\n> -\t\t\t\t\t     NULL, NULL, flags, msg, &err);\n> +\tfor (i = 0; i < refnames->nr; i++) {\n> +\t\tconst struct object_id *old_oid = old_oids ? &old_oids->oid[i] : NULL;\n> +\n\nNit: we could add a `struct string_list_item *item =\nrefnames->items[i];` for a nicer diff.\n\n> +\t\tif (old_oid && is_null_oid(old_oid))\n> +\t\t\told_oid = NULL;\n> +\t\tret = ref_transaction_delete(transaction, refnames->items[i].string,\n> +\t\t\t\t\t     old_oid, NULL, flags, msg, &err);\n>  \t\tif (ret) {\n>  \t\t\twarning(_(\"could not delete reference %s: %s\"),\n> -\t\t\t\titem->string, err.buf);\n> +\t\t\t\trefnames->items[i].string, err.buf);\n>  \t\t\tstrbuf_reset(&err);\n>  \t\t\tfailures = 1;\n>  \t\t}\n> diff --git a/refs.h b/refs.h\n> index 71d5c186d0..b76b556cf7 100644\n> --- a/refs.h\n> +++ b/refs.h\n> @@ -9,6 +9,7 @@\n>  struct fsck_options;\n>  struct object_id;\n>  struct ref_store;\n> +struct oid_array;\n>  struct strbuf;\n>  struct string_list;\n>  struct string_list_item;\n> @@ -613,13 +614,20 @@ int refs_delete_ref(struct ref_store *refs, const char *msg,\n>  \t\t    unsigned int flags);\n>\n>  /*\n> - * Delete the specified references. If there are any problems, emit\n> + * Delete the specified references. If old_oids is non-NULL, it must contain\n> + * an entry for each refname, in the same order. Each non-null entry is used\n> + * to verify the current value of the corresponding reference before deleting\n> + * it. A null entry disables verification for that reference.\n> + *\n> + * If there are any problems, emit\n>   * errors but attempt to keep going (i.e., the deletes are not done in\n>   * an all-or-nothing transaction). msg and flags are passed through to\n>   * ref_transaction_delete().\n>   */\n>  int refs_delete_refs(struct ref_store *refs, const char *msg,\n> -\t\t     struct string_list *refnames, unsigned int flags);\n> +\t\t     struct string_list *refnames,\n> +\t\t     const struct oid_array *old_oids,\n> +\t\t     unsigned int flags);\n>\n>  /** Delete a reflog */\n>  int refs_delete_reflog(struct ref_store *refs, const char *refname);\n> diff --git a/t/helper/test-ref-store.c b/t/helper/test-ref-store.c\n> index 3866d0aca4..c2c7dfb065 100644\n> --- a/t/helper/test-ref-store.c\n> +++ b/t/helper/test-ref-store.c\n> @@ -140,7 +140,7 @@ static int cmd_delete_refs(struct ref_store *refs, const char **argv)\n>  \twhile (*argv)\n>  \t\tstring_list_append(&refnames, *argv++);\n>\n> -\tresult = refs_delete_refs(refs, msg, &refnames, flags);\n> +\tresult = refs_delete_refs(refs, msg, &refnames, NULL, flags);\n>  \tstring_list_clear(&refnames, 0);\n>  \treturn result;\n>  }\n> --\n> 2.39.3 (Apple Git-146)\n"},{"id":"552906","messageId":"20260920103855.19874-1-maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"CAOLa=ZTWGJZCmZnPLt5az_w-6YkGuQhQUKyJq6X=VFQL1T_6ZQ@mail.gmail.com","subject":"Re: [PATCH 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-20T10:38:55Z","receivedAt":"2026-09-20T10:38:59Z","isPatch":true,"body":"Thanks. Yes, supplying old_oid changes these deletions from\nunconditional to compare-and-delete, so a concurrent ref change can make\nthe transaction fail. I should have called that out explicitly.\n\nI think that failure is desirable here: otherwise the command can delete\na value that it never examined. For branch and tag deletion this also\nrestores the behavior from before 8198907795 (use delete_refs when\ndeleting tags or branches, 2021-01-21), where delete_ref() was passed the\nOID that had been resolved by the caller. That commit batched the deletes\nthrough delete_refs(), but the expected OIDs were lost in the conversion.\n\nI reproduced the race with a reference-transaction hook that updates the\nbranch during the \"preparing\" phase, after delete_branches() has collected\nits OID. Current Git returns success and deletes the concurrently updated\nbranch. With this series, the outer transaction fails its old-OID check and\nleaves the new value intact. The same check prevents pruning based on a\nstale scan from deleting a ref that another process updated meanwhile.\n\nI will make this behavior change explicit in the commit messages and add a\nregression test for the concurrent update. Your comment also exposed that\nremote prune can print \"[pruned]\" after such a deletion failure; I will fix\nthat reporting in v2 as well.\n\nAnd agreed on using a local item variable for the loop; I will include that\nin v2.\n\nThanks,\nMaciej\n"},{"id":"552907","messageId":"cover.1789901584.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"CAOLa=ZTWGJZCmZnPLt5az_w-6YkGuQhQUKyJq6X=VFQL1T_6ZQ@mail.gmail.com","subject":"[PATCH v2 0/3] refs: report old OIDs for batched deletions","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-20T10:54:19Z","receivedAt":"2026-09-20T10:54:26Z","isPatch":true,"body":"The reference-transaction hook receives zero as both the old and new OID\nwhen branch, tag, fetch, and remote delete refs through refs_delete_refs().\nThose callers already know the values that they selected for deletion.\n\nTeach refs_delete_refs() to accept aligned old OIDs and pass them into the\ntransaction. Besides making the hook records useful, this makes the selected\ncallers reject concurrent changes instead of deleting values that they did\nnot inspect. For branch and tag, this restores the compare-and-delete\nbehavior that existed before 8198907795 converted them to batched deletion.\nFor pruning, it prevents a stale scan from deleting a ref updated by another\nprocess.\n\nThe values are already available at every updated call site, so the series\nadds no ref reads and retains batched performance.\n\nChanges since v1:\n\n * Document the conditional deletion behavior and its race protection.\n * Add tests that update refs from the hook's preparing phase and verify that\n   branch deletion and remote pruning preserve the concurrent update.\n * Avoid printing deletion status when a non-atomic prune fails.\n * Use a local string_list_item in refs_delete_refs(), as suggested by\n   Karthik.\n\nBased on maint at e9019fcafe (Git 2.55).\n\nTests:\n\n * t1416-ref-transaction-hooks.sh (files and reftable)\n * t3200-branch.sh\n * t7004-tag.sh\n * t5510-fetch.sh\n * t5505-remote.sh\n\nMaciej Ciemborowicz (3):\n  refs: allow callers to supply old OIDs for batch deletion\n  branch, tag: retain old OIDs in batched deletions\n  fetch, remote: retain old OIDs when pruning refs\n\n bisect.c                         |   2 +-\n builtin/branch.c                 |   7 +-\n builtin/fetch.c                  |  13 +++-\n builtin/remote.c                 |  39 +++++++++--\n builtin/tag.c                    |   7 +-\n refs.c                           |  23 ++++---\n refs.h                           |  12 +++-\n t/helper/test-ref-store.c        |   2 +-\n t/t1416-ref-transaction-hooks.sh | 110 +++++++++++++++++++++++++++++++\n 9 files changed, 190 insertions(+), 25 deletions(-)\n\nRange-diff against v1:\n1:  e1c72cfba ! 1:  5c96a5a1e refs: allow callers to supply old OIDs for batch deletion\n    @@ Commit message\n         reference-transaction hooks consequently see a null old OID.\n     \n         Add an optional oid_array whose entries correspond to the refnames. Pass each\n    -    non-null OID to ref_transaction_delete(). Existing callers retain the\n    -    unconditional behavior for now.\n    +    non-null OID to ref_transaction_delete(). Supplying an OID makes the deletion\n    +    conditional: if the ref changed after the caller resolved it, the transaction\n    +    fails instead of deleting the new value. Existing callers that pass NULL\n    +    retain the unconditional behavior.\n     \n         Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n     \n    @@ refs.c: void ref_transaction_for_each_rejected_update(struct ref_transaction *tr\n      \t}\n      \n     -\tfor_each_string_list_item(item, refnames) {\n    --\t\tret = ref_transaction_delete(transaction, item->string,\n    --\t\t\t\t\t     NULL, NULL, flags, msg, &err);\n     +\tfor (i = 0; i < refnames->nr; i++) {\n    ++\t\tstruct string_list_item *item = &refnames->items[i];\n     +\t\tconst struct object_id *old_oid = old_oids ? &old_oids->oid[i] : NULL;\n     +\n     +\t\tif (old_oid && is_null_oid(old_oid))\n     +\t\t\told_oid = NULL;\n    -+\t\tret = ref_transaction_delete(transaction, refnames->items[i].string,\n    + \t\tret = ref_transaction_delete(transaction, item->string,\n    +-\t\t\t\t\t     NULL, NULL, flags, msg, &err);\n     +\t\t\t\t\t     old_oid, NULL, flags, msg, &err);\n      \t\tif (ret) {\n      \t\t\twarning(_(\"could not delete reference %s: %s\"),\n    --\t\t\t\titem->string, err.buf);\n    -+\t\t\t\trefnames->items[i].string, err.buf);\n    - \t\t\tstrbuf_reset(&err);\n    - \t\t\tfailures = 1;\n    - \t\t}\n    + \t\t\t\titem->string, err.buf);\n     \n      ## refs.h ##\n     @@\n2:  09e0b8557 ! 2:  d00fdeba2 branch, tag: retain old OIDs in batched deletions\n    @@ Metadata\n      ## Commit message ##\n         branch, tag: retain old OIDs in batched deletions\n     \n    -    Since 8198907795 (use delete_refs when deleting tags or branches,\n    -    2021-01-21), branch and tag deletion pass no old OIDs to the ref transaction.\n    -    As a result, reference-transaction hooks report zero as both the old and new\n    -    OID.\n    +    Before 8198907795 (use delete_refs when deleting tags or branches,\n    +    2021-01-21), branch and tag deletion passed each resolved old OID to\n    +    delete_ref(). This prevented the command from deleting a ref that another\n    +    process had changed after it was inspected.\n     \n    -    Both commands already resolve the old OIDs before starting the deletion. Pass\n    -    those values to refs_delete_refs() so hooks receive useful old values without\n    -    adding any ref reads.\n    +    The conversion to batched deletion dropped those old OIDs. Besides making the\n    +    deletions unconditional, this causes reference-transaction hooks to report\n    +    zero as both the old and new OID.\n    +\n    +    Both commands still resolve the old OIDs before starting the deletion. Pass\n    +    those values to refs_delete_refs(). This restores the old race protection and\n    +    lets hooks receive useful old values without adding any ref reads. If a ref\n    +    changes concurrently, the transaction fails and preserves the new value.\n     \n         Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n     \n    @@ t/t1416-ref-transaction-hooks.sh: test_expect_success setup '\n     +\tgit tag -d delete-tag &&\n     +\ttest_cmp expect actual\n     +'\n    ++\n    ++test_expect_success 'branch deletion rejects a concurrent update' '\n    ++\tgit branch delete-race PRE &&\n    ++\ttest_hook reference-transaction <<-\\EOF &&\n    ++\t\tmarker=$(git rev-parse --git-path delete-race-once)\n    ++\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n    ++\t\tthen\n    ++\t\t\t>\"$marker\"\n    ++\t\t\tgit update-ref refs/heads/delete-race POST\n    ++\t\tfi\n    ++\t\texit 0\n    ++\tEOF\n    ++\ttest_must_fail git branch -D delete-race 2>err &&\n    ++\ttest_grep \"is at $POST_OID but expected $PRE_OID\" err &&\n    ++\ttest_cmp_rev POST refs/heads/delete-race\n    ++'\n     +\n      test_expect_success 'hook allows updating ref if successful' '\n      \tgit reset --hard PRE &&\n3:  95c8abce3 ! 3:  461c36ccd fetch, remote: retain old OIDs when pruning refs\n    @@ Commit message\n         new_oid member. The pruning paths discard that value and request unconditional\n         deletion, so reference-transaction hooks receive a null old OID.\n     \n    -    Carry the recorded values into the deletion transactions. This reuses data\n    -    collected while finding stale refs and therefore requires no additional ref\n    -    reads.\n    +    Carry the recorded values into the deletion transactions. Besides giving the\n    +    hooks useful values, this stops a stale scan from deleting a ref that another\n    +    process updated before the transaction acquired its locks. A concurrent\n    +    change now makes the prune fail and preserves the new value.\n    +\n    +    This reuses data collected while finding stale refs and therefore requires no\n    +    additional ref reads. Do not print deletion status when a non-atomic prune\n    +    fails its old-OID check.\n     \n         Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n     \n    @@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n     -\t\t\t\t\t\t  NULL, 0);\n     +\t\t\t\t\t\t  &old_oids, 0);\n      \t\t}\n    ++\t\tif (result)\n    ++\t\t\tgoto cleanup;\n      \t}\n      \n    + \tif (verbosity >= 0) {\n     @@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n      \n      cleanup:\n    @@ builtin/remote.c: static int prune_remote(const char *remote, int dry_run)\n     +\tfor_each_string_list_item(item, &refs_to_prune)\n     +\t\toid_array_append(&old_oids, item->util);\n      \n    - \tif (!dry_run)\n    +-\tif (!dry_run)\n    ++\tif (!dry_run) {\n      \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n      \t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n     -\t\t\t\t\t   NULL, 0);\n     +\t\t\t\t\t   &old_oids, 0);\n    ++\t\tif (result)\n    ++\t\t\tgoto cleanup;\n    ++\t}\n      \n      \tfor_each_string_list_item(item, &states.stale) {\n     -\t\tconst char *refname = item->util;\n    @@ builtin/remote.c: static int prune_remote(const char *remote, int dry_run)\n      \t\tif (dry_run)\n      \t\t\tprintf_ln(_(\" * [would prune] %s\"),\n     @@ builtin/remote.c: static int prune_remote(const char *remote, int dry_run)\n    + \trefs_warn_dangling_symrefs(get_main_ref_store(the_repository),\n      \t\t\t\t   stdout, \" \", dry_run, &refs_to_prune);\n      \n    ++cleanup:\n      \tstring_list_clear(&refs_to_prune, 0);\n     +\toid_array_clear(&old_oids);\n      \tfree_remote_ref_states(&states);\n    @@ builtin/remote.c: static int prune_remote(const char *remote, int dry_run)\n      }\n     \n      ## t/t1416-ref-transaction-hooks.sh ##\n    -@@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'hook gets old values for batched branch/tag deletion' '\n    - \ttest_cmp expect actual\n    +@@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'branch deletion rejects a concurrent update' '\n    + \ttest_cmp_rev POST refs/heads/delete-race\n      '\n      \n     +test_expect_success 'hook gets old values when pruning remote refs' '\n    @@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'hook gets old values for\n     +\t\ttest_cmp expect actual\n     +\t)\n     +'\n    ++\n    ++test_expect_success 'remote prune rejects a concurrent update' '\n    ++\ttest_when_finished \"rm -rf race-empty.git race-prune\" &&\n    ++\ttest_create_repo race-empty.git --bare &&\n    ++\ttest_create_repo race-prune &&\n    ++\ttest_commit -C race-prune one &&\n    ++\tone=$(git -C race-prune rev-parse HEAD) &&\n    ++\ttest_commit -C race-prune two &&\n    ++\ttwo=$(git -C race-prune rev-parse HEAD) &&\n    ++\tgit -C race-prune remote add origin ../race-empty.git &&\n    ++\tgit -C race-prune update-ref refs/remotes/origin/race \"$one\" &&\n    ++\ttest_hook -C race-prune reference-transaction <<-\\EOF &&\n    ++\t\tmarker=$(git rev-parse --git-path prune-race-once)\n    ++\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n    ++\t\tthen\n    ++\t\t\t>\"$marker\"\n    ++\t\t\tgit update-ref refs/remotes/origin/race HEAD\n    ++\t\tfi\n    ++\t\texit 0\n    ++\tEOF\n    ++\ttest_must_fail git -C race-prune remote prune origin >out 2>err &&\n    ++\ttest \"$two\" = \"$(git -C race-prune rev-parse refs/remotes/origin/race)\" &&\n    ++\t! grep \"\\[pruned\\]\" out\n    ++'\n     +\n      test_expect_success 'hook allows updating ref if successful' '\n      \tgit reset --hard PRE &&\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"552908","messageId":"5c96a5a1ebafd49a301c5c1dc77a2e19d6677ab0.1789901584.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1789901584.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v2 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-20T10:54:20Z","receivedAt":"2026-09-20T10:54:27Z","isPatch":true,"body":"refs_delete_refs() currently performs unconditional deletions. Thus callers\ncannot preserve old values that they have already resolved, and\nreference-transaction hooks consequently see a null old OID.\n\nAdd an optional oid_array whose entries correspond to the refnames. Pass each\nnon-null OID to ref_transaction_delete(). Supplying an OID makes the deletion\nconditional: if the ref changed after the caller resolved it, the transaction\nfails instead of deleting the new value. Existing callers that pass NULL\nretain the unconditional behavior.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n bisect.c                  |  2 +-\n builtin/branch.c          |  3 ++-\n builtin/fetch.c           |  2 +-\n builtin/remote.c          |  5 +++--\n builtin/tag.c             |  3 ++-\n refs.c                    | 23 ++++++++++++++---------\n refs.h                    | 12 ++++++++++--\n t/helper/test-ref-store.c |  2 +-\n 8 files changed, 34 insertions(+), 18 deletions(-)\n\ndiff --git a/bisect.c b/bisect.c\nindex 94c7028d2..9aa3bace9 100644\n--- a/bisect.c\n+++ b/bisect.c\n@@ -1203,7 +1203,7 @@ int bisect_clean_state(void)\n \tstring_list_append(&refs_for_removal, \"BISECT_EXPECTED_REV\");\n \tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t  \"bisect: remove\", &refs_for_removal,\n-\t\t\t\t  REF_NO_DEREF);\n+\t\t\t\t  NULL, REF_NO_DEREF);\n \tstring_list_clear(&refs_for_removal, 0);\n \tunlink_or_warn(git_path_bisect_ancestors_ok());\n \tunlink_or_warn(git_path_bisect_log());\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9e..f1abeb681 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -322,7 +322,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n+\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex c1d7c672f..d202147b2 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -1467,7 +1467,7 @@ static int prune_refs(struct display_state *display_state,\n \t\t} else {\n \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n-\t\t\t\t\t\t  0);\n+\t\t\t\t\t\t  NULL, 0);\n \t\t}\n \t}\n \ndiff --git a/builtin/remote.c b/builtin/remote.c\nindex de989ea3b..13d3cc52d 100644\n--- a/builtin/remote.c\n+++ b/builtin/remote.c\n@@ -1073,7 +1073,7 @@ static int rm(int argc, const char **argv, const char *prefix,\n \tif (!result)\n \t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t  \"remote: remove\", &branches,\n-\t\t\t\t\t  REF_NO_DEREF);\n+\t\t\t\t\t  NULL, REF_NO_DEREF);\n \tstring_list_clear(&branches, 0);\n \n \tif (skipped.nr) {\n@@ -1645,7 +1645,8 @@ static int prune_remote(const char *remote, int dry_run)\n \n \tif (!dry_run)\n \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n-\t\t\t\t\t   \"remote: prune\", &refs_to_prune, 0);\n+\t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n+\t\t\t\t\t   NULL, 0);\n \n \tfor_each_string_list_item(item, &states.stale) {\n \t\tconst char *refname = item->util;\ndiff --git a/builtin/tag.c b/builtin/tag.c\nindex 06c125b53..40874a292 100644\n--- a/builtin/tag.c\n+++ b/builtin/tag.c\n@@ -122,7 +122,8 @@ static int delete_tags(const char **argv)\n \tstruct string_list_item *item;\n \n \tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n+\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n \t\tresult = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\ndiff --git a/refs.c b/refs.c\nindex d3caa9a63..9c593baea 100644\n--- a/refs.c\n+++ b/refs.c\n@@ -18,6 +18,7 @@\n #include \"refs/refs-internal.h\"\n #include \"hook.h\"\n #include \"object-name.h\"\n+#include \"oid-array.h\"\n #include \"odb.h\"\n #include \"object.h\"\n #include \"path.h\"\n@@ -3056,33 +3057,37 @@ void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n }\n \n int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n-\t\t     struct string_list *refnames, unsigned int flags)\n+\t\t     struct string_list *refnames,\n+\t\t     const struct oid_array *old_oids,\n+\t\t     unsigned int flags)\n {\n \tstruct ref_transaction *transaction;\n \tstruct strbuf err = STRBUF_INIT;\n-\tstruct string_list_item *item;\n+\tsize_t i;\n \tint ret = 0, failures = 0;\n \tchar *msg;\n \n+\tif (old_oids && old_oids->nr != refnames->nr)\n+\t\tBUG(\"refname and old OID counts do not match\");\n \tif (!refnames->nr)\n \t\treturn 0;\n \n \tmsg = normalize_reflog_message(logmsg);\n \n-\t/*\n-\t * Since we don't check the references' old_oids, the\n-\t * individual updates can't fail, so we can pack all of the\n-\t * updates into a single transaction.\n-\t */\n \ttransaction = ref_store_transaction_begin(refs, 0, &err);\n \tif (!transaction) {\n \t\tret = error(\"%s\", err.buf);\n \t\tgoto out;\n \t}\n \n-\tfor_each_string_list_item(item, refnames) {\n+\tfor (i = 0; i < refnames->nr; i++) {\n+\t\tstruct string_list_item *item = &refnames->items[i];\n+\t\tconst struct object_id *old_oid = old_oids ? &old_oids->oid[i] : NULL;\n+\n+\t\tif (old_oid && is_null_oid(old_oid))\n+\t\t\told_oid = NULL;\n \t\tret = ref_transaction_delete(transaction, item->string,\n-\t\t\t\t\t     NULL, NULL, flags, msg, &err);\n+\t\t\t\t\t     old_oid, NULL, flags, msg, &err);\n \t\tif (ret) {\n \t\t\twarning(_(\"could not delete reference %s: %s\"),\n \t\t\t\titem->string, err.buf);\ndiff --git a/refs.h b/refs.h\nindex 71d5c186d..b76b556cf 100644\n--- a/refs.h\n+++ b/refs.h\n@@ -9,6 +9,7 @@\n struct fsck_options;\n struct object_id;\n struct ref_store;\n+struct oid_array;\n struct strbuf;\n struct string_list;\n struct string_list_item;\n@@ -613,13 +614,20 @@ int refs_delete_ref(struct ref_store *refs, const char *msg,\n \t\t    unsigned int flags);\n \n /*\n- * Delete the specified references. If there are any problems, emit\n+ * Delete the specified references. If old_oids is non-NULL, it must contain\n+ * an entry for each refname, in the same order. Each non-null entry is used\n+ * to verify the current value of the corresponding reference before deleting\n+ * it. A null entry disables verification for that reference.\n+ *\n+ * If there are any problems, emit\n  * errors but attempt to keep going (i.e., the deletes are not done in\n  * an all-or-nothing transaction). msg and flags are passed through to\n  * ref_transaction_delete().\n  */\n int refs_delete_refs(struct ref_store *refs, const char *msg,\n-\t\t     struct string_list *refnames, unsigned int flags);\n+\t\t     struct string_list *refnames,\n+\t\t     const struct oid_array *old_oids,\n+\t\t     unsigned int flags);\n \n /** Delete a reflog */\n int refs_delete_reflog(struct ref_store *refs, const char *refname);\ndiff --git a/t/helper/test-ref-store.c b/t/helper/test-ref-store.c\nindex 3866d0aca..c2c7dfb06 100644\n--- a/t/helper/test-ref-store.c\n+++ b/t/helper/test-ref-store.c\n@@ -140,7 +140,7 @@ static int cmd_delete_refs(struct ref_store *refs, const char **argv)\n \twhile (*argv)\n \t\tstring_list_append(&refnames, *argv++);\n \n-\tresult = refs_delete_refs(refs, msg, &refnames, flags);\n+\tresult = refs_delete_refs(refs, msg, &refnames, NULL, flags);\n \tstring_list_clear(&refnames, 0);\n \treturn result;\n }\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"552909","messageId":"d00fdeba2f673cf5a174f919452694c733736e84.1789901584.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1789901584.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v2 2/3] branch, tag: retain old OIDs in batched deletions","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-20T10:54:21Z","receivedAt":"2026-09-20T10:54:28Z","isPatch":true,"body":"Before 8198907795 (use delete_refs when deleting tags or branches,\n2021-01-21), branch and tag deletion passed each resolved old OID to\ndelete_ref(). This prevented the command from deleting a ref that another\nprocess had changed after it was inspected.\n\nThe conversion to batched deletion dropped those old OIDs. Besides making the\ndeletions unconditional, this causes reference-transaction hooks to report\nzero as both the old and new OID.\n\nBoth commands still resolve the old OIDs before starting the deletion. Pass\nthose values to refs_delete_refs(). This restores the old race protection and\nlets hooks receive useful old values without adding any ref reads. If a ref\nchanges concurrently, the transaction fails and preserves the new value.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n builtin/branch.c                 |  6 ++++-\n builtin/tag.c                    |  6 ++++-\n t/t1416-ref-transaction-hooks.sh | 44 ++++++++++++++++++++++++++++++++\n 3 files changed, 54 insertions(+), 2 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex f1abeb681..9f03ebc09 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -16,6 +16,7 @@\n #include \"commit.h\"\n #include \"gettext.h\"\n #include \"object-name.h\"\n+#include \"oid-array.h\"\n #include \"remote.h\"\n #include \"parse-options.h\"\n #include \"branch.h\"\n@@ -230,6 +231,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \tstruct string_list_item *item;\n \tint branch_name_pos;\n \tconst char *fmt_remotes = \"refs/remotes/%s\";\n@@ -314,6 +316,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n+\t\toid_array_append(&old_oids, &oid);\n \t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n \t\t\t\t    : (flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n@@ -323,7 +326,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \n \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n-\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n+\t\t\t     &refs_to_delete, &old_oids, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n@@ -342,6 +345,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tfree(describe_ref);\n \t}\n \tstring_list_clear(&refs_to_delete, 0);\n+\toid_array_clear(&old_oids);\n \n \tfree(name);\n \tstrbuf_release(&bname);\ndiff --git a/builtin/tag.c b/builtin/tag.c\nindex 40874a292..0a3eb70fa 100644\n--- a/builtin/tag.c\n+++ b/builtin/tag.c\n@@ -119,11 +119,14 @@ static int delete_tags(const char **argv)\n {\n \tint result;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \tstruct string_list_item *item;\n \n \tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n+\tfor_each_string_list_item(item, &refs_to_delete)\n+\t\toid_array_append(&old_oids, item->util);\n \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n-\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n+\t\t\t     &refs_to_delete, &old_oids, REF_NO_DEREF))\n \t\tresult = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n@@ -137,6 +140,7 @@ static int delete_tags(const char **argv)\n \t\tfree(oid);\n \t}\n \tstring_list_clear(&refs_to_delete, 0);\n+\toid_array_clear(&old_oids);\n \treturn result;\n }\n \ndiff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\nindex 4fe9d9b23..01b5ba8c4 100755\n--- a/t/t1416-ref-transaction-hooks.sh\n+++ b/t/t1416-ref-transaction-hooks.sh\n@@ -14,6 +14,50 @@ test_expect_success setup '\n \tPOST_OID=$(git rev-parse POST)\n '\n \n+test_expect_success 'hook gets old values for batched branch/tag deletion' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\tgit branch to-delete PRE &&\n+\tgit tag delete-tag POST &&\n+\tgit pack-refs --all &&\n+\ttest_hook reference-transaction <<-\\EOF &&\n+\t\tif test \"$1\" = committed\n+\t\tthen\n+\t\t\t# Ignore backend-internal zero-to-zero records.\n+\t\t\twhile read -r old new ref\n+\t\t\tdo\n+\t\t\t\tcase \"$old\" in\n+\t\t\t\t*[!0]*)\n+\t\t\t\t\techo \"$old $new $ref\"\n+\t\t\t\t\t;;\n+\t\t\t\tesac\n+\t\t\tdone >>actual\n+\t\tfi\n+\tEOF\n+\tcat >expect <<-EOF &&\n+\t\t$PRE_OID $ZERO_OID refs/heads/to-delete\n+\t\t$POST_OID $ZERO_OID refs/tags/delete-tag\n+\tEOF\n+\tgit branch -D to-delete &&\n+\tgit tag -d delete-tag &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'branch deletion rejects a concurrent update' '\n+\tgit branch delete-race PRE &&\n+\ttest_hook reference-transaction <<-\\EOF &&\n+\t\tmarker=$(git rev-parse --git-path delete-race-once)\n+\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n+\t\tthen\n+\t\t\t>\"$marker\"\n+\t\t\tgit update-ref refs/heads/delete-race POST\n+\t\tfi\n+\t\texit 0\n+\tEOF\n+\ttest_must_fail git branch -D delete-race 2>err &&\n+\ttest_grep \"is at $POST_OID but expected $PRE_OID\" err &&\n+\ttest_cmp_rev POST refs/heads/delete-race\n+'\n+\n test_expect_success 'hook allows updating ref if successful' '\n \tgit reset --hard PRE &&\n \ttest_hook reference-transaction <<-\\EOF &&\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"552910","messageId":"461c36ccdae09fb827a3c0efc7eed5aef072e09b.1789901584.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1789901584.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v2 3/3] fetch, remote: retain old OIDs when pruning refs","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-20T10:54:22Z","receivedAt":"2026-09-20T10:54:29Z","isPatch":true,"body":"get_stale_heads() records the current value of each stale local ref in its\nnew_oid member. The pruning paths discard that value and request unconditional\ndeletion, so reference-transaction hooks receive a null old OID.\n\nCarry the recorded values into the deletion transactions. Besides giving the\nhooks useful values, this stops a stale scan from deleting a ref that another\nprocess updated before the transaction acquired its locks. A concurrent\nchange now makes the prune fail and preserves the new value.\n\nThis reuses data collected while finding stale refs and therefore requires no\nadditional ref reads. Do not print deletion status when a non-atomic prune\nfails its old-OID check.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n builtin/fetch.c                  | 13 +++++--\n builtin/remote.c                 | 36 ++++++++++++++---\n t/t1416-ref-transaction-hooks.sh | 66 ++++++++++++++++++++++++++++++++\n 3 files changed, 106 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex d202147b2..da413ace0 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -1452,14 +1452,18 @@ static int prune_refs(struct display_state *display_state,\n \tstruct ref *ref, *stale_refs = get_stale_heads(rs, ref_map);\n \tstruct strbuf err = STRBUF_INIT;\n \tstruct string_list refnames = STRING_LIST_INIT_NODUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \n-\tfor (ref = stale_refs; ref; ref = ref->next)\n+\tfor (ref = stale_refs; ref; ref = ref->next) {\n \t\tstring_list_append(&refnames, ref->name);\n+\t\toid_array_append(&old_oids, &ref->new_oid);\n+\t}\n \n \tif (!dry_run) {\n \t\tif (transaction) {\n \t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n-\t\t\t\tresult = ref_transaction_delete(transaction, ref->name, NULL,\n+\t\t\t\tresult = ref_transaction_delete(transaction, ref->name,\n+\t\t\t\t\t\t\t&ref->new_oid,\n \t\t\t\t\t\t\t\tNULL, 0, \"fetch: prune\", &err);\n \t\t\t\tif (result)\n \t\t\t\t\tgoto cleanup;\n@@ -1467,8 +1471,10 @@ static int prune_refs(struct display_state *display_state,\n \t\t} else {\n \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n-\t\t\t\t\t\t  NULL, 0);\n+\t\t\t\t\t\t  &old_oids, 0);\n \t\t}\n+\t\tif (result)\n+\t\t\tgoto cleanup;\n \t}\n \n \tif (verbosity >= 0) {\n@@ -1487,6 +1493,7 @@ static int prune_refs(struct display_state *display_state,\n \n cleanup:\n \tstring_list_clear(&refnames, 0);\n+\toid_array_clear(&old_oids);\n \tstrbuf_release(&err);\n \tfree_refs(stale_refs);\n \treturn result;\ndiff --git a/builtin/remote.c b/builtin/remote.c\nindex 13d3cc52d..b899bec55 100644\n--- a/builtin/remote.c\n+++ b/builtin/remote.c\n@@ -17,6 +17,7 @@\n #include \"refs.h\"\n #include \"refspec.h\"\n #include \"odb.h\"\n+#include \"oid-array.h\"\n #include \"strvec.h\"\n #include \"commit-reach.h\"\n #include \"progress.h\"\n@@ -380,6 +381,11 @@ struct ref_states {\n \tint queried;\n };\n \n+struct stale_ref {\n+\tstruct object_id oid;\n+\tchar name[FLEX_ARRAY];\n+};\n+\n #define REF_STATES_INIT { \\\n \t.new_refs = STRING_LIST_INIT_DUP, \\\n \t.skipped = STRING_LIST_INIT_DUP, \\\n@@ -410,9 +416,13 @@ static int get_ref_states(const struct ref *remote_refs, struct ref_states *stat\n \t}\n \tstale_refs = get_stale_heads(&states->remote->fetch, fetch_map);\n \tfor (ref = stale_refs; ref; ref = ref->next) {\n+\t\tstruct stale_ref *stale_ref;\n \t\tstruct string_list_item *item =\n \t\t\tstring_list_append(&states->stale, abbrev_branch(ref->name));\n-\t\titem->util = xstrdup(ref->name);\n+\n+\t\tFLEX_ALLOC_STR(stale_ref, name, ref->name);\n+\t\toidcpy(&stale_ref->oid, &ref->new_oid);\n+\t\titem->util = stale_ref;\n \t}\n \tfree_refs(stale_refs);\n \tfree_refs(fetch_map);\n@@ -1627,6 +1637,7 @@ static int prune_remote(const char *remote, int dry_run)\n \tint result = 0;\n \tstruct ref_states states = REF_STATES_INIT;\n \tstruct string_list refs_to_prune = STRING_LIST_INIT_NODUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \tstruct string_list_item *item;\n \n \tget_remote_ref_states(remote, &states, GET_REF_STATES);\n@@ -1639,17 +1650,28 @@ static int prune_remote(const char *remote, int dry_run)\n \tprintf_ln(_(\"Pruning %s\"), remote);\n \tprintf_ln(_(\"URL: %s\"), states.remote->url.v[0]);\n \n-\tfor_each_string_list_item(item, &states.stale)\n-\t\tstring_list_append(&refs_to_prune, item->util);\n+\tfor_each_string_list_item(item, &states.stale) {\n+\t\tstruct stale_ref *stale_ref = item->util;\n+\t\tstruct string_list_item *to_prune;\n+\n+\t\tto_prune = string_list_append(&refs_to_prune, stale_ref->name);\n+\t\tto_prune->util = &stale_ref->oid;\n+\t}\n \tstring_list_sort(&refs_to_prune);\n+\tfor_each_string_list_item(item, &refs_to_prune)\n+\t\toid_array_append(&old_oids, item->util);\n \n-\tif (!dry_run)\n+\tif (!dry_run) {\n \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n-\t\t\t\t\t   NULL, 0);\n+\t\t\t\t\t   &old_oids, 0);\n+\t\tif (result)\n+\t\t\tgoto cleanup;\n+\t}\n \n \tfor_each_string_list_item(item, &states.stale) {\n-\t\tconst char *refname = item->util;\n+\t\tstruct stale_ref *stale_ref = item->util;\n+\t\tconst char *refname = stale_ref->name;\n \n \t\tif (dry_run)\n \t\t\tprintf_ln(_(\" * [would prune] %s\"),\n@@ -1662,7 +1684,9 @@ static int prune_remote(const char *remote, int dry_run)\n \trefs_warn_dangling_symrefs(get_main_ref_store(the_repository),\n \t\t\t\t   stdout, \" \", dry_run, &refs_to_prune);\n \n+cleanup:\n \tstring_list_clear(&refs_to_prune, 0);\n+\toid_array_clear(&old_oids);\n \tfree_remote_ref_states(&states);\n \treturn result;\n }\ndiff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\nindex 01b5ba8c4..2b51b216b 100755\n--- a/t/t1416-ref-transaction-hooks.sh\n+++ b/t/t1416-ref-transaction-hooks.sh\n@@ -58,6 +58,72 @@ test_expect_success 'branch deletion rejects a concurrent update' '\n \ttest_cmp_rev POST refs/heads/delete-race\n '\n \n+test_expect_success 'hook gets old values when pruning remote refs' '\n+\ttest_create_repo empty.git --bare &&\n+\ttest_create_repo prune &&\n+\tgit -C prune remote add origin ../empty.git &&\n+\ttest_commit -C prune one &&\n+\tone=$(git -C prune rev-parse HEAD) &&\n+\ttest_commit -C prune two &&\n+\ttwo=$(git -C prune rev-parse HEAD) &&\n+\tgit -C prune update-ref refs/remotes/origin/remote-prune-z \"$one\" &&\n+\tgit -C prune update-ref refs/remotes/origin/remote-prune-a \"$two\" &&\n+\tgit -C prune pack-refs --all &&\n+\ttest_hook -C prune reference-transaction <<-\\EOF &&\n+\t\tif test \"$1\" = committed\n+\t\tthen\n+\t\t\t# Ignore backend-internal zero-to-zero records.\n+\t\t\twhile read -r old new ref\n+\t\t\tdo\n+\t\t\t\tcase \"$old\" in\n+\t\t\t\t*[!0]*)\n+\t\t\t\t\techo \"$old $new $ref\"\n+\t\t\t\t\t;;\n+\t\t\t\tesac\n+\t\t\tdone >>actual\n+\t\tfi\n+\tEOF\n+\t(\n+\t\tcd prune &&\n+\t\tgit remote prune origin &&\n+\t\tgit update-ref refs/remotes/origin/fetch-prune \"$one\" &&\n+\t\tgit fetch --prune origin &&\n+\t\tgit update-ref refs/remotes/origin/atomic-prune \"$one\" &&\n+\t\tgit fetch --atomic --prune origin &&\n+\t\tcat >expect <<-EOF &&\n+\t\t\t$two $ZERO_OID refs/remotes/origin/remote-prune-a\n+\t\t\t$one $ZERO_OID refs/remotes/origin/remote-prune-z\n+\t\t\t$one $ZERO_OID refs/remotes/origin/fetch-prune\n+\t\t\t$one $ZERO_OID refs/remotes/origin/atomic-prune\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success 'remote prune rejects a concurrent update' '\n+\ttest_when_finished \"rm -rf race-empty.git race-prune\" &&\n+\ttest_create_repo race-empty.git --bare &&\n+\ttest_create_repo race-prune &&\n+\ttest_commit -C race-prune one &&\n+\tone=$(git -C race-prune rev-parse HEAD) &&\n+\ttest_commit -C race-prune two &&\n+\ttwo=$(git -C race-prune rev-parse HEAD) &&\n+\tgit -C race-prune remote add origin ../race-empty.git &&\n+\tgit -C race-prune update-ref refs/remotes/origin/race \"$one\" &&\n+\ttest_hook -C race-prune reference-transaction <<-\\EOF &&\n+\t\tmarker=$(git rev-parse --git-path prune-race-once)\n+\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n+\t\tthen\n+\t\t\t>\"$marker\"\n+\t\t\tgit update-ref refs/remotes/origin/race HEAD\n+\t\tfi\n+\t\texit 0\n+\tEOF\n+\ttest_must_fail git -C race-prune remote prune origin >out 2>err &&\n+\ttest \"$two\" = \"$(git -C race-prune rev-parse refs/remotes/origin/race)\" &&\n+\t! grep \"\\[pruned\\]\" out\n+'\n+\n test_expect_success 'hook allows updating ref if successful' '\n \tgit reset --hard PRE &&\n \ttest_hook reference-transaction <<-\\EOF &&\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"552927","messageId":"CAOLa=ZRd9x4yEcTs+TfnzGFK1iGNigm75F0ggpB=5M0jxGZb6w@mail.gmail.com","threadId":"66351","inReplyTo":"5c96a5a1ebafd49a301c5c1dc77a2e19d6677ab0.1789901584.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v2 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-21T13:12:58Z","receivedAt":"2026-09-21T13:13:00Z","isPatch":true,"body":"Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n\n> refs_delete_refs() currently performs unconditional deletions. Thus callers\n> cannot preserve old values that they have already resolved, and\n> reference-transaction hooks consequently see a null old OID.\n>\n> Add an optional oid_array whose entries correspond to the refnames. Pass each\n> non-null OID to ref_transaction_delete(). Supplying an OID makes the deletion\n> conditional: if the ref changed after the caller resolved it, the transaction\n> fails instead of deleting the new value. Existing callers that pass NULL\n> retain the unconditional behavior.\n>\n> Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n> ---\n>  bisect.c                  |  2 +-\n>  builtin/branch.c          |  3 ++-\n>  builtin/fetch.c           |  2 +-\n>  builtin/remote.c          |  5 +++--\n>  builtin/tag.c             |  3 ++-\n>  refs.c                    | 23 ++++++++++++++---------\n>  refs.h                    | 12 ++++++++++--\n>  t/helper/test-ref-store.c |  2 +-\n>  8 files changed, 34 insertions(+), 18 deletions(-)\n>\n> diff --git a/bisect.c b/bisect.c\n> index 94c7028d2..9aa3bace9 100644\n> --- a/bisect.c\n> +++ b/bisect.c\n> @@ -1203,7 +1203,7 @@ int bisect_clean_state(void)\n>  \tstring_list_append(&refs_for_removal, \"BISECT_EXPECTED_REV\");\n>  \tresult = refs_delete_refs(get_main_ref_store(the_repository),\n>  \t\t\t\t  \"bisect: remove\", &refs_for_removal,\n> -\t\t\t\t  REF_NO_DEREF);\n> +\t\t\t\t  NULL, REF_NO_DEREF);\n>  \tstring_list_clear(&refs_for_removal, 0);\n>  \tunlink_or_warn(git_path_bisect_ancestors_ok());\n>  \tunlink_or_warn(git_path_bisect_log());\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 1572a4f9e..f1abeb681 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -322,7 +322,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>  \t\tfree(target);\n>  \t}\n>\n> -\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n> +\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n> +\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n>  \t\tret = 1;\n>\n>  \tfor_each_string_list_item(item, &refs_to_delete) {\n> diff --git a/builtin/fetch.c b/builtin/fetch.c\n> index c1d7c672f..d202147b2 100644\n> --- a/builtin/fetch.c\n> +++ b/builtin/fetch.c\n> @@ -1467,7 +1467,7 @@ static int prune_refs(struct display_state *display_state,\n>  \t\t} else {\n>  \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n>  \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n> -\t\t\t\t\t\t  0);\n> +\t\t\t\t\t\t  NULL, 0);\n>  \t\t}\n>  \t}\n>\n> diff --git a/builtin/remote.c b/builtin/remote.c\n> index de989ea3b..13d3cc52d 100644\n> --- a/builtin/remote.c\n> +++ b/builtin/remote.c\n> @@ -1073,7 +1073,7 @@ static int rm(int argc, const char **argv, const char *prefix,\n>  \tif (!result)\n>  \t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n>  \t\t\t\t\t  \"remote: remove\", &branches,\n> -\t\t\t\t\t  REF_NO_DEREF);\n> +\t\t\t\t\t  NULL, REF_NO_DEREF);\n>  \tstring_list_clear(&branches, 0);\n>\n>  \tif (skipped.nr) {\n> @@ -1645,7 +1645,8 @@ static int prune_remote(const char *remote, int dry_run)\n>\n>  \tif (!dry_run)\n>  \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n> -\t\t\t\t\t   \"remote: prune\", &refs_to_prune, 0);\n> +\t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n> +\t\t\t\t\t   NULL, 0);\n>\n>  \tfor_each_string_list_item(item, &states.stale) {\n>  \t\tconst char *refname = item->util;\n> diff --git a/builtin/tag.c b/builtin/tag.c\n> index 06c125b53..40874a292 100644\n> --- a/builtin/tag.c\n> +++ b/builtin/tag.c\n> @@ -122,7 +122,8 @@ static int delete_tags(const char **argv)\n>  \tstruct string_list_item *item;\n>\n>  \tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n> -\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n> +\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n> +\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n>  \t\tresult = 1;\n>\n>  \tfor_each_string_list_item(item, &refs_to_delete) {\n> diff --git a/refs.c b/refs.c\n> index d3caa9a63..9c593baea 100644\n> --- a/refs.c\n> +++ b/refs.c\n> @@ -18,6 +18,7 @@\n>  #include \"refs/refs-internal.h\"\n>  #include \"hook.h\"\n>  #include \"object-name.h\"\n> +#include \"oid-array.h\"\n>  #include \"odb.h\"\n>  #include \"object.h\"\n>  #include \"path.h\"\n> @@ -3056,33 +3057,37 @@ void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n>  }\n>\n>  int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n> -\t\t     struct string_list *refnames, unsigned int flags)\n> +\t\t     struct string_list *refnames,\n> +\t\t     const struct oid_array *old_oids,\n> +\t\t     unsigned int flags)\n>  {\n>  \tstruct ref_transaction *transaction;\n>  \tstruct strbuf err = STRBUF_INIT;\n> -\tstruct string_list_item *item;\n> +\tsize_t i;\n>  \tint ret = 0, failures = 0;\n>  \tchar *msg;\n>\n> +\tif (old_oids && old_oids->nr != refnames->nr)\n> +\t\tBUG(\"refname and old OID counts do not match\");\n>  \tif (!refnames->nr)\n>  \t\treturn 0;\n>\n>  \tmsg = normalize_reflog_message(logmsg);\n>\n> -\t/*\n> -\t * Since we don't check the references' old_oids, the\n> -\t * individual updates can't fail, so we can pack all of the\n> -\t * updates into a single transaction.\n> -\t */\n\nOkay so we already have the error buf sent to the refs subsystem and the\nappropriate error will now be displayed.\n\n>  \ttransaction = ref_store_transaction_begin(refs, 0, &err);\n>  \tif (!transaction) {\n>  \t\tret = error(\"%s\", err.buf);\n>  \t\tgoto out;\n>  \t}\n>\n> -\tfor_each_string_list_item(item, refnames) {\n> +\tfor (i = 0; i < refnames->nr; i++) {\n> +\t\tstruct string_list_item *item = &refnames->items[i];\n> +\t\tconst struct object_id *old_oid = old_oids ? &old_oids->oid[i] : NULL;\n> +\n> +\t\tif (old_oid && is_null_oid(old_oid))\n> +\t\t\told_oid = NULL;\n\nWe need to do this since `ref_transaction_delete()` doesn't expect\nold_oids set to zeroes. But why would a callee do this? Shouldn't this\nalso be a bug, if the callee doesn't care about the previous value\nshouldn't they simply set `old_oids->oid[i] = NULL`?\n\n>  \t\tret = ref_transaction_delete(transaction, item->string,\n> -\t\t\t\t\t     NULL, NULL, flags, msg, &err);\n> +\t\t\t\t\t     old_oid, NULL, flags, msg, &err);\n>  \t\tif (ret) {\n>  \t\t\twarning(_(\"could not delete reference %s: %s\"),\n>  \t\t\t\titem->string, err.buf);\n> diff --git a/refs.h b/refs.h\n> index 71d5c186d..b76b556cf 100644\n> --- a/refs.h\n> +++ b/refs.h\n> @@ -9,6 +9,7 @@\n>  struct fsck_options;\n>  struct object_id;\n>  struct ref_store;\n> +struct oid_array;\n>  struct strbuf;\n>  struct string_list;\n>  struct string_list_item;\n> @@ -613,13 +614,20 @@ int refs_delete_ref(struct ref_store *refs, const char *msg,\n>  \t\t    unsigned int flags);\n>\n>  /*\n> - * Delete the specified references. If there are any problems, emit\n> + * Delete the specified references. If old_oids is non-NULL, it must contain\n> + * an entry for each refname, in the same order. Each non-null entry is used\n> + * to verify the current value of the corresponding reference before deleting\n> + * it. A null entry disables verification for that reference.\n> + *\n\nHere too, we don't talk about zero-oid's. So I think we should skip the\nimplicit conversion.\n\n> + * If there are any problems, emit\n>   * errors but attempt to keep going (i.e., the deletes are not done in\n>   * an all-or-nothing transaction). msg and flags are passed through to\n>   * ref_transaction_delete().\n>   */\n>  int refs_delete_refs(struct ref_store *refs, const char *msg,\n> -\t\t     struct string_list *refnames, unsigned int flags);\n> +\t\t     struct string_list *refnames,\n> +\t\t     const struct oid_array *old_oids,\n> +\t\t     unsigned int flags);\n>\n>  /** Delete a reflog */\n>  int refs_delete_reflog(struct ref_store *refs, const char *refname);\n> diff --git a/t/helper/test-ref-store.c b/t/helper/test-ref-store.c\n> index 3866d0aca..c2c7dfb06 100644\n> --- a/t/helper/test-ref-store.c\n> +++ b/t/helper/test-ref-store.c\n> @@ -140,7 +140,7 @@ static int cmd_delete_refs(struct ref_store *refs, const char **argv)\n>  \twhile (*argv)\n>  \t\tstring_list_append(&refnames, *argv++);\n>\n> -\tresult = refs_delete_refs(refs, msg, &refnames, flags);\n> +\tresult = refs_delete_refs(refs, msg, &refnames, NULL, flags);\n>  \tstring_list_clear(&refnames, 0);\n>  \treturn result;\n>  }\n> --\n> 2.39.3 (Apple Git-146)\n"},{"id":"552929","messageId":"CAOLa=ZRoNm_kS5CvUH3o208B7+2JSud8o5xAe2ikjYGNVwZiaw@mail.gmail.com","threadId":"66351","inReplyTo":"d00fdeba2f673cf5a174f919452694c733736e84.1789901584.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v2 2/3] branch, tag: retain old OIDs in batched deletions","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-21T13:19:46Z","receivedAt":"2026-09-21T13:19:50Z","isPatch":true,"body":"Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n\n> Before 8198907795 (use delete_refs when deleting tags or branches,\n> 2021-01-21), branch and tag deletion passed each resolved old OID to\n> delete_ref(). This prevented the command from deleting a ref that another\n> process had changed after it was inspected.\n>\n> The conversion to batched deletion dropped those old OIDs. Besides making the\n> deletions unconditional, this causes reference-transaction hooks to report\n> zero as both the old and new OID.\n>\n> Both commands still resolve the old OIDs before starting the deletion. Pass\n> those values to refs_delete_refs(). This restores the old race protection and\n> lets hooks receive useful old values without adding any ref reads. If a ref\n> changes concurrently, the transaction fails and preserves the new value.\n>\n> Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n> ---\n>  builtin/branch.c                 |  6 ++++-\n>  builtin/tag.c                    |  6 ++++-\n>  t/t1416-ref-transaction-hooks.sh | 44 ++++++++++++++++++++++++++++++++\n>  3 files changed, 54 insertions(+), 2 deletions(-)\n>\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index f1abeb681..9f03ebc09 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -16,6 +16,7 @@\n>  #include \"commit.h\"\n>  #include \"gettext.h\"\n>  #include \"object-name.h\"\n> +#include \"oid-array.h\"\n>  #include \"remote.h\"\n>  #include \"parse-options.h\"\n>  #include \"branch.h\"\n> @@ -230,6 +231,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>  \tstruct strbuf bname = STRBUF_INIT;\n>  \tenum interpret_branch_kind allowed_interpret;\n>  \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n> +\tstruct oid_array old_oids = OID_ARRAY_INIT;\n>  \tstruct string_list_item *item;\n>  \tint branch_name_pos;\n>  \tconst char *fmt_remotes = \"refs/remotes/%s\";\n> @@ -314,6 +316,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>  \t\t}\n>\n>  \t\titem = string_list_append(&refs_to_delete, name);\n> +\t\toid_array_append(&old_oids, &oid);\n>  \t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n>  \t\t\t\t    : (flags & REF_ISSYMREF) ? target\n>  \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n> @@ -323,7 +326,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>  \t}\n>\n>  \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n> -\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n> +\t\t\t     &refs_to_delete, &old_oids, REF_NO_DEREF))\n>  \t\tret = 1;\n>\n>  \tfor_each_string_list_item(item, &refs_to_delete) {\n> @@ -342,6 +345,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>  \t\tfree(describe_ref);\n>  \t}\n>  \tstring_list_clear(&refs_to_delete, 0);\n> +\toid_array_clear(&old_oids);\n>\n>  \tfree(name);\n>  \tstrbuf_release(&bname);\n> diff --git a/builtin/tag.c b/builtin/tag.c\n> index 40874a292..0a3eb70fa 100644\n> --- a/builtin/tag.c\n> +++ b/builtin/tag.c\n> @@ -119,11 +119,14 @@ static int delete_tags(const char **argv)\n>  {\n>  \tint result;\n>  \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n> +\tstruct oid_array old_oids = OID_ARRAY_INIT;\n>  \tstruct string_list_item *item;\n>\n>  \tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n> +\tfor_each_string_list_item(item, &refs_to_delete)\n> +\t\toid_array_append(&old_oids, item->util);\n\nNit: wouldn't it make sense to add the oid to `old_oids` within\n`collect_tags()` instead of iterating over all tags again?\n\nYou would have to change the callback data sent. If not, we should call\nthis out in the commit message at the least.\n\n>  \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n> -\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n> +\t\t\t     &refs_to_delete, &old_oids, REF_NO_DEREF))\n>  \t\tresult = 1;\n>\n>  \tfor_each_string_list_item(item, &refs_to_delete) {\n> @@ -137,6 +140,7 @@ static int delete_tags(const char **argv)\n>  \t\tfree(oid);\n>  \t}\n>  \tstring_list_clear(&refs_to_delete, 0);\n> +\toid_array_clear(&old_oids);\n>  \treturn result;\n>  }\n>\n> diff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\n> index 4fe9d9b23..01b5ba8c4 100755\n> --- a/t/t1416-ref-transaction-hooks.sh\n> +++ b/t/t1416-ref-transaction-hooks.sh\n> @@ -14,6 +14,50 @@ test_expect_success setup '\n>  \tPOST_OID=$(git rev-parse POST)\n>  '\n>\n> +test_expect_success 'hook gets old values for batched branch/tag deletion' '\n> +\ttest_when_finished \"rm -f actual\" &&\n> +\tgit branch to-delete PRE &&\n> +\tgit tag delete-tag POST &&\n> +\tgit pack-refs --all &&\n> +\ttest_hook reference-transaction <<-\\EOF &&\n> +\t\tif test \"$1\" = committed\n> +\t\tthen\n> +\t\t\t# Ignore backend-internal zero-to-zero records.\n> +\t\t\twhile read -r old new ref\n> +\t\t\tdo\n> +\t\t\t\tcase \"$old\" in\n> +\t\t\t\t*[!0]*)\n> +\t\t\t\t\techo \"$old $new $ref\"\n> +\t\t\t\t\t;;\n> +\t\t\t\tesac\n> +\t\t\tdone >>actual\n> +\t\tfi\n> +\tEOF\n> +\tcat >expect <<-EOF &&\n> +\t\t$PRE_OID $ZERO_OID refs/heads/to-delete\n> +\t\t$POST_OID $ZERO_OID refs/tags/delete-tag\n> +\tEOF\n> +\tgit branch -D to-delete &&\n> +\tgit tag -d delete-tag &&\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success 'branch deletion rejects a concurrent update' '\n> +\tgit branch delete-race PRE &&\n> +\ttest_hook reference-transaction <<-\\EOF &&\n> +\t\tmarker=$(git rev-parse --git-path delete-race-once)\n> +\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n> +\t\tthen\n> +\t\t\t>\"$marker\"\n> +\t\t\tgit update-ref refs/heads/delete-race POST\n> +\t\tfi\n> +\t\texit 0\n> +\tEOF\n> +\ttest_must_fail git branch -D delete-race 2>err &&\n> +\ttest_grep \"is at $POST_OID but expected $PRE_OID\" err &&\n> +\ttest_cmp_rev POST refs/heads/delete-race\n> +'\n> +\n>  test_expect_success 'hook allows updating ref if successful' '\n>  \tgit reset --hard PRE &&\n>  \ttest_hook reference-transaction <<-\\EOF &&\n> --\n> 2.39.3 (Apple Git-146)\n\nThe rest of the patch looks good! :)\n"},{"id":"552931","messageId":"CAOLa=ZRYTevU5SpkGBQu198Rbaemms4s03pFaZ4DOKCGMOV_vQ@mail.gmail.com","threadId":"66351","inReplyTo":"461c36ccdae09fb827a3c0efc7eed5aef072e09b.1789901584.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v2 3/3] fetch, remote: retain old OIDs when pruning refs","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-21T13:56:29Z","receivedAt":"2026-09-21T13:56:32Z","isPatch":true,"body":"Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n\n> get_stale_heads() records the current value of each stale local ref in its\n> new_oid member. The pruning paths discard that value and request unconditional\n> deletion, so reference-transaction hooks receive a null old OID.\n>\n> Carry the recorded values into the deletion transactions. Besides giving the\n> hooks useful values, this stops a stale scan from deleting a ref that another\n> process updated before the transaction acquired its locks. A concurrent\n> change now makes the prune fail and preserves the new value.\n>\n> This reuses data collected while finding stale refs and therefore requires no\n> additional ref reads. Do not print deletion status when a non-atomic prune\n> fails its old-OID check.\n>\n\nThis does break user behavior though, previously we would never fail on\npruning refs, but now we would and in a all-or-nothing manner. So\nperhaps a better way would be to use the `REF_TRANSACTION_ALLOW_FAILURE`?\n\n> Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n> ---\n>  builtin/fetch.c                  | 13 +++++--\n>  builtin/remote.c                 | 36 ++++++++++++++---\n>  t/t1416-ref-transaction-hooks.sh | 66 ++++++++++++++++++++++++++++++++\n>  3 files changed, 106 insertions(+), 9 deletions(-)\n>\n> diff --git a/builtin/fetch.c b/builtin/fetch.c\n> index d202147b2..da413ace0 100644\n> --- a/builtin/fetch.c\n> +++ b/builtin/fetch.c\n> @@ -1452,14 +1452,18 @@ static int prune_refs(struct display_state *display_state,\n>  \tstruct ref *ref, *stale_refs = get_stale_heads(rs, ref_map);\n>  \tstruct strbuf err = STRBUF_INIT;\n>  \tstruct string_list refnames = STRING_LIST_INIT_NODUP;\n> +\tstruct oid_array old_oids = OID_ARRAY_INIT;\n>\n> -\tfor (ref = stale_refs; ref; ref = ref->next)\n> +\tfor (ref = stale_refs; ref; ref = ref->next) {\n>  \t\tstring_list_append(&refnames, ref->name);\n> +\t\toid_array_append(&old_oids, &ref->new_oid);\n> +\t}\n>\n\nHere `refnames` is built, but below it is only used for the non-atomic\nflow. Perhaps, we should move this into the `else` block?\n\n>  \tif (!dry_run) {\n>  \t\tif (transaction) {\n>  \t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n> -\t\t\t\tresult = ref_transaction_delete(transaction, ref->name, NULL,\n> +\t\t\t\tresult = ref_transaction_delete(transaction, ref->name,\n> +\t\t\t\t\t\t\t&ref->new_oid,\n>  \t\t\t\t\t\t\t\tNULL, 0, \"fetch: prune\", &err);\n\nNit: the formatting seems off.\n\nCuriously, was an LLM used to create these patches? If so, please do\nread our policy in 'Documentation/SubmittingPatches' regarding AI usage.\n\n>  \t\t\t\tif (result)\n>  \t\t\t\t\tgoto cleanup;\n> @@ -1467,8 +1471,10 @@ static int prune_refs(struct display_state *display_state,\n>  \t\t} else {\n>  \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n>  \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n> -\t\t\t\t\t\t  NULL, 0);\n> +\t\t\t\t\t\t  &old_oids, 0);\n>  \t\t}\n> +\t\tif (result)\n> +\t\t\tgoto cleanup;\n\nSo, previously, we'd always prune all references without checking the\nold_oid. Now we should expect that this can fail. So we do need to check\nfor the `result`.\n\nSeems like the other branch condition also does the same, we can extract\nthis out?\n\n>  \t}\n>\n>  \tif (verbosity >= 0) {\n> @@ -1487,6 +1493,7 @@ static int prune_refs(struct display_state *display_state,\n>\n>  cleanup:\n>  \tstring_list_clear(&refnames, 0);\n> +\toid_array_clear(&old_oids);\n>  \tstrbuf_release(&err);\n>  \tfree_refs(stale_refs);\n>  \treturn result;\n> diff --git a/builtin/remote.c b/builtin/remote.c\n> index 13d3cc52d..b899bec55 100644\n> --- a/builtin/remote.c\n> +++ b/builtin/remote.c\n> @@ -17,6 +17,7 @@\n>  #include \"refs.h\"\n>  #include \"refspec.h\"\n>  #include \"odb.h\"\n> +#include \"oid-array.h\"\n>  #include \"strvec.h\"\n>  #include \"commit-reach.h\"\n>  #include \"progress.h\"\n> @@ -380,6 +381,11 @@ struct ref_states {\n>  \tint queried;\n>  };\n>\n> +struct stale_ref {\n> +\tstruct object_id oid;\n> +\tchar name[FLEX_ARRAY];\n> +};\n> +\n>  #define REF_STATES_INIT { \\\n>  \t.new_refs = STRING_LIST_INIT_DUP, \\\n>  \t.skipped = STRING_LIST_INIT_DUP, \\\n> @@ -410,9 +416,13 @@ static int get_ref_states(const struct ref *remote_refs, struct ref_states *stat\n>  \t}\n>  \tstale_refs = get_stale_heads(&states->remote->fetch, fetch_map);\n>  \tfor (ref = stale_refs; ref; ref = ref->next) {\n> +\t\tstruct stale_ref *stale_ref;\n>  \t\tstruct string_list_item *item =\n>  \t\t\tstring_list_append(&states->stale, abbrev_branch(ref->name));\n> -\t\titem->util = xstrdup(ref->name);\n> +\n> +\t\tFLEX_ALLOC_STR(stale_ref, name, ref->name);\n> +\t\toidcpy(&stale_ref->oid, &ref->new_oid);\n> +\t\titem->util = stale_ref;\n>  \t}\n>  \tfree_refs(stale_refs);\n>  \tfree_refs(fetch_map);\n> @@ -1627,6 +1637,7 @@ static int prune_remote(const char *remote, int dry_run)\n>  \tint result = 0;\n>  \tstruct ref_states states = REF_STATES_INIT;\n>  \tstruct string_list refs_to_prune = STRING_LIST_INIT_NODUP;\n> +\tstruct oid_array old_oids = OID_ARRAY_INIT;\n>  \tstruct string_list_item *item;\n>\n>  \tget_remote_ref_states(remote, &states, GET_REF_STATES);\n> @@ -1639,17 +1650,28 @@ static int prune_remote(const char *remote, int dry_run)\n>  \tprintf_ln(_(\"Pruning %s\"), remote);\n>  \tprintf_ln(_(\"URL: %s\"), states.remote->url.v[0]);\n>\n> -\tfor_each_string_list_item(item, &states.stale)\n> -\t\tstring_list_append(&refs_to_prune, item->util);\n> +\tfor_each_string_list_item(item, &states.stale) {\n> +\t\tstruct stale_ref *stale_ref = item->util;\n> +\t\tstruct string_list_item *to_prune;\n> +\n> +\t\tto_prune = string_list_append(&refs_to_prune, stale_ref->name);\n> +\t\tto_prune->util = &stale_ref->oid;\n> +\t}\n>  \tstring_list_sort(&refs_to_prune);\n> +\tfor_each_string_list_item(item, &refs_to_prune)\n> +\t\toid_array_append(&old_oids, item->util);\n>\n\nWe do this in the previous block? We don't need a new iterator here.\n\n> -\tif (!dry_run)\n> +\tif (!dry_run) {\n>  \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n>  \t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n> -\t\t\t\t\t   NULL, 0);\n> +\t\t\t\t\t   &old_oids, 0);\n> +\t\tif (result)\n> +\t\t\tgoto cleanup;\n> +\t}\n>\n>  \tfor_each_string_list_item(item, &states.stale) {\n> -\t\tconst char *refname = item->util;\n> +\t\tstruct stale_ref *stale_ref = item->util;\n> +\t\tconst char *refname = stale_ref->name;\n>\n>  \t\tif (dry_run)\n>  \t\t\tprintf_ln(_(\" * [would prune] %s\"),\n> @@ -1662,7 +1684,9 @@ static int prune_remote(const char *remote, int dry_run)\n>  \trefs_warn_dangling_symrefs(get_main_ref_store(the_repository),\n>  \t\t\t\t   stdout, \" \", dry_run, &refs_to_prune);\n>\n> +cleanup:\n>  \tstring_list_clear(&refs_to_prune, 0);\n> +\toid_array_clear(&old_oids);\n>  \tfree_remote_ref_states(&states);\n>  \treturn result;\n>  }\n> diff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\n> index 01b5ba8c4..2b51b216b 100755\n> --- a/t/t1416-ref-transaction-hooks.sh\n> +++ b/t/t1416-ref-transaction-hooks.sh\n> @@ -58,6 +58,72 @@ test_expect_success 'branch deletion rejects a concurrent update' '\n>  \ttest_cmp_rev POST refs/heads/delete-race\n>  '\n>\n> +test_expect_success 'hook gets old values when pruning remote refs' '\n> +\ttest_create_repo empty.git --bare &&\n> +\ttest_create_repo prune &&\n\ntest_create_repo is considered deprecated, let's use `git init`\ndirectly. While we're at it, we should also cleanup the directories we\ncreate here.\n\n> +\tgit -C prune remote add origin ../empty.git &&\n> +\ttest_commit -C prune one &&\n> +\tone=$(git -C prune rev-parse HEAD) &&\n> +\ttest_commit -C prune two &&\n> +\ttwo=$(git -C prune rev-parse HEAD) &&\n> +\tgit -C prune update-ref refs/remotes/origin/remote-prune-z \"$one\" &&\n> +\tgit -C prune update-ref refs/remotes/origin/remote-prune-a \"$two\" &&\n> +\tgit -C prune pack-refs --all &&\n\nWhy do we need to pack-refs?\n\n> +\ttest_hook -C prune reference-transaction <<-\\EOF &&\n> +\t\tif test \"$1\" = committed\n> +\t\tthen\n> +\t\t\t# Ignore backend-internal zero-to-zero records.\n> +\t\t\twhile read -r old new ref\n> +\t\t\tdo\n> +\t\t\t\tcase \"$old\" in\n> +\t\t\t\t*[!0]*)\n> +\t\t\t\t\techo \"$old $new $ref\"\n> +\t\t\t\t\t;;\n> +\t\t\t\tesac\n> +\t\t\tdone >>actual\n> +\t\tfi\n> +\tEOF\n> +\t(\n> +\t\tcd prune &&\n\nAll the commands above also run in the 'prune' directory, can we put all\nof them in this subshell?\n\n> +\t\tgit remote prune origin &&\n> +\t\tgit update-ref refs/remotes/origin/fetch-prune \"$one\" &&\n> +\t\tgit fetch --prune origin &&\n> +\t\tgit update-ref refs/remotes/origin/atomic-prune \"$one\" &&\n> +\t\tgit fetch --atomic --prune origin &&\n> +\t\tcat >expect <<-EOF &&\n> +\t\t\t$two $ZERO_OID refs/remotes/origin/remote-prune-a\n> +\t\t\t$one $ZERO_OID refs/remotes/origin/remote-prune-z\n> +\t\t\t$one $ZERO_OID refs/remotes/origin/fetch-prune\n> +\t\t\t$one $ZERO_OID refs/remotes/origin/atomic-prune\n> +\t\tEOF\n> +\t\ttest_cmp expect actual\n> +\t)\n> +'\n> +\n> +test_expect_success 'remote prune rejects a concurrent update' '\n> +\ttest_when_finished \"rm -rf race-empty.git race-prune\" &&\n> +\ttest_create_repo race-empty.git --bare &&\n> +\ttest_create_repo race-prune &&\n\nsame as above.\n\n> +\ttest_commit -C race-prune one &&\n> +\tone=$(git -C race-prune rev-parse HEAD) &&\n> +\ttest_commit -C race-prune two &&\n> +\ttwo=$(git -C race-prune rev-parse HEAD) &&\n> +\tgit -C race-prune remote add origin ../race-empty.git &&\n> +\tgit -C race-prune update-ref refs/remotes/origin/race \"$one\" &&\n> +\ttest_hook -C race-prune reference-transaction <<-\\EOF &&\n> +\t\tmarker=$(git rev-parse --git-path prune-race-once)\n> +\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n> +\t\tthen\n> +\t\t\t>\"$marker\"\n> +\t\t\tgit update-ref refs/remotes/origin/race HEAD\n> +\t\tfi\n> +\t\texit 0\n> +\tEOF\n> +\ttest_must_fail git -C race-prune remote prune origin >out 2>err &&\n> +\ttest \"$two\" = \"$(git -C race-prune rev-parse refs/remotes/origin/race)\" &&\n> +\t! grep \"\\[pruned\\]\" out\n> +'\n> +\n>  test_expect_success 'hook allows updating ref if successful' '\n>  \tgit reset --hard PRE &&\n>  \ttest_hook reference-transaction <<-\\EOF &&\n> --\n> 2.39.3 (Apple Git-146)\n"},{"id":"552932","messageId":"CAOLa=ZR4V45R0zST_gxb3FMSWCwbi2MFN=5sCzhTAQfuZrRH7g@mail.gmail.com","threadId":"66351","inReplyTo":"cover.1789901584.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v2 0/3] refs: report old OIDs for batched deletions","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-21T13:57:49Z","receivedAt":"2026-09-21T13:57:52Z","isPatch":true,"body":"Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n\n> The reference-transaction hook receives zero as both the old and new OID\n> when branch, tag, fetch, and remote delete refs through refs_delete_refs().\n> Those callers already know the values that they selected for deletion.\n>\n> Teach refs_delete_refs() to accept aligned old OIDs and pass them into the\n> transaction. Besides making the hook records useful, this makes the selected\n> callers reject concurrent changes instead of deleting values that they did\n> not inspect. For branch and tag, this restores the compare-and-delete\n> behavior that existed before 8198907795 converted them to batched deletion.\n> For pruning, it prevents a stale scan from deleting a ref updated by another\n> process.\n>\n> The values are already available at every updated call site, so the series\n> adds no ref reads and retains batched performance.\n\nI still have some concerns about backward comparability here, since we\ngo from a delete all without any checks to a all-or-nothing situation,\nwhich can be an issue with commands like `git fetch --prune`.\n\nLeft some comments on the individual patches.\n\n>\n> Changes since v1:\n>\n>  * Document the conditional deletion behavior and its race protection.\n>  * Add tests that update refs from the hook's preparing phase and verify that\n>    branch deletion and remote pruning preserve the concurrent update.\n>  * Avoid printing deletion status when a non-atomic prune fails.\n>  * Use a local string_list_item in refs_delete_refs(), as suggested by\n>    Karthik.\n>\n> Based on maint at e9019fcafe (Git 2.55).\n\nMight be worthwhile to rebase on top of master. Seems like there are\nconflicts with d38352cd43 (A few more fixes before -rc2, 2026-09-17).\n\n[snip]\n\nThanks!\n"},{"id":"552948","messageId":"CACQ=SRG0q6Ezre3Z2bv6JJw07KXnUn2SDxNLTt0FqbwEdcbOqw@mail.gmail.com","threadId":"66351","inReplyTo":"CAOLa=ZR4V45R0zST_gxb3FMSWCwbi2MFN=5sCzhTAQfuZrRH7g@mail.gmail.com","subject":"Re: [PATCH v2 0/3] refs: report old OIDs for batched deletions","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-21T20:01:12Z","receivedAt":"2026-09-21T20:01:27Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> This does break user behavior though, previously we would never fail on\n> pruning refs, but now we would and in a all-or-nothing manner. So\n> perhaps a better way would be to use the REF_TRANSACTION_ALLOW_FAILURE?\n\nThat makes sense. I will use it.\n\n> Here refnames is built, but below it is only used for the non-atomic\n> flow. Perhaps, we should move this into the else block?\n\nYes, I will move construction of the refname and OID arrays into the\nnon-atomic branch.\n\n> Nit: the formatting seems off.\n\nWill fix.\n\n> Curiously, was an LLM used to create these patches? If so, please do\n> read our policy in 'Documentation/SubmittingPatches' regarding AI usage.\n\nYes, I use an AI coding agent. Thank you for pointing me to the\npolicy. I have now read\nit. I reviewed the resulting changes and tests, understand the\nimplementation, so I take responsibility for the version I submit.\n\n> Seems like the other branch condition also does the same, we can extract\n> this out?\n\nWill do.\n\n> We do this in the previous block? We don't need a new iterator here.\nRight. I will append the refname and corresponding OID in the same loop.\n> test_create_repo is considered deprecated, let's use git init\n> directly. While we're at it, we should also cleanup the directories we\n> create here.\n\nWill do it.\n\n> Why do we need to pack-refs?\nIt is not required to reproduce this problem. I will remove it.\n> All the commands above also run in the 'prune' directory, can we put all\n> of them in this subshell?\n\nYes, I will move the repository operations into the subshell where\npossible.\n\nThanks for the review. I hope tomorow I will prepare v3.\n\nCheers,\n- Maciej Ciemborowicz\n\nOn Mon, Sep 21, 2026 at 3:57 PM Karthik Nayak <karthik.188@gmail.com> wrote:\n>\n> Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n>\n> > The reference-transaction hook receives zero as both the old and new OID\n> > when branch, tag, fetch, and remote delete refs through refs_delete_refs().\n> > Those callers already know the values that they selected for deletion.\n> >\n> > Teach refs_delete_refs() to accept aligned old OIDs and pass them into the\n> > transaction. Besides making the hook records useful, this makes the selected\n> > callers reject concurrent changes instead of deleting values that they did\n> > not inspect. For branch and tag, this restores the compare-and-delete\n> > behavior that existed before 8198907795 converted them to batched deletion.\n> > For pruning, it prevents a stale scan from deleting a ref updated by another\n> > process.\n> >\n> > The values are already available at every updated call site, so the series\n> > adds no ref reads and retains batched performance.\n>\n> I still have some concerns about backward comparability here, since we\n> go from a delete all without any checks to a all-or-nothing situation,\n> which can be an issue with commands like `git fetch --prune`.\n>\n> Left some comments on the individual patches.\n>\n> >\n> > Changes since v1:\n> >\n> >  * Document the conditional deletion behavior and its race protection.\n> >  * Add tests that update refs from the hook's preparing phase and verify that\n> >    branch deletion and remote pruning preserve the concurrent update.\n> >  * Avoid printing deletion status when a non-atomic prune fails.\n> >  * Use a local string_list_item in refs_delete_refs(), as suggested by\n> >    Karthik.\n> >\n> > Based on maint at e9019fcafe (Git 2.55).\n>\n> Might be worthwhile to rebase on top of master. Seems like there are\n> conflicts with d38352cd43 (A few more fixes before -rc2, 2026-09-17).\n>\n> [snip]\n>\n> Thanks!\n"},{"id":"552963","messageId":"xmqq4ifijh2g.fsf@gitster.g","threadId":"66351","inReplyTo":"5c96a5a1ebafd49a301c5c1dc77a2e19d6677ab0.1789901584.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v2 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-21T23:55:03Z","receivedAt":"2026-09-21T23:55:06Z","isPatch":true,"body":"Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n\n> refs_delete_refs() currently performs unconditional deletions. Thus callers\n> cannot preserve old values that they have already resolved, and\n> reference-transaction hooks consequently see a null old OID.\n>\n> Add an optional oid_array whose entries correspond to the refnames.\n\nI had to read this sentence three times and still couldn't guess\nwhat it wanted to say.  I _think_ the code is passing a list of\nrefnames, and your new parameter that is oid_array serves as a\nparallel list, where the ref, identified by the Nth element of the\nlist of refnames, is protected from deletion with the Nth element of\nthe list of oids in such a way that ref is not removed unless it\npoints at the specified object.  You'd need to find a concise way to\ntell that story instead of the above sentence that does not give\nreaders any meaningful information.\n\n>  int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n> -\t\t     struct string_list *refnames, unsigned int flags)\n> +\t\t     struct string_list *refnames,\n> +\t\t     const struct oid_array *old_oids,\n> +\t\t     unsigned int flags)\n>  {\n>  \tstruct ref_transaction *transaction;\n>  \tstruct strbuf err = STRBUF_INIT;\n> -\tstruct string_list_item *item;\n> +\tsize_t i;\n>  \tint ret = 0, failures = 0;\n>  \tchar *msg;\n>  \n> +\tif (old_oids && old_oids->nr != refnames->nr)\n> +\t\tBUG(\"refname and old OID counts do not match\");\n\nOK.  So it is not end-users' but calling code's responsibility to\nensure that the optional list of object names have exactly the same\nnumber of entries as the list of refs.\n\n>  \tif (!refnames->nr)\n>  \t\treturn 0;\n\nAnd this is as before.  Shouldn't the new test above be placed below\nthis?  After all, if we are removing no refs, we really do not care\nwhat garbage is in the old oids array---we won't even look at it.\n\n>  \tmsg = normalize_reflog_message(logmsg);\n>  \n> -\t/*\n> -\t * Since we don't check the references' old_oids, the\n> -\t * individual updates can't fail, so we can pack all of the\n> -\t * updates into a single transaction.\n> -\t */\n\nTo me, this reads more like \"We want to make sure that each deletion\nis independent and philosophically each of them should belong in\nseparate transactions so that even when some fails the rest would\nproceed.  Luckily, the current API does not allow you to check the\ncurrent value to protect refs from deletion, so we can cram all\ndelete operations in a single transaction and still claim that we\nare not making it all-or-none!\".  Natural continuation of that\nargument is \"If we ever extend the API so that refs are optionally\nprotected from deletion, we can get into a situation where some refs\ncan be successfully removed while others cannot.  Keeping everything\nin a single transaction WILL BECOME A WRONG DESIGN CHOICE when it\nhappens.\"\n\nAnd this new code is doing exactly that, making all the deletions,\nof possibly unrelated refs, into an all-or-none matter.\n\nDon't we need to have separate transactions to delete each ref to\nretain the \"delete them independently\" semantics?  If the caller\n(e.g., \"git fetch --prune\" without \"--atomic\") wants to delete 1000\nrefs, and a single ref fails its old-oid check due to a concurrent\nupdate, none of the 1000 refs will be removed and the transaction\nwould be aborted.  <refs.h> explains this function like so:\n\n    /*\n     * Delete the specified references. If there are any problems, emit\n     * errors but attempt to keep going (i.e., the deletes are not done in\n     * an all-or-nothing transaction). msg and flags are passed through to\n     * ref_transaction_delete().\n     */\n    int refs_delete_refs(struct ref_store *refs, const char *msg,\n                         struct string_list *refnames, unsigned int flags);\n\nbecause we want to avoid exactly such a failure mode.\n\nI do not offhand remember if our ref transactions have a mode where\nit acts more like a glorified \"batch\" job and commit does not\nnecessarily require everything succeeding, but if it do, then it is\nOK to keep using a single transaction but to run it in such a \"best\neffort\" mode.\n\n>  \ttransaction = ref_store_transaction_begin(refs, 0, &err);\n>  \tif (!transaction) {\n>  \t\tret = error(\"%s\", err.buf);\n>  \t\tgoto out;\n>  \t}\n>  \n> -\tfor_each_string_list_item(item, refnames) {\n> +\tfor (i = 0; i < refnames->nr; i++) {\n> +\t\tstruct string_list_item *item = &refnames->items[i];\n> +\t\tconst struct object_id *old_oid = old_oids ? &old_oids->oid[i] : NULL;\n> +\n> +\t\tif (old_oid && is_null_oid(old_oid))\n> +\t\t\told_oid = NULL;\n>  \t\tret = ref_transaction_delete(transaction, item->string,\n> -\t\t\t\t\t     NULL, NULL, flags, msg, &err);\n> +\t\t\t\t\t     old_oid, NULL, flags, msg, &err);\n>  \t\tif (ret) {\n>  \t\t\twarning(_(\"could not delete reference %s: %s\"),\n>  \t\t\t\titem->string, err.buf);\n"},{"id":"552976","messageId":"cover.1790079917.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1789901584.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v3 0/3] refs: report old OIDs for batched deletions","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-22T12:26:06Z","receivedAt":"2026-09-22T12:26:13Z","isPatch":true,"body":"This follows up on the reference-transaction bug report at [1].\n\nThe reference-transaction hook receives zero as both the old and new OID\nwhen branch, tag, fetch, and remote delete refs through refs_delete_refs().\nThose callers already know the values that they selected for deletion.\n\nTeach refs_delete_refs() to accept a parallel array of expected old OIDs and\npass them into the transaction. Besides making hook records useful, this\nrestores conditional deletion for branch and tag and adds it to pruning\nwithout additional ref reads. Use REF_TRANSACTION_ALLOW_FAILURE for\nnon-atomic batches so a concurrent change rejects only that deletion while\nunrelated stale refs are still removed. Atomic fetches retain their\nall-or-nothing behavior.\n\nChanges since v2:\n\n * Rebase onto master at d38352cd43.\n * Use REF_TRANSACTION_ALLOW_FAILURE for conditional batch deletion and\n   report individual rejections.\n * Clarify how the parallel refname and old-OID arrays correspond, and skip\n   their length check when there are no refs to delete.\n * Document null OIDs as the unconditional-deletion sentinel needed for\n   broken refs.\n * Append tag OIDs in collect_tags() instead of making a second pass.\n * Build the fetch refname/OID arrays only for non-atomic pruning and fix the\n   atomic-path formatting.\n * Replace deprecated test_create_repo calls, clean up test repositories,\n   remove unnecessary packed-ref setup, and cover partial prune failure.\n\nThe full test suite passes with DEVELOPER=1. The focused tests also pass with\nSHA-1 and SHA-256 using both the files and reftable backends.\n\n[1] https://lore.kernel.org/git/CACQ=SRGTTdQ+dHXhN6F52dBv5KxZBRfk_Em2fvmEmGJDoB6oTg@mail.gmail.com/\n\nMaciej Ciemborowicz (3):\n  refs: allow callers to supply old OIDs for batch deletion\n  branch, tag: retain old OIDs in batched deletions\n  fetch, remote: retain old OIDs when pruning refs\n\n bisect.c                         |   2 +-\n builtin/branch.c                 |   7 +-\n builtin/fetch.c                  |  21 ++++--\n builtin/remote.c                 |  37 +++++++--\n builtin/tag.c                    |  28 +++++--\n refs.c                           |  54 +++++++++++---\n refs.h                           |  13 +++-\n t/helper/test-ref-store.c        |   2 +-\n t/t1416-ref-transaction-hooks.sh | 124 +++++++++++++++++++++++++++++++\n 9 files changed, 250 insertions(+), 38 deletions(-)\n\nRange-diff against v2:\n1:  2e36ce00e ! 1:  3315d5f47 refs: allow callers to supply old OIDs for batch deletion\n    @@ Metadata\n      ## Commit message ##\n         refs: allow callers to supply old OIDs for batch deletion\n     \n    -    refs_delete_refs() currently performs unconditional deletions. Thus callers\n    -    cannot preserve old values that they have already resolved, and\n    -    reference-transaction hooks consequently see a null old OID.\n    +    refs_delete_refs() performs unconditional deletions, so callers cannot\n    +    preserve old values that they have already resolved. Consequently,\n    +    reference-transaction hooks see a null old OID.\n     \n    -    Add an optional oid_array whose entries correspond to the refnames. Pass each\n    -    non-null OID to ref_transaction_delete(). Supplying an OID makes the deletion\n    -    conditional: if the ref changed after the caller resolved it, the transaction\n    -    fails instead of deleting the new value. Existing callers that pass NULL\n    -    retain the unconditional behavior.\n    +    Let callers provide an optional array of expected old OIDs in parallel with\n    +    the refname list. When the array is provided, delete the ref at position N\n    +    only if it still points at the OID at position N. A null OID requests an\n    +    unconditional deletion for refs whose old value cannot be resolved, such as\n    +    broken refs.\n    +\n    +    Use REF_TRANSACTION_ALLOW_FAILURE when old OIDs are supplied. This retains\n    +    the helper's best-effort behavior: an old-OID mismatch rejects that deletion\n    +    while independent deletions in the batch can still proceed.\n     \n         Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n     \n    @@ bisect.c: int bisect_clean_state(void)\n      \tunlink_or_warn(git_path_bisect_log());\n     \n      ## builtin/branch.c ##\n    -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n    - \t\tfree(target);\n    +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n      \t}\n      \n    --\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n    -+\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n    + \tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n    +-\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n    ++\t    refs_delete_refs(get_main_ref_store(the_repository), NULL,\n     +\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n      \t\tret = 1;\n      \n    @@ refs.c\n      #include \"object.h\"\n      #include \"path.h\"\n     @@ refs.c: void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n    + \t}\n      }\n      \n    ++struct delete_refs_rejection_data {\n    ++\tint failures;\n    ++};\n    ++\n    ++static void delete_refs_rejection_handler(const char *refname,\n    ++\t\t\t\t\t  const struct object_id *old_oid UNUSED,\n    ++\t\t\t\t\t  const struct object_id *new_oid UNUSED,\n    ++\t\t\t\t\t  const char *old_target UNUSED,\n    ++\t\t\t\t\t  const char *new_target UNUSED,\n    ++\t\t\t\t\t  enum ref_transaction_error err,\n    ++\t\t\t\t\t  const char *details,\n    ++\t\t\t\t\t  void *cb_data)\n    ++{\n    ++\tstruct delete_refs_rejection_data *data = cb_data;\n    ++\n    ++\twarning(_(\"could not delete reference %s: %s\"), refname,\n    ++\t\tdetails ? details : ref_transaction_error_msg(err));\n    ++\tdata->failures = 1;\n    ++}\n    ++\n      int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n     -\t\t     struct string_list *refnames, unsigned int flags)\n     +\t\t     struct string_list *refnames,\n     +\t\t     const struct oid_array *old_oids,\n     +\t\t     unsigned int flags)\n      {\n    ++\tstruct delete_refs_rejection_data rejection_data = { 0 };\n      \tstruct ref_transaction *transaction;\n      \tstruct strbuf err = STRBUF_INIT;\n     -\tstruct string_list_item *item;\n    @@ refs.c: void ref_transaction_for_each_rejected_update(struct ref_transaction *tr\n      \tint ret = 0, failures = 0;\n      \tchar *msg;\n      \n    -+\tif (old_oids && old_oids->nr != refnames->nr)\n    -+\t\tBUG(\"refname and old OID counts do not match\");\n      \tif (!refnames->nr)\n      \t\treturn 0;\n    ++\tif (old_oids && old_oids->nr != refnames->nr)\n    ++\t\tBUG(\"refname and old OID counts do not match\");\n      \n      \tmsg = normalize_reflog_message(logmsg);\n      \n    @@ refs.c: void ref_transaction_for_each_rejected_update(struct ref_transaction *tr\n     -\t * individual updates can't fail, so we can pack all of the\n     -\t * updates into a single transaction.\n     -\t */\n    - \ttransaction = ref_store_transaction_begin(refs, 0, &err);\n    +-\ttransaction = ref_store_transaction_begin(refs, 0, &err);\n    ++\ttransaction = ref_store_transaction_begin(refs,\n    ++\t\t\told_oids ? REF_TRANSACTION_ALLOW_FAILURE : 0, &err);\n      \tif (!transaction) {\n      \t\tret = error(\"%s\", err.buf);\n      \t\tgoto out;\n    @@ refs.c: void ref_transaction_for_each_rejected_update(struct ref_transaction *tr\n      \t\tif (ret) {\n      \t\t\twarning(_(\"could not delete reference %s: %s\"),\n      \t\t\t\titem->string, err.buf);\n    +@@ refs.c: int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n    + \t\t\t      refnames->items[0].string, err.buf);\n    + \t\telse\n    + \t\t\terror(_(\"could not delete references: %s\"), err.buf);\n    +-\t}\n    ++\t} else if (old_oids)\n    ++\t\tref_transaction_for_each_rejected_update(transaction,\n    ++\t\t\t\t\t\t delete_refs_rejection_handler,\n    ++\t\t\t\t\t\t &rejection_data);\n    + \n    + out:\n    ++\tif (rejection_data.failures)\n    ++\t\tfailures = 1;\n    + \tif (!ret && failures)\n    + \t\tret = -1;\n    + \tref_transaction_free(transaction);\n     \n      ## refs.h ##\n     @@\n    @@ refs.h: int refs_delete_ref(struct ref_store *refs, const char *msg,\n      /*\n     - * Delete the specified references. If there are any problems, emit\n     + * Delete the specified references. If old_oids is non-NULL, it must contain\n    -+ * an entry for each refname, in the same order. Each non-null entry is used\n    -+ * to verify the current value of the corresponding reference before deleting\n    -+ * it. A null entry disables verification for that reference.\n    ++ * an entry for each refname, in the same order. Each non-null OID is used to\n    ++ * verify the current value of the corresponding reference before deleting\n    ++ * it. A null OID requests an unconditional deletion, which allows callers to\n    ++ * include broken refs whose old value cannot be resolved.\n     + *\n     + * If there are any problems, emit\n       * errors but attempt to keep going (i.e., the deletes are not done in\n2:  e8b867f8e ! 2:  2065188aa branch, tag: retain old OIDs in batched deletions\n    @@ Commit message\n         delete_ref(). This prevented the command from deleting a ref that another\n         process had changed after it was inspected.\n     \n    -    The conversion to batched deletion dropped those old OIDs. Besides making the\n    -    deletions unconditional, this causes reference-transaction hooks to report\n    -    zero as both the old and new OID.\n    +    The conversion to batched deletion dropped those old OIDs. Besides making\n    +    the deletions unconditional, this causes reference-transaction hooks to\n    +    report zero as both the old and new OID.\n     \n         Both commands still resolve the old OIDs before starting the deletion. Pass\n    -    those values to refs_delete_refs(). This restores the old race protection and\n    -    lets hooks receive useful old values without adding any ref reads. If a ref\n    -    changes concurrently, the transaction fails and preserves the new value.\n    +    those values to refs_delete_refs(). This restores the old race protection\n    +    and lets hooks receive useful old values without adding ref reads. If a ref\n    +    changes concurrently, reject its deletion and preserve the new value.\n     \n         Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n     \n    @@ builtin/branch.c\n      #include \"remote.h\"\n      #include \"parse-options.h\"\n      #include \"branch.h\"\n    -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n    +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n      \tstruct strbuf bname = STRBUF_INIT;\n      \tenum interpret_branch_kind allowed_interpret;\n      \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n    @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n      \tstruct string_list_item *item;\n      \tint branch_name_pos;\n      \tconst char *fmt_remotes = \"refs/remotes/%s\";\n    -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n    +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n      \t\t}\n      \n      \t\titem = string_list_append(&refs_to_delete, name);\n     +\t\toid_array_append(&old_oids, &oid);\n    - \t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n    - \t\t\t\t    : (flags & REF_ISSYMREF) ? target\n    + \t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n    + \t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n      \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n    -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n    - \t}\n    +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n      \n    - \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n    + \tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n    + \t    refs_delete_refs(get_main_ref_store(the_repository), NULL,\n     -\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n     +\t\t\t     &refs_to_delete, &old_oids, REF_NO_DEREF))\n      \t\tret = 1;\n      \n      \tfor_each_string_list_item(item, &refs_to_delete) {\n    -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n    +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n      \t\tfree(describe_ref);\n      \t}\n      \tstring_list_clear(&refs_to_delete, 0);\n    @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n      \tstrbuf_release(&bname);\n     \n      ## builtin/tag.c ##\n    -@@ builtin/tag.c: static int delete_tags(const char **argv)\n    +@@ builtin/tag.c: static int for_each_tag_name(const char **argv, each_tag_name_fn fn,\n    + \treturn had_error;\n    + }\n    + \n    ++struct tags_to_delete {\n    ++\tstruct string_list refs;\n    ++\tstruct oid_array old_oids;\n    ++};\n    ++\n    + static int collect_tags(const char *name UNUSED, const char *ref,\n    + \t\t\tconst struct object_id *oid, void *cb_data)\n    + {\n    +-\tstruct string_list *ref_list = cb_data;\n    ++\tstruct tags_to_delete *data = cb_data;\n    ++\tstruct string_list_item *item;\n    + \n    +-\tstring_list_append(ref_list, ref);\n    +-\tref_list->items[ref_list->nr - 1].util = oiddup(oid);\n    ++\titem = string_list_append(&data->refs, ref);\n    ++\titem->util = oiddup(oid);\n    ++\toid_array_append(&data->old_oids, oid);\n    + \treturn 0;\n    + }\n    + \n    + static int delete_tags(const char **argv)\n      {\n      \tint result;\n    - \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n    -+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n    +-\tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n    ++\tstruct tags_to_delete data = {\n    ++\t\t.refs = STRING_LIST_INIT_DUP,\n    ++\t\t.old_oids = OID_ARRAY_INIT,\n    ++\t};\n      \tstruct string_list_item *item;\n      \n    - \tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n    -+\tfor_each_string_list_item(item, &refs_to_delete)\n    -+\t\toid_array_append(&old_oids, item->util);\n    +-\tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n    ++\tresult = for_each_tag_name(argv, collect_tags, &data);\n      \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n     -\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n    -+\t\t\t     &refs_to_delete, &old_oids, REF_NO_DEREF))\n    ++\t\t\t     &data.refs, &data.old_oids, REF_NO_DEREF))\n      \t\tresult = 1;\n      \n    - \tfor_each_string_list_item(item, &refs_to_delete) {\n    +-\tfor_each_string_list_item(item, &refs_to_delete) {\n    ++\tfor_each_string_list_item(item, &data.refs) {\n    + \t\tconst char *name = item->string;\n    + \t\tstruct object_id *oid = item->util;\n    + \t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name))\n     @@ builtin/tag.c: static int delete_tags(const char **argv)\n    + \n      \t\tfree(oid);\n      \t}\n    - \tstring_list_clear(&refs_to_delete, 0);\n    -+\toid_array_clear(&old_oids);\n    +-\tstring_list_clear(&refs_to_delete, 0);\n    ++\tstring_list_clear(&data.refs, 0);\n    ++\toid_array_clear(&data.old_oids);\n      \treturn result;\n      }\n      \n3:  6aebfac97 ! 3:  3f3062252 fetch, remote: retain old OIDs when pruning refs\n    @@ Commit message\n         fetch, remote: retain old OIDs when pruning refs\n     \n         get_stale_heads() records the current value of each stale local ref in its\n    -    new_oid member. The pruning paths discard that value and request unconditional\n    -    deletion, so reference-transaction hooks receive a null old OID.\n    +    new_oid member. The pruning paths discard that value and request\n    +    unconditional deletion, so reference-transaction hooks receive a null old\n    +    OID.\n     \n    -    Carry the recorded values into the deletion transactions. Besides giving the\n    -    hooks useful values, this stops a stale scan from deleting a ref that another\n    -    process updated before the transaction acquired its locks. A concurrent\n    -    change now makes the prune fail and preserves the new value.\n    +    Pass the recorded values into the deletion transactions. If a ref changes\n    +    after the stale scan, reject that deletion and preserve the new value.\n    +    Non-atomic pruning uses refs_delete_refs(), whose partial-failure mode still\n    +    deletes unaffected stale refs. An atomic fetch remains all-or-nothing.\n     \n    -    This reuses data collected while finding stale refs and therefore requires no\n    -    additional ref reads. Do not print deletion status when a non-atomic prune\n    -    fails its old-OID check.\n    +    Reuse values collected while finding stale refs, avoiding additional ref\n    +    reads. Avoid reporting deletion status when pruning encounters a rejected\n    +    update.\n     \n         Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n     \n    @@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n      \tstruct ref *ref, *stale_refs = get_stale_heads(rs, ref_map);\n      \tstruct strbuf err = STRBUF_INIT;\n      \tstruct string_list refnames = STRING_LIST_INIT_NODUP;\n    -+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n    - \n    +-\n     -\tfor (ref = stale_refs; ref; ref = ref->next)\n    -+\tfor (ref = stale_refs; ref; ref = ref->next) {\n    - \t\tstring_list_append(&refnames, ref->name);\n    -+\t\toid_array_append(&old_oids, &ref->new_oid);\n    -+\t}\n    +-\t\tstring_list_append(&refnames, ref->name);\n    ++\tstruct oid_array old_oids = OID_ARRAY_INIT;\n      \n      \tif (!dry_run) {\n      \t\tif (transaction) {\n      \t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n     -\t\t\t\tresult = ref_transaction_delete(transaction, ref->name, NULL,\n    +-\t\t\t\t\t\t\t\tNULL, 0, \"fetch: prune\", &err);\n     +\t\t\t\tresult = ref_transaction_delete(transaction, ref->name,\n    -+\t\t\t\t\t\t\t&ref->new_oid,\n    - \t\t\t\t\t\t\t\tNULL, 0, \"fetch: prune\", &err);\n    ++\t\t\t\t\t\t\t&ref->new_oid, NULL, 0,\n    ++\t\t\t\t\t\t\t\"fetch: prune\", &err);\n      \t\t\t\tif (result)\n      \t\t\t\t\tgoto cleanup;\n    -@@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n    + \t\t\t}\n      \t\t} else {\n    ++\t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n    ++\t\t\t\tstring_list_append(&refnames, ref->name);\n    ++\t\t\t\toid_array_append(&old_oids, &ref->new_oid);\n    ++\t\t\t}\n      \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n      \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n     -\t\t\t\t\t\t  NULL, 0);\n    @@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n      \t}\n      \n      \tif (verbosity >= 0) {\n    + \t\tint summary_width = transport_summary_width(stale_refs);\n    + \n    ++\t\tif (!refnames.nr)\n    ++\t\t\tfor (ref = stale_refs; ref; ref = ref->next)\n    ++\t\t\t\tstring_list_append(&refnames, ref->name);\n    + \t\tfor (ref = stale_refs; ref; ref = ref->next) {\n    + \t\t\tdisplay_ref_update(display_state, '-', _(\"[deleted]\"), NULL,\n    + \t\t\t\t\t   _(\"(none)\"), ref->name,\n     @@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n      \n      cleanup:\n    @@ builtin/remote.c: static int prune_remote(const char *remote, int dry_run)\n      \n     -\tfor_each_string_list_item(item, &states.stale)\n     -\t\tstring_list_append(&refs_to_prune, item->util);\n    +-\tstring_list_sort(&refs_to_prune);\n     +\tfor_each_string_list_item(item, &states.stale) {\n     +\t\tstruct stale_ref *stale_ref = item->util;\n    -+\t\tstruct string_list_item *to_prune;\n     +\n    -+\t\tto_prune = string_list_append(&refs_to_prune, stale_ref->name);\n    -+\t\tto_prune->util = &stale_ref->oid;\n    ++\t\tstring_list_append(&refs_to_prune, stale_ref->name);\n    ++\t\toid_array_append(&old_oids, &stale_ref->oid);\n     +\t}\n    - \tstring_list_sort(&refs_to_prune);\n    -+\tfor_each_string_list_item(item, &refs_to_prune)\n    -+\t\toid_array_append(&old_oids, item->util);\n      \n     -\tif (!dry_run)\n     +\tif (!dry_run) {\n    @@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'branch deletion rejects a\n      '\n      \n     +test_expect_success 'hook gets old values when pruning remote refs' '\n    -+\ttest_create_repo empty.git --bare &&\n    -+\ttest_create_repo prune &&\n    -+\tgit -C prune remote add origin ../empty.git &&\n    -+\ttest_commit -C prune one &&\n    -+\tone=$(git -C prune rev-parse HEAD) &&\n    -+\ttest_commit -C prune two &&\n    -+\ttwo=$(git -C prune rev-parse HEAD) &&\n    -+\tgit -C prune update-ref refs/remotes/origin/remote-prune-z \"$one\" &&\n    -+\tgit -C prune update-ref refs/remotes/origin/remote-prune-a \"$two\" &&\n    -+\tgit -C prune pack-refs --all &&\n    ++\ttest_when_finished \"rm -rf empty.git prune\" &&\n    ++\tgit init --bare empty.git &&\n    ++\tgit init prune &&\n    ++\t(\n    ++\t\tcd prune &&\n    ++\t\tgit remote add origin ../empty.git &&\n    ++\t\tgit commit --allow-empty -m one &&\n    ++\t\tone=$(git rev-parse HEAD) &&\n    ++\t\tgit commit --allow-empty -m two &&\n    ++\t\ttwo=$(git rev-parse HEAD) &&\n    ++\t\tgit update-ref refs/remotes/origin/remote-prune-z \"$one\" &&\n    ++\t\tgit update-ref refs/remotes/origin/remote-prune-a \"$two\"\n    ++\t) &&\n     +\ttest_hook -C prune reference-transaction <<-\\EOF &&\n     +\t\tif test \"$1\" = committed\n     +\t\tthen\n    @@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'branch deletion rejects a\n     +\tEOF\n     +\t(\n     +\t\tcd prune &&\n    ++\t\tone=$(git rev-parse HEAD^) &&\n    ++\t\ttwo=$(git rev-parse HEAD) &&\n     +\t\tgit remote prune origin &&\n     +\t\tgit update-ref refs/remotes/origin/fetch-prune \"$one\" &&\n     +\t\tgit fetch --prune origin &&\n    @@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'branch deletion rejects a\n     +\n     +test_expect_success 'remote prune rejects a concurrent update' '\n     +\ttest_when_finished \"rm -rf race-empty.git race-prune\" &&\n    -+\ttest_create_repo race-empty.git --bare &&\n    -+\ttest_create_repo race-prune &&\n    -+\ttest_commit -C race-prune one &&\n    -+\tone=$(git -C race-prune rev-parse HEAD) &&\n    -+\ttest_commit -C race-prune two &&\n    -+\ttwo=$(git -C race-prune rev-parse HEAD) &&\n    -+\tgit -C race-prune remote add origin ../race-empty.git &&\n    -+\tgit -C race-prune update-ref refs/remotes/origin/race \"$one\" &&\n    ++\tgit init --bare race-empty.git &&\n    ++\tgit init race-prune &&\n    ++\t(\n    ++\t\tcd race-prune &&\n    ++\t\tgit commit --allow-empty -m one &&\n    ++\t\tone=$(git rev-parse HEAD) &&\n    ++\t\tgit commit --allow-empty -m two &&\n    ++\t\ttwo=$(git rev-parse HEAD) &&\n    ++\t\tgit remote add origin ../race-empty.git &&\n    ++\t\tgit update-ref refs/remotes/origin/race \"$one\" &&\n    ++\t\tgit update-ref refs/remotes/origin/other \"$one\"\n    ++\t) &&\n     +\ttest_hook -C race-prune reference-transaction <<-\\EOF &&\n     +\t\tmarker=$(git rev-parse --git-path prune-race-once)\n     +\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n    @@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'branch deletion rejects a\n     +\t\tfi\n     +\t\texit 0\n     +\tEOF\n    -+\ttest_must_fail git -C race-prune remote prune origin >out 2>err &&\n    -+\ttest \"$two\" = \"$(git -C race-prune rev-parse refs/remotes/origin/race)\" &&\n    -+\t! grep \"\\[pruned\\]\" out\n    ++\t(\n    ++\t\tcd race-prune &&\n    ++\t\ttwo=$(git rev-parse HEAD) &&\n    ++\t\ttest_must_fail git remote prune origin >out 2>err &&\n    ++\t\ttest_cmp_rev \"$two\" refs/remotes/origin/race &&\n    ++\t\ttest_must_fail git rev-parse --verify refs/remotes/origin/other &&\n    ++\t\ttest_grep ! \"\\[pruned\\]\" out\n    ++\t)\n     +'\n     +\n      test_expect_success 'hook allows updating ref if successful' '\n\nbase-commit: d38352cd43ab9745686d697872408bc3249a153f\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"552977","messageId":"3315d5f47ad7d8bcdbeda90b161606507c7040ea.1790079917.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790079917.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v3 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-22T12:26:07Z","receivedAt":"2026-09-22T12:26:14Z","isPatch":true,"body":"refs_delete_refs() performs unconditional deletions, so callers cannot\npreserve old values that they have already resolved. Consequently,\nreference-transaction hooks see a null old OID.\n\nLet callers provide an optional array of expected old OIDs in parallel with\nthe refname list. When the array is provided, delete the ref at position N\nonly if it still points at the OID at position N. A null OID requests an\nunconditional deletion for refs whose old value cannot be resolved, such as\nbroken refs.\n\nUse REF_TRANSACTION_ALLOW_FAILURE when old OIDs are supplied. This retains\nthe helper's best-effort behavior: an old-OID mismatch rejects that deletion\nwhile independent deletions in the batch can still proceed.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n bisect.c                  |  2 +-\n builtin/branch.c          |  3 ++-\n builtin/fetch.c           |  2 +-\n builtin/remote.c          |  5 ++--\n builtin/tag.c             |  3 ++-\n refs.c                    | 54 +++++++++++++++++++++++++++++++--------\n refs.h                    | 13 ++++++++--\n t/helper/test-ref-store.c |  2 +-\n 8 files changed, 64 insertions(+), 20 deletions(-)\n\ndiff --git a/bisect.c b/bisect.c\nindex 9cbb3dc67..931a80098 100644\n--- a/bisect.c\n+++ b/bisect.c\n@@ -1206,7 +1206,7 @@ int bisect_clean_state(void)\n \tstring_list_append(&refs_for_removal, \"BISECT_EXPECTED_REV\");\n \tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t  \"bisect: remove\", &refs_for_removal,\n-\t\t\t\t  REF_NO_DEREF);\n+\t\t\t\t  NULL, REF_NO_DEREF);\n \tstring_list_clear(&refs_for_removal, 0);\n \tunlink_or_warn(git_path_bisect_ancestors_ok());\n \tunlink_or_warn(git_path_bisect_log());\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex a613148fc..c9f259d04 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -351,7 +351,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t}\n \n \tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n-\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL,\n+\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex 533fdfe7d..b662216bf 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -1486,7 +1486,7 @@ static int prune_refs(struct display_state *display_state,\n \t\t} else {\n \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n-\t\t\t\t\t\t  0);\n+\t\t\t\t\t\t  NULL, 0);\n \t\t}\n \t}\n \ndiff --git a/builtin/remote.c b/builtin/remote.c\nindex de989ea3b..13d3cc52d 100644\n--- a/builtin/remote.c\n+++ b/builtin/remote.c\n@@ -1073,7 +1073,7 @@ static int rm(int argc, const char **argv, const char *prefix,\n \tif (!result)\n \t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t  \"remote: remove\", &branches,\n-\t\t\t\t\t  REF_NO_DEREF);\n+\t\t\t\t\t  NULL, REF_NO_DEREF);\n \tstring_list_clear(&branches, 0);\n \n \tif (skipped.nr) {\n@@ -1645,7 +1645,8 @@ static int prune_remote(const char *remote, int dry_run)\n \n \tif (!dry_run)\n \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n-\t\t\t\t\t   \"remote: prune\", &refs_to_prune, 0);\n+\t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n+\t\t\t\t\t   NULL, 0);\n \n \tfor_each_string_list_item(item, &states.stale) {\n \t\tconst char *refname = item->util;\ndiff --git a/builtin/tag.c b/builtin/tag.c\nindex 06c125b53..40874a292 100644\n--- a/builtin/tag.c\n+++ b/builtin/tag.c\n@@ -122,7 +122,8 @@ static int delete_tags(const char **argv)\n \tstruct string_list_item *item;\n \n \tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n+\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n \t\tresult = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\ndiff --git a/refs.c b/refs.c\nindex 92d5df5b7..1e0f432ed 100644\n--- a/refs.c\n+++ b/refs.c\n@@ -16,6 +16,7 @@\n #include \"refs/refs-internal.h\"\n #include \"hook.h\"\n #include \"object-name.h\"\n+#include \"oid-array.h\"\n #include \"odb.h\"\n #include \"object.h\"\n #include \"path.h\"\n@@ -3069,34 +3070,60 @@ void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n \t}\n }\n \n+struct delete_refs_rejection_data {\n+\tint failures;\n+};\n+\n+static void delete_refs_rejection_handler(const char *refname,\n+\t\t\t\t\t  const struct object_id *old_oid UNUSED,\n+\t\t\t\t\t  const struct object_id *new_oid UNUSED,\n+\t\t\t\t\t  const char *old_target UNUSED,\n+\t\t\t\t\t  const char *new_target UNUSED,\n+\t\t\t\t\t  enum ref_transaction_error err,\n+\t\t\t\t\t  const char *details,\n+\t\t\t\t\t  void *cb_data)\n+{\n+\tstruct delete_refs_rejection_data *data = cb_data;\n+\n+\twarning(_(\"could not delete reference %s: %s\"), refname,\n+\t\tdetails ? details : ref_transaction_error_msg(err));\n+\tdata->failures = 1;\n+}\n+\n int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n-\t\t     struct string_list *refnames, unsigned int flags)\n+\t\t     struct string_list *refnames,\n+\t\t     const struct oid_array *old_oids,\n+\t\t     unsigned int flags)\n {\n+\tstruct delete_refs_rejection_data rejection_data = { 0 };\n \tstruct ref_transaction *transaction;\n \tstruct strbuf err = STRBUF_INIT;\n-\tstruct string_list_item *item;\n+\tsize_t i;\n \tint ret = 0, failures = 0;\n \tchar *msg;\n \n \tif (!refnames->nr)\n \t\treturn 0;\n+\tif (old_oids && old_oids->nr != refnames->nr)\n+\t\tBUG(\"refname and old OID counts do not match\");\n \n \tmsg = normalize_reflog_message(logmsg);\n \n-\t/*\n-\t * Since we don't check the references' old_oids, the\n-\t * individual updates can't fail, so we can pack all of the\n-\t * updates into a single transaction.\n-\t */\n-\ttransaction = ref_store_transaction_begin(refs, 0, &err);\n+\ttransaction = ref_store_transaction_begin(refs,\n+\t\t\told_oids ? REF_TRANSACTION_ALLOW_FAILURE : 0, &err);\n \tif (!transaction) {\n \t\tret = error(\"%s\", err.buf);\n \t\tgoto out;\n \t}\n \n-\tfor_each_string_list_item(item, refnames) {\n+\tfor (i = 0; i < refnames->nr; i++) {\n+\t\tstruct string_list_item *item = &refnames->items[i];\n+\t\tconst struct object_id *old_oid = old_oids ? &old_oids->oid[i] : NULL;\n+\n+\t\tif (old_oid && is_null_oid(old_oid))\n+\t\t\told_oid = NULL;\n \t\tret = ref_transaction_delete(transaction, item->string,\n-\t\t\t\t\t     NULL, NULL, flags, msg, &err);\n+\t\t\t\t\t     old_oid, NULL, flags, msg, &err);\n \t\tif (ret) {\n \t\t\twarning(_(\"could not delete reference %s: %s\"),\n \t\t\t\titem->string, err.buf);\n@@ -3112,9 +3139,14 @@ int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n \t\t\t      refnames->items[0].string, err.buf);\n \t\telse\n \t\t\terror(_(\"could not delete references: %s\"), err.buf);\n-\t}\n+\t} else if (old_oids)\n+\t\tref_transaction_for_each_rejected_update(transaction,\n+\t\t\t\t\t\t delete_refs_rejection_handler,\n+\t\t\t\t\t\t &rejection_data);\n \n out:\n+\tif (rejection_data.failures)\n+\t\tfailures = 1;\n \tif (!ret && failures)\n \t\tret = -1;\n \tref_transaction_free(transaction);\ndiff --git a/refs.h b/refs.h\nindex 9979446d1..3a7aacefe 100644\n--- a/refs.h\n+++ b/refs.h\n@@ -9,6 +9,7 @@\n struct fsck_options;\n struct object_id;\n struct ref_store;\n+struct oid_array;\n struct strbuf;\n struct string_list;\n struct string_list_item;\n@@ -623,13 +624,21 @@ int refs_delete_ref(struct ref_store *refs, const char *msg,\n \t\t    unsigned int flags);\n \n /*\n- * Delete the specified references. If there are any problems, emit\n+ * Delete the specified references. If old_oids is non-NULL, it must contain\n+ * an entry for each refname, in the same order. Each non-null OID is used to\n+ * verify the current value of the corresponding reference before deleting\n+ * it. A null OID requests an unconditional deletion, which allows callers to\n+ * include broken refs whose old value cannot be resolved.\n+ *\n+ * If there are any problems, emit\n  * errors but attempt to keep going (i.e., the deletes are not done in\n  * an all-or-nothing transaction). msg and flags are passed through to\n  * ref_transaction_delete().\n  */\n int refs_delete_refs(struct ref_store *refs, const char *msg,\n-\t\t     struct string_list *refnames, unsigned int flags);\n+\t\t     struct string_list *refnames,\n+\t\t     const struct oid_array *old_oids,\n+\t\t     unsigned int flags);\n \n /** Delete a reflog */\n int refs_delete_reflog(struct ref_store *refs, const char *refname);\ndiff --git a/t/helper/test-ref-store.c b/t/helper/test-ref-store.c\nindex db58f0058..6d6857cbd 100644\n--- a/t/helper/test-ref-store.c\n+++ b/t/helper/test-ref-store.c\n@@ -132,7 +132,7 @@ static int cmd_delete_refs(struct ref_store *refs, const char **argv)\n \twhile (*argv)\n \t\tstring_list_append(&refnames, *argv++);\n \n-\tresult = refs_delete_refs(refs, msg, &refnames, flags);\n+\tresult = refs_delete_refs(refs, msg, &refnames, NULL, flags);\n \tstring_list_clear(&refnames, 0);\n \treturn result;\n }\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"552978","messageId":"2065188aabecd857456b3f2d791edf9f7c8c6c6a.1790079917.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790079917.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v3 2/3] branch, tag: retain old OIDs in batched deletions","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-22T12:26:08Z","receivedAt":"2026-09-22T12:26:15Z","isPatch":true,"body":"Before 8198907795 (use delete_refs when deleting tags or branches,\n2021-01-21), branch and tag deletion passed each resolved old OID to\ndelete_ref(). This prevented the command from deleting a ref that another\nprocess had changed after it was inspected.\n\nThe conversion to batched deletion dropped those old OIDs. Besides making\nthe deletions unconditional, this causes reference-transaction hooks to\nreport zero as both the old and new OID.\n\nBoth commands still resolve the old OIDs before starting the deletion. Pass\nthose values to refs_delete_refs(). This restores the old race protection\nand lets hooks receive useful old values without adding ref reads. If a ref\nchanges concurrently, reject its deletion and preserve the new value.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n builtin/branch.c                 |  6 ++++-\n builtin/tag.c                    | 27 ++++++++++++++------\n t/t1416-ref-transaction-hooks.sh | 44 ++++++++++++++++++++++++++++++++\n 3 files changed, 68 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c9f259d04..f222a2644 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -16,6 +16,7 @@\n #include \"commit.h\"\n #include \"gettext.h\"\n #include \"object-name.h\"\n+#include \"oid-array.h\"\n #include \"remote.h\"\n #include \"parse-options.h\"\n #include \"branch.h\"\n@@ -248,6 +249,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \tstruct string_list_item *item;\n \tint branch_name_pos;\n \tconst char *fmt_remotes = \"refs/remotes/%s\";\n@@ -342,6 +344,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n+\t\toid_array_append(&old_oids, &oid);\n \t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n \t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n@@ -352,7 +355,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \n \tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n \t    refs_delete_refs(get_main_ref_store(the_repository), NULL,\n-\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n+\t\t\t     &refs_to_delete, &old_oids, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n@@ -377,6 +380,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(describe_ref);\n \t}\n \tstring_list_clear(&refs_to_delete, 0);\n+\toid_array_clear(&old_oids);\n \n \tfree(name);\n \tstrbuf_release(&bname);\ndiff --git a/builtin/tag.c b/builtin/tag.c\nindex 40874a292..32b70c369 100644\n--- a/builtin/tag.c\n+++ b/builtin/tag.c\n@@ -105,28 +105,38 @@ static int for_each_tag_name(const char **argv, each_tag_name_fn fn,\n \treturn had_error;\n }\n \n+struct tags_to_delete {\n+\tstruct string_list refs;\n+\tstruct oid_array old_oids;\n+};\n+\n static int collect_tags(const char *name UNUSED, const char *ref,\n \t\t\tconst struct object_id *oid, void *cb_data)\n {\n-\tstruct string_list *ref_list = cb_data;\n+\tstruct tags_to_delete *data = cb_data;\n+\tstruct string_list_item *item;\n \n-\tstring_list_append(ref_list, ref);\n-\tref_list->items[ref_list->nr - 1].util = oiddup(oid);\n+\titem = string_list_append(&data->refs, ref);\n+\titem->util = oiddup(oid);\n+\toid_array_append(&data->old_oids, oid);\n \treturn 0;\n }\n \n static int delete_tags(const char **argv)\n {\n \tint result;\n-\tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n+\tstruct tags_to_delete data = {\n+\t\t.refs = STRING_LIST_INIT_DUP,\n+\t\t.old_oids = OID_ARRAY_INIT,\n+\t};\n \tstruct string_list_item *item;\n \n-\tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n+\tresult = for_each_tag_name(argv, collect_tags, &data);\n \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n-\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n+\t\t\t     &data.refs, &data.old_oids, REF_NO_DEREF))\n \t\tresult = 1;\n \n-\tfor_each_string_list_item(item, &refs_to_delete) {\n+\tfor_each_string_list_item(item, &data.refs) {\n \t\tconst char *name = item->string;\n \t\tstruct object_id *oid = item->util;\n \t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name))\n@@ -136,7 +146,8 @@ static int delete_tags(const char **argv)\n \n \t\tfree(oid);\n \t}\n-\tstring_list_clear(&refs_to_delete, 0);\n+\tstring_list_clear(&data.refs, 0);\n+\toid_array_clear(&data.old_oids);\n \treturn result;\n }\n \ndiff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\nindex 4fe9d9b23..01b5ba8c4 100755\n--- a/t/t1416-ref-transaction-hooks.sh\n+++ b/t/t1416-ref-transaction-hooks.sh\n@@ -14,6 +14,50 @@ test_expect_success setup '\n \tPOST_OID=$(git rev-parse POST)\n '\n \n+test_expect_success 'hook gets old values for batched branch/tag deletion' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\tgit branch to-delete PRE &&\n+\tgit tag delete-tag POST &&\n+\tgit pack-refs --all &&\n+\ttest_hook reference-transaction <<-\\EOF &&\n+\t\tif test \"$1\" = committed\n+\t\tthen\n+\t\t\t# Ignore backend-internal zero-to-zero records.\n+\t\t\twhile read -r old new ref\n+\t\t\tdo\n+\t\t\t\tcase \"$old\" in\n+\t\t\t\t*[!0]*)\n+\t\t\t\t\techo \"$old $new $ref\"\n+\t\t\t\t\t;;\n+\t\t\t\tesac\n+\t\t\tdone >>actual\n+\t\tfi\n+\tEOF\n+\tcat >expect <<-EOF &&\n+\t\t$PRE_OID $ZERO_OID refs/heads/to-delete\n+\t\t$POST_OID $ZERO_OID refs/tags/delete-tag\n+\tEOF\n+\tgit branch -D to-delete &&\n+\tgit tag -d delete-tag &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'branch deletion rejects a concurrent update' '\n+\tgit branch delete-race PRE &&\n+\ttest_hook reference-transaction <<-\\EOF &&\n+\t\tmarker=$(git rev-parse --git-path delete-race-once)\n+\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n+\t\tthen\n+\t\t\t>\"$marker\"\n+\t\t\tgit update-ref refs/heads/delete-race POST\n+\t\tfi\n+\t\texit 0\n+\tEOF\n+\ttest_must_fail git branch -D delete-race 2>err &&\n+\ttest_grep \"is at $POST_OID but expected $PRE_OID\" err &&\n+\ttest_cmp_rev POST refs/heads/delete-race\n+'\n+\n test_expect_success 'hook allows updating ref if successful' '\n \tgit reset --hard PRE &&\n \ttest_hook reference-transaction <<-\\EOF &&\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"552979","messageId":"3f3062252ac1aa057b9ee9a2dd9892e629ba7a82.1790079917.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790079917.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v3 3/3] fetch, remote: retain old OIDs when pruning refs","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-22T12:26:09Z","receivedAt":"2026-09-22T12:26:17Z","isPatch":true,"body":"get_stale_heads() records the current value of each stale local ref in its\nnew_oid member. The pruning paths discard that value and request\nunconditional deletion, so reference-transaction hooks receive a null old\nOID.\n\nPass the recorded values into the deletion transactions. If a ref changes\nafter the stale scan, reject that deletion and preserve the new value.\nNon-atomic pruning uses refs_delete_refs(), whose partial-failure mode still\ndeletes unaffected stale refs. An atomic fetch remains all-or-nothing.\n\nReuse values collected while finding stale refs, avoiding additional ref\nreads. Avoid reporting deletion status when pruning encounters a rejected\nupdate.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n builtin/fetch.c                  | 21 ++++++---\n builtin/remote.c                 | 34 +++++++++++---\n t/t1416-ref-transaction-hooks.sh | 80 ++++++++++++++++++++++++++++++++\n 3 files changed, 122 insertions(+), 13 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex b662216bf..2a59ac10f 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -1471,28 +1471,36 @@ static int prune_refs(struct display_state *display_state,\n \tstruct ref *ref, *stale_refs = get_stale_heads(rs, ref_map);\n \tstruct strbuf err = STRBUF_INIT;\n \tstruct string_list refnames = STRING_LIST_INIT_NODUP;\n-\n-\tfor (ref = stale_refs; ref; ref = ref->next)\n-\t\tstring_list_append(&refnames, ref->name);\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \n \tif (!dry_run) {\n \t\tif (transaction) {\n \t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n-\t\t\t\tresult = ref_transaction_delete(transaction, ref->name, NULL,\n-\t\t\t\t\t\t\t\tNULL, 0, \"fetch: prune\", &err);\n+\t\t\t\tresult = ref_transaction_delete(transaction, ref->name,\n+\t\t\t\t\t\t\t&ref->new_oid, NULL, 0,\n+\t\t\t\t\t\t\t\"fetch: prune\", &err);\n \t\t\t\tif (result)\n \t\t\t\t\tgoto cleanup;\n \t\t\t}\n \t\t} else {\n+\t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n+\t\t\t\tstring_list_append(&refnames, ref->name);\n+\t\t\t\toid_array_append(&old_oids, &ref->new_oid);\n+\t\t\t}\n \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n-\t\t\t\t\t\t  NULL, 0);\n+\t\t\t\t\t\t  &old_oids, 0);\n \t\t}\n+\t\tif (result)\n+\t\t\tgoto cleanup;\n \t}\n \n \tif (verbosity >= 0) {\n \t\tint summary_width = transport_summary_width(stale_refs);\n \n+\t\tif (!refnames.nr)\n+\t\t\tfor (ref = stale_refs; ref; ref = ref->next)\n+\t\t\t\tstring_list_append(&refnames, ref->name);\n \t\tfor (ref = stale_refs; ref; ref = ref->next) {\n \t\t\tdisplay_ref_update(display_state, '-', _(\"[deleted]\"), NULL,\n \t\t\t\t\t   _(\"(none)\"), ref->name,\n@@ -1506,6 +1514,7 @@ static int prune_refs(struct display_state *display_state,\n \n cleanup:\n \tstring_list_clear(&refnames, 0);\n+\toid_array_clear(&old_oids);\n \tstrbuf_release(&err);\n \tfree_refs(stale_refs);\n \treturn result;\ndiff --git a/builtin/remote.c b/builtin/remote.c\nindex 13d3cc52d..a99d18832 100644\n--- a/builtin/remote.c\n+++ b/builtin/remote.c\n@@ -17,6 +17,7 @@\n #include \"refs.h\"\n #include \"refspec.h\"\n #include \"odb.h\"\n+#include \"oid-array.h\"\n #include \"strvec.h\"\n #include \"commit-reach.h\"\n #include \"progress.h\"\n@@ -380,6 +381,11 @@ struct ref_states {\n \tint queried;\n };\n \n+struct stale_ref {\n+\tstruct object_id oid;\n+\tchar name[FLEX_ARRAY];\n+};\n+\n #define REF_STATES_INIT { \\\n \t.new_refs = STRING_LIST_INIT_DUP, \\\n \t.skipped = STRING_LIST_INIT_DUP, \\\n@@ -410,9 +416,13 @@ static int get_ref_states(const struct ref *remote_refs, struct ref_states *stat\n \t}\n \tstale_refs = get_stale_heads(&states->remote->fetch, fetch_map);\n \tfor (ref = stale_refs; ref; ref = ref->next) {\n+\t\tstruct stale_ref *stale_ref;\n \t\tstruct string_list_item *item =\n \t\t\tstring_list_append(&states->stale, abbrev_branch(ref->name));\n-\t\titem->util = xstrdup(ref->name);\n+\n+\t\tFLEX_ALLOC_STR(stale_ref, name, ref->name);\n+\t\toidcpy(&stale_ref->oid, &ref->new_oid);\n+\t\titem->util = stale_ref;\n \t}\n \tfree_refs(stale_refs);\n \tfree_refs(fetch_map);\n@@ -1627,6 +1637,7 @@ static int prune_remote(const char *remote, int dry_run)\n \tint result = 0;\n \tstruct ref_states states = REF_STATES_INIT;\n \tstruct string_list refs_to_prune = STRING_LIST_INIT_NODUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \tstruct string_list_item *item;\n \n \tget_remote_ref_states(remote, &states, GET_REF_STATES);\n@@ -1639,17 +1650,24 @@ static int prune_remote(const char *remote, int dry_run)\n \tprintf_ln(_(\"Pruning %s\"), remote);\n \tprintf_ln(_(\"URL: %s\"), states.remote->url.v[0]);\n \n-\tfor_each_string_list_item(item, &states.stale)\n-\t\tstring_list_append(&refs_to_prune, item->util);\n-\tstring_list_sort(&refs_to_prune);\n+\tfor_each_string_list_item(item, &states.stale) {\n+\t\tstruct stale_ref *stale_ref = item->util;\n+\n+\t\tstring_list_append(&refs_to_prune, stale_ref->name);\n+\t\toid_array_append(&old_oids, &stale_ref->oid);\n+\t}\n \n-\tif (!dry_run)\n+\tif (!dry_run) {\n \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n-\t\t\t\t\t   NULL, 0);\n+\t\t\t\t\t   &old_oids, 0);\n+\t\tif (result)\n+\t\t\tgoto cleanup;\n+\t}\n \n \tfor_each_string_list_item(item, &states.stale) {\n-\t\tconst char *refname = item->util;\n+\t\tstruct stale_ref *stale_ref = item->util;\n+\t\tconst char *refname = stale_ref->name;\n \n \t\tif (dry_run)\n \t\t\tprintf_ln(_(\" * [would prune] %s\"),\n@@ -1662,7 +1680,9 @@ static int prune_remote(const char *remote, int dry_run)\n \trefs_warn_dangling_symrefs(get_main_ref_store(the_repository),\n \t\t\t\t   stdout, \" \", dry_run, &refs_to_prune);\n \n+cleanup:\n \tstring_list_clear(&refs_to_prune, 0);\n+\toid_array_clear(&old_oids);\n \tfree_remote_ref_states(&states);\n \treturn result;\n }\ndiff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\nindex 01b5ba8c4..8b52f2366 100755\n--- a/t/t1416-ref-transaction-hooks.sh\n+++ b/t/t1416-ref-transaction-hooks.sh\n@@ -58,6 +58,86 @@ test_expect_success 'branch deletion rejects a concurrent update' '\n \ttest_cmp_rev POST refs/heads/delete-race\n '\n \n+test_expect_success 'hook gets old values when pruning remote refs' '\n+\ttest_when_finished \"rm -rf empty.git prune\" &&\n+\tgit init --bare empty.git &&\n+\tgit init prune &&\n+\t(\n+\t\tcd prune &&\n+\t\tgit remote add origin ../empty.git &&\n+\t\tgit commit --allow-empty -m one &&\n+\t\tone=$(git rev-parse HEAD) &&\n+\t\tgit commit --allow-empty -m two &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\tgit update-ref refs/remotes/origin/remote-prune-z \"$one\" &&\n+\t\tgit update-ref refs/remotes/origin/remote-prune-a \"$two\"\n+\t) &&\n+\ttest_hook -C prune reference-transaction <<-\\EOF &&\n+\t\tif test \"$1\" = committed\n+\t\tthen\n+\t\t\t# Ignore backend-internal zero-to-zero records.\n+\t\t\twhile read -r old new ref\n+\t\t\tdo\n+\t\t\t\tcase \"$old\" in\n+\t\t\t\t*[!0]*)\n+\t\t\t\t\techo \"$old $new $ref\"\n+\t\t\t\t\t;;\n+\t\t\t\tesac\n+\t\t\tdone >>actual\n+\t\tfi\n+\tEOF\n+\t(\n+\t\tcd prune &&\n+\t\tone=$(git rev-parse HEAD^) &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\tgit remote prune origin &&\n+\t\tgit update-ref refs/remotes/origin/fetch-prune \"$one\" &&\n+\t\tgit fetch --prune origin &&\n+\t\tgit update-ref refs/remotes/origin/atomic-prune \"$one\" &&\n+\t\tgit fetch --atomic --prune origin &&\n+\t\tcat >expect <<-EOF &&\n+\t\t\t$two $ZERO_OID refs/remotes/origin/remote-prune-a\n+\t\t\t$one $ZERO_OID refs/remotes/origin/remote-prune-z\n+\t\t\t$one $ZERO_OID refs/remotes/origin/fetch-prune\n+\t\t\t$one $ZERO_OID refs/remotes/origin/atomic-prune\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success 'remote prune rejects a concurrent update' '\n+\ttest_when_finished \"rm -rf race-empty.git race-prune\" &&\n+\tgit init --bare race-empty.git &&\n+\tgit init race-prune &&\n+\t(\n+\t\tcd race-prune &&\n+\t\tgit commit --allow-empty -m one &&\n+\t\tone=$(git rev-parse HEAD) &&\n+\t\tgit commit --allow-empty -m two &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\tgit remote add origin ../race-empty.git &&\n+\t\tgit update-ref refs/remotes/origin/race \"$one\" &&\n+\t\tgit update-ref refs/remotes/origin/other \"$one\"\n+\t) &&\n+\ttest_hook -C race-prune reference-transaction <<-\\EOF &&\n+\t\tmarker=$(git rev-parse --git-path prune-race-once)\n+\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n+\t\tthen\n+\t\t\t>\"$marker\"\n+\t\t\tgit update-ref refs/remotes/origin/race HEAD\n+\t\tfi\n+\t\texit 0\n+\tEOF\n+\t(\n+\t\tcd race-prune &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\ttest_must_fail git remote prune origin >out 2>err &&\n+\t\ttest_cmp_rev \"$two\" refs/remotes/origin/race &&\n+\t\ttest_must_fail git rev-parse --verify refs/remotes/origin/other &&\n+\t\ttest_grep ! \"\\[pruned\\]\" out\n+\t)\n+'\n+\n test_expect_success 'hook allows updating ref if successful' '\n \tgit reset --hard PRE &&\n \ttest_hook reference-transaction <<-\\EOF &&\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"553007","messageId":"xmqqjyoddsjy.fsf@gitster.g","threadId":"66351","inReplyTo":"3315d5f47ad7d8bcdbeda90b161606507c7040ea.1790079917.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v3 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-22T18:55:45Z","receivedAt":"2026-09-22T18:55:48Z","isPatch":true,"body":"Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n\n> refs_delete_refs() performs unconditional deletions, so callers cannot\n> preserve old values that they have already resolved. Consequently,\n> reference-transaction hooks see a null old OID.\n>\n> Let callers provide an optional array of expected old OIDs in parallel with\n> the refname list. When the array is provided, delete the ref at position N\n> only if it still points at the OID at position N. A null OID requests an\n> unconditional deletion for refs whose old value cannot be resolved, such as\n> broken refs.\n>\n> Use REF_TRANSACTION_ALLOW_FAILURE when old OIDs are supplied. This retains\n> the helper's best-effort behavior: an old-OID mismatch rejects that deletion\n> while independent deletions in the batch can still proceed.\n\nThe history around this area seems to look like this (you can use\n\"git blame\" to figure this out yourself).\n\n * 98ffd5ff67 (delete_refs(): new function for the refs API,\n   2015-06-22) started the API function to allow multiple refs in\n   bulk.  The comment in refs.h that says refs_delete_refs() is not\n   done in an all-or-nothing transaction has been there ever since.\n\n * 2fb330ca72 (packed_delete_refs(): implement method, 2017-09-08)\n   started the \"because these operations cannot fail, we can afford\n   to run bulk deletion in a transaction without having to worry\n   about making it all-or-none\" for the packed backends.\n\n * e85e5dd78a (refs/files: use transactions to delete references,\n   2023-11-14) did the same for the files backends.\n\n * d6f8e72982 (refs: deduplicate code to delete references,\n   2023-11-14) consolidated the \"because these cannot fail, we can\n   afford to run bulk deletion in a transaction without making it\n   all-or-none\" codepaths between files and packed backends.\n\n * 23fc8e4f61 (refs: implement batch reference update support,\n   2025-04-08) introduced REF_TRANSACTION_ALLOW_FAILURE so that some\n   callers can take advantage of \"ref transactions\" as a batched\n   update mechanism, without having to roll everything back upon a\n   failure.\n\nDoesn't the above observation suggest us that we should always be\npassing to ref_store_transaction_begin() inside refs_delete_refs()\nthe REF_TRANSACTION_ALLOW_FAILURE flag?  I would say that it was a\nmissed clean-up opportunity at 23fc8e4f61 that we didn't do so back\nthen.\n\n> diff --git a/refs.c b/refs.c\n> index 92d5df5b7..1e0f432ed 100644\n> --- a/refs.c\n> +++ b/refs.c\n> @@ -3069,34 +3070,60 @@ void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n>  \t}\n>  }\n>  \n> +struct delete_refs_rejection_data {\n> +\tint failures;\n> +};\n\nThis makes readers expect that we would be counting failures, e.g.,\nthe caller may request deletion of 100 refs and we report 30 of them\nfailed to be deleted.\n\n> +static void delete_refs_rejection_handler(const char *refname,\n> +\t\t\t\t\t  const struct object_id *old_oid UNUSED,\n> +\t\t\t\t\t  const struct object_id *new_oid UNUSED,\n> +\t\t\t\t\t  const char *old_target UNUSED,\n> +\t\t\t\t\t  const char *new_target UNUSED,\n> +\t\t\t\t\t  enum ref_transaction_error err,\n> +\t\t\t\t\t  const char *details,\n> +\t\t\t\t\t  void *cb_data)\n> +{\n> +\tstruct delete_refs_rejection_data *data = cb_data;\n> +\n> +\twarning(_(\"could not delete reference %s: %s\"), refname,\n> +\t\tdetails ? details : ref_transaction_error_msg(err));\n> +\tdata->failures = 1;\n> +}\n\nBut that is not what is happening.  If we wanted to count, it is a\nsimple matter of incrementing the data->failures member instead of\nassigning 1 to it, of course.\n\nIt also might be annoying to see 30 warning messages in such a\ncase---or it may be what the caller is asking.  I cannot tell.  If\nwe wanted to squelch excessive warning messages, we could count and\ncut-off after N failures, of course.\n\n>  int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n> -\t\t     struct string_list *refnames, unsigned int flags)\n> +\t\t     struct string_list *refnames,\n> +\t\t     const struct oid_array *old_oids,\n> +\t\t     unsigned int flags)\n>  {\n> +\tstruct delete_refs_rejection_data rejection_data = { 0 };\n>  \tstruct ref_transaction *transaction;\n>  \tstruct strbuf err = STRBUF_INIT;\n> -\tstruct string_list_item *item;\n> +\tsize_t i;\n>  \tint ret = 0, failures = 0;\n>  \tchar *msg;\n>  \n>  \tif (!refnames->nr)\n>  \t\treturn 0;\n> +\tif (old_oids && old_oids->nr != refnames->nr)\n> +\t\tBUG(\"refname and old OID counts do not match\");\n>  \n>  \tmsg = normalize_reflog_message(logmsg);\n>  \n> -\t/*\n> -\t * Since we don't check the references' old_oids, the\n> -\t * individual updates can't fail, so we can pack all of the\n> -\t * updates into a single transaction.\n> -\t */\n> -\ttransaction = ref_store_transaction_begin(refs, 0, &err);\n> +\ttransaction = ref_store_transaction_begin(refs,\n> +\t\t\told_oids ? REF_TRANSACTION_ALLOW_FAILURE : 0, &err);\n\nThis is the conditional/unconditional REF_TRANSACTION_ALLOW_FAILURE\nI discussed earlier.\n\n>  \tif (!transaction) {\n>  \t\tret = error(\"%s\", err.buf);\n>  \t\tgoto out;\n>  \t}\n>  \n> -\tfor_each_string_list_item(item, refnames) {\n> +\tfor (i = 0; i < refnames->nr; i++) {\n> +\t\tstruct string_list_item *item = &refnames->items[i];\n> +\t\tconst struct object_id *old_oid = old_oids ? &old_oids->oid[i] : NULL;\n> +\n> +\t\tif (old_oid && is_null_oid(old_oid))\n> +\t\t\told_oid = NULL;\n\nI think there was a comment by another reviewer on the previous\nround around this area, which was never answered.  In general, it is\na polite thing to respond to review messages and see that your\nresponse is acknowledged before you send an updated patch.\n\nI _think_ the reason why you need to treat null_oid specially is\nbecause you are using a flat array of object names, not an array of\npointers to individual object names, but in that case, I wonder if\nref_transaction_delete() should be the one who pays attention to the\nNULL-ness of its old_oid parameter?  The current code does detect\nand reject (old_oid && is_null_oid(old_oid)) case, but I am not sure\nwhat we are gaining by that limitation.  Rather I wonder if the\nfirst two lines of the function should read more like\n\n                if (old_oid && is_null_oid(old_oid))\n        -\t\tBUG(\"delete called with old_oid set to zeros\");\n        +\t\told_oid = NULL;\n\nnot forcing the callers (like we see above) to do the same.\n\n> @@ -3112,9 +3139,14 @@ int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n>  \t\t\t      refnames->items[0].string, err.buf);\n>  \t\telse\n>  \t\t\terror(_(\"could not delete references: %s\"), err.buf);\n> -\t}\n> +\t} else if (old_oids)\n> +\t\tref_transaction_for_each_rejected_update(transaction,\n> +\t\t\t\t\t\t delete_refs_rejection_handler,\n> +\t\t\t\t\t\t &rejection_data);\n\nI personally feel that we should be weaning ourselves off of the\nassumption that presence of old_oids[] is the ONLY thing to cause\nrejection.  IOW, always call for-each-rejected-update here\nregardless of old_oids != NULL.\n\n>  out:\n> +\tif (rejection_data.failures)\n> +\t\tfailures = 1;\n\nThis is quite roundabout thing to do.  rejection_data.failures,\nunlike my initial assumption, is not counting but is either 0 or 1,\nso failures here is also either 0 or 1, and then ...\n\n>  \tif (!ret && failures)\n>  \t\tret = -1;\n\n... if we have the failures computed to non-zero, we make sure ret\nis not zero.  Shouldn't we at least get rid of the local variable\nfailures?\n\nAnd if a variable FOO is not counting the number of FOO, do not name\nit FOOs.  If it is a Boolean recording if we got FOOed, call it as\nsuch.  My preference in this code path is to actually count failures\nin the member \"int failures\" of rejection_data structure, but if we\nare not counting, then call it \"bool failed\", perhaps.\n\n\tout:\n\t\tif (!ret && rejection_data.failed)\n\t\t\tret = -1;\n\n"},{"id":"553008","messageId":"xmqqeceldrlw.fsf@gitster.g","threadId":"66351","inReplyTo":"3f3062252ac1aa057b9ee9a2dd9892e629ba7a82.1790079917.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v3 3/3] fetch, remote: retain old OIDs when pruning refs","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-22T19:16:11Z","receivedAt":"2026-09-22T19:16:14Z","isPatch":true,"body":"Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n\n>  \tif (!dry_run) {\n>  \t\tif (transaction) {\n>  \t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n> -\t\t\t\tresult = ref_transaction_delete(transaction, ref->name, NULL,\n> -\t\t\t\t\t\t\t\tNULL, 0, \"fetch: prune\", &err);\n> +\t\t\t\tresult = ref_transaction_delete(transaction, ref->name,\n> +\t\t\t\t\t\t\t&ref->new_oid, NULL, 0,\n> +\t\t\t\t\t\t\t\"fetch: prune\", &err);\n>  \t\t\t\tif (result)\n>  \t\t\t\t\tgoto cleanup;\n>  \t\t\t}\n>  \t\t} else {\n> +\t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n> +\t\t\t\tstring_list_append(&refnames, ref->name);\n> +\t\t\t\toid_array_append(&old_oids, &ref->new_oid);\n> +\t\t\t}\n>  \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n>  \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n> -\t\t\t\t\t\t  NULL, 0);\n> +\t\t\t\t\t\t  &old_oids, 0);\n>  \t\t}\n> +\t\tif (result)\n> +\t\t\tgoto cleanup;\n>  \t}\n\nHmph, I may not be reading the code correctly, but the last \"goto\ncleanup\" in the above block can happen when refs_delete_refs() call\nthat internally uses the best effort transaction sees an error.  If\nwe were about to prune 30 refs but failed to prune one of them, and\nif we are running with non-negative verbosity, don't we still want\nto make the \"[deleted]\" report for the 29 of them and possibly\nreport \"[failed to delete]\" for the one that failed?\n\n>  \n>  \tif (verbosity >= 0) {\n>  \t\tint summary_width = transport_summary_width(stale_refs);\n>  \n> +\t\tif (!refnames.nr)\n> +\t\t\tfor (ref = stale_refs; ref; ref = ref->next)\n> +\t\t\t\tstring_list_append(&refnames, ref->name);\n>  \t\tfor (ref = stale_refs; ref; ref = ref->next) {\n>  \t\t\tdisplay_ref_update(display_state, '-', _(\"[deleted]\"), NULL,\n>  \t\t\t\t\t   _(\"(none)\"), ref->name,\n\n> @@ -1639,17 +1650,24 @@ static int prune_remote(const char *remote, int dry_run)\n>  \tprintf_ln(_(\"Pruning %s\"), remote);\n>  \tprintf_ln(_(\"URL: %s\"), states.remote->url.v[0]);\n>  \n> -\tfor_each_string_list_item(item, &states.stale)\n> -\t\tstring_list_append(&refs_to_prune, item->util);\n> -\tstring_list_sort(&refs_to_prune);\n> +\tfor_each_string_list_item(item, &states.stale) {\n> +\t\tstruct stale_ref *stale_ref = item->util;\n> +\n> +\t\tstring_list_append(&refs_to_prune, stale_ref->name);\n> +\t\toid_array_append(&old_oids, &stale_ref->oid);\n> +\t}\n>  \n> -\tif (!dry_run)\n> +\tif (!dry_run) {\n>  \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n>  \t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n> -\t\t\t\t\t   NULL, 0);\n> +\t\t\t\t\t   &old_oids, 0);\n> +\t\tif (result)\n> +\t\t\tgoto cleanup;\n> +\t}\n\nDitto.  Beyond the post context of this hunk ... \n\n>  \tfor_each_string_list_item(item, &states.stale) {\n> -\t\tconst char *refname = item->util;\n> +\t\tstruct stale_ref *stale_ref = item->util;\n> +\t\tconst char *refname = stale_ref->name;\n>  \n>  \t\tif (dry_run)\n>  \t\t\tprintf_ln(_(\" * [would prune] %s\"),\n\n... around here is a code that reports \"* [pruned]\" for the ones\nthat we successfully removed, which is now ignored when even one of\nthe bulk removal fails.\n\n"},{"id":"553009","messageId":"CACQ=SRGf=cKQooiSQD+ZsG8tCAdHkCrxoW5vyPSnT=UMjSajmw@mail.gmail.com","threadId":"66351","inReplyTo":"xmqqjyoddsjy.fsf@gitster.g","subject":"Re: [PATCH v3 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-22T19:21:56Z","receivedAt":"2026-09-22T19:22:10Z","isPatch":true,"body":"On Tue, Sep 22, 2026 at 8:55 PM Junio C Hamano <gitster@pobox.com> wrote:\n\n> I think there was a comment by another reviewer on the previous\n> round around this area, which was never answered.  In general, it is\n> a polite thing to respond to review messages and see that your\n> response is acknowledged before you send an updated patch.\n\nI'm very sorry, I didn't check my email before submitting the patch.\nI'll take a look at this. By the way, I expected the review process to\nbe tough, but I'm starting to wonder if I'll ever get through it :).\nYou mentioned earlier that you could prepare a patch. Is that offer\nstill on the table?\n\nCheers,\nMaciej Ciemborowicz\n"},{"id":"553022","messageId":"cover.1790113781.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790079917.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v4 0/3] refs: report old OIDs for batched deletions","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-22T22:29:10Z","receivedAt":"2026-09-22T22:29:16Z","isPatch":true,"body":"Okay, I will keep trying. I believe this version addresses all comments\nfrom the previous round, and I hope I have not missed anything.\n\nThis follows up on the reference-transaction bug report at [1].\n\nThe reference-transaction hook receives zero as both the old and new OID\nwhen branch, tag, fetch, and remote delete refs through refs_delete_refs().\nThose callers already know the values that they selected for deletion.\n\nTeach refs_delete_refs() to accept a parallel array of expected old OIDs and\npass them into the transaction. Besides making hook records useful, this\nrestores conditional deletion for branch and tag and adds it to pruning\nwithout additional ref reads. Non-atomic batches preserve best-effort\nbehavior, while atomic fetches remain all-or-nothing.\n\nChanges since v3:\n\n * Rebase onto master at 3bc0341127 (Git 2.56-rc2).\n * Always use REF_TRANSACTION_ALLOW_FAILURE in refs_delete_refs(), including\n   unconditional batches, and always inspect rejected updates.\n * Count individual failures directly and remove the redundant failures\n   variable.\n * Treat a null old OID as an unconditional deletion in\n   ref_transaction_delete(), instead of requiring each caller to convert it.\n * Let refs_delete_refs() return the exact set of individually failed refs.\n * Continue reporting successful fetch and remote prune deletions after a\n   partial failure, while omitting rejected refs from deletion and dangling\n   symref reports.\n * Add coverage for partial fetch pruning as well as remote pruning.\n\nThe full test suite passes. The focused reference-transaction tests also\npass with SHA-1 and SHA-256 using both the files and reftable backends.\n\n[1] https://lore.kernel.org/git/CACQ=SRGTTdQ+dHXhN6F52dBv5KxZBRfk_Em2fvmEmGJDoB6oTg@mail.gmail.com/\n\nMaciej Ciemborowicz (3):\n  refs: allow callers to supply old OIDs for batch deletion\n  branch, tag: retain old OIDs in batched deletions\n  fetch, remote: retain old OIDs when pruning refs\n\n bisect.c                         |   2 +-\n builtin/branch.c                 |   7 +-\n builtin/fetch.c                  |  30 ++++--\n builtin/remote.c                 |  47 +++++++--\n builtin/tag.c                    |  28 ++++--\n refs.c                           |  67 ++++++++++---\n refs.h                           |  31 ++++--\n t/helper/test-ref-store.c        |   2 +-\n t/t1416-ref-transaction-hooks.sh | 160 +++++++++++++++++++++++++++++++\n 9 files changed, 324 insertions(+), 50 deletions(-)\n\nRange-diff against v3:\n1:  3315d5f47 ! 1:  f4a9d065c refs: allow callers to supply old OIDs for batch deletion\n    @@ Commit message\n         reference-transaction hooks see a null old OID.\n     \n         Let callers provide an optional array of expected old OIDs in parallel with\n    -    the refname list. When the array is provided, delete the ref at position N\n    -    only if it still points at the OID at position N. A null OID requests an\n    -    unconditional deletion for refs whose old value cannot be resolved, such as\n    -    broken refs.\n    +    the refname list. Delete the ref at position N only if it still points at\n    +    the OID at position N. Treat a null OID as an unconditional deletion in\n    +    ref_transaction_delete(), allowing callers to include broken refs whose old\n    +    value cannot be resolved.\n     \n    -    Use REF_TRANSACTION_ALLOW_FAILURE when old OIDs are supplied. This retains\n    -    the helper's best-effort behavior: an old-OID mismatch rejects that deletion\n    -    while independent deletions in the batch can still proceed.\n    +    refs_delete_refs() has always promised best-effort deletion. Always use\n    +    REF_TRANSACTION_ALLOW_FAILURE and report rejected updates so one failure\n    +    does not prevent independent refs in the batch from being deleted. Let\n    +    callers request the exact set of failed refs when they need to report\n    +    partial results. This also completes the conversion that was missed when\n    +    batched transaction failure support was introduced.\n     \n         Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n     \n    @@ bisect.c: int bisect_clean_state(void)\n      \tresult = refs_delete_refs(get_main_ref_store(the_repository),\n      \t\t\t\t  \"bisect: remove\", &refs_for_removal,\n     -\t\t\t\t  REF_NO_DEREF);\n    -+\t\t\t\t  NULL, REF_NO_DEREF);\n    ++\t\t\t\t  NULL, NULL, REF_NO_DEREF);\n      \tstring_list_clear(&refs_for_removal, 0);\n      \tunlink_or_warn(git_path_bisect_ancestors_ok());\n      \tunlink_or_warn(git_path_bisect_log());\n    @@ builtin/remote.c: static int rm(int argc, const char **argv, const char *prefix,\n      \t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n      \t\t\t\t\t  \"remote: remove\", &branches,\n     -\t\t\t\t\t  REF_NO_DEREF);\n    -+\t\t\t\t\t  NULL, REF_NO_DEREF);\n    ++\t\t\t\t\t  NULL, NULL, REF_NO_DEREF);\n      \tstring_list_clear(&branches, 0);\n      \n      \tif (skipped.nr) {\n    @@ refs.c\n      #include \"odb.h\"\n      #include \"object.h\"\n      #include \"path.h\"\n    +@@ refs.c: int ref_transaction_delete(struct ref_transaction *transaction,\n    + \t\t\t   struct strbuf *err)\n    + {\n    + \tif (old_oid && is_null_oid(old_oid))\n    +-\t\tBUG(\"delete called with old_oid set to zeros\");\n    ++\t\told_oid = NULL;\n    + \tif (old_oid && old_target)\n    + \t\tBUG(\"delete called with both old_oid and old_target set\");\n    + \tif (old_target && !(flags & REF_NO_DEREF))\n     @@ refs.c: void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n      \t}\n      }\n      \n     +struct delete_refs_rejection_data {\n     +\tint failures;\n    ++\tstruct string_list *failed_refs;\n     +};\n     +\n     +static void delete_refs_rejection_handler(const char *refname,\n    @@ refs.c: void ref_transaction_for_each_rejected_update(struct ref_transaction *tr\n     +\n     +\twarning(_(\"could not delete reference %s: %s\"), refname,\n     +\t\tdetails ? details : ref_transaction_error_msg(err));\n    -+\tdata->failures = 1;\n    ++\tdata->failures++;\n    ++\tif (data->failed_refs)\n    ++\t\tstring_list_insert(data->failed_refs, refname);\n     +}\n     +\n      int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n     -\t\t     struct string_list *refnames, unsigned int flags)\n     +\t\t     struct string_list *refnames,\n     +\t\t     const struct oid_array *old_oids,\n    ++\t\t     struct string_list *failed_refs,\n     +\t\t     unsigned int flags)\n      {\n    -+\tstruct delete_refs_rejection_data rejection_data = { 0 };\n    ++\tstruct delete_refs_rejection_data rejection_data = {\n    ++\t\t.failed_refs = failed_refs,\n    ++\t};\n      \tstruct ref_transaction *transaction;\n      \tstruct strbuf err = STRBUF_INIT;\n     -\tstruct string_list_item *item;\n    +-\tint ret = 0, failures = 0;\n     +\tsize_t i;\n    - \tint ret = 0, failures = 0;\n    ++\tint ret = 0;\n      \tchar *msg;\n      \n      \tif (!refnames->nr)\n      \t\treturn 0;\n     +\tif (old_oids && old_oids->nr != refnames->nr)\n     +\t\tBUG(\"refname and old OID counts do not match\");\n    ++\tif (failed_refs && !failed_refs->strdup_strings)\n    ++\t\tBUG(\"failed ref list does not duplicate strings\");\n      \n      \tmsg = normalize_reflog_message(logmsg);\n      \n    @@ refs.c: void ref_transaction_for_each_rejected_update(struct ref_transaction *tr\n     -\t */\n     -\ttransaction = ref_store_transaction_begin(refs, 0, &err);\n     +\ttransaction = ref_store_transaction_begin(refs,\n    -+\t\t\told_oids ? REF_TRANSACTION_ALLOW_FAILURE : 0, &err);\n    ++\t\t\t\t\t\t  REF_TRANSACTION_ALLOW_FAILURE, &err);\n      \tif (!transaction) {\n      \t\tret = error(\"%s\", err.buf);\n      \t\tgoto out;\n    @@ refs.c: void ref_transaction_for_each_rejected_update(struct ref_transaction *tr\n     +\t\tstruct string_list_item *item = &refnames->items[i];\n     +\t\tconst struct object_id *old_oid = old_oids ? &old_oids->oid[i] : NULL;\n     +\n    -+\t\tif (old_oid && is_null_oid(old_oid))\n    -+\t\t\told_oid = NULL;\n      \t\tret = ref_transaction_delete(transaction, item->string,\n     -\t\t\t\t\t     NULL, NULL, flags, msg, &err);\n     +\t\t\t\t\t     old_oid, NULL, flags, msg, &err);\n      \t\tif (ret) {\n      \t\t\twarning(_(\"could not delete reference %s: %s\"),\n      \t\t\t\titem->string, err.buf);\n    + \t\t\tstrbuf_reset(&err);\n    +-\t\t\tfailures = 1;\n    ++\t\t\trejection_data.failures++;\n    ++\t\t\tif (failed_refs)\n    ++\t\t\t\tstring_list_insert(failed_refs, item->string);\n    + \t\t}\n    + \t}\n    + \n     @@ refs.c: int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n    - \t\t\t      refnames->items[0].string, err.buf);\n      \t\telse\n      \t\t\terror(_(\"could not delete references: %s\"), err.buf);\n    --\t}\n    -+\t} else if (old_oids)\n    + \t}\n    ++\tif (!ret)\n     +\t\tref_transaction_for_each_rejected_update(transaction,\n    -+\t\t\t\t\t\t delete_refs_rejection_handler,\n    -+\t\t\t\t\t\t &rejection_data);\n    ++\t\t\t\t\t\t\t delete_refs_rejection_handler,\n    ++\t\t\t\t\t\t\t &rejection_data);\n      \n      out:\n    -+\tif (rejection_data.failures)\n    -+\t\tfailures = 1;\n    - \tif (!ret && failures)\n    +-\tif (!ret && failures)\n    ++\tif (!ret && rejection_data.failures)\n      \t\tret = -1;\n      \tref_transaction_free(transaction);\n    + \tstrbuf_release(&err);\n     \n      ## refs.h ##\n     @@\n    @@ refs.h: int refs_delete_ref(struct ref_store *refs, const char *msg,\n      \n      /*\n     - * Delete the specified references. If there are any problems, emit\n    +- * errors but attempt to keep going (i.e., the deletes are not done in\n    +- * an all-or-nothing transaction). msg and flags are passed through to\n    +- * ref_transaction_delete().\n     + * Delete the specified references. If old_oids is non-NULL, it must contain\n     + * an entry for each refname, in the same order. Each non-null OID is used to\n     + * verify the current value of the corresponding reference before deleting\n     + * it. A null OID requests an unconditional deletion, which allows callers to\n     + * include broken refs whose old value cannot be resolved.\n     + *\n    -+ * If there are any problems, emit\n    -  * errors but attempt to keep going (i.e., the deletes are not done in\n    -  * an all-or-nothing transaction). msg and flags are passed through to\n    -  * ref_transaction_delete().\n    ++ * If failed_refs is non-NULL, it must be initialized with\n    ++ * STRING_LIST_INIT_DUP. The names of individual updates that cannot be queued\n    ++ * or are rejected while processing the best-effort batch are inserted into\n    ++ * it. A transaction-wide failure is returned without populating the list.\n    ++ *\n    ++ * If there are any problems, emit errors but attempt to keep going (i.e.,\n    ++ * the deletes are not done in an all-or-nothing transaction). msg and flags\n    ++ * are passed through to ref_transaction_delete().\n       */\n      int refs_delete_refs(struct ref_store *refs, const char *msg,\n     -\t\t     struct string_list *refnames, unsigned int flags);\n     +\t\t     struct string_list *refnames,\n     +\t\t     const struct oid_array *old_oids,\n    ++\t\t     struct string_list *failed_refs,\n     +\t\t     unsigned int flags);\n      \n      /** Delete a reflog */\n      int refs_delete_reflog(struct ref_store *refs, const char *refname);\n    +@@ refs.h: int ref_transaction_create(struct ref_transaction *transaction,\n    + \t\t\t   struct strbuf *err);\n    + \n    + /*\n    +- * Add a reference deletion to transaction. If old_oid is non-NULL,\n    +- * then it holds the value that the reference should have had before\n    +- * the update (which must not be null_oid).\n    ++ * Add a reference deletion to transaction. If old_oid is non-NULL and not\n    ++ * null_oid, then it holds the value that the reference should have had before\n    ++ * the update. Passing null_oid is equivalent to passing NULL and disables the\n    ++ * old value check.\n    +  *\n    +  * See the above comment \"Reference transaction updates\" for more\n    +  * information.\n     \n      ## t/helper/test-ref-store.c ##\n     @@ t/helper/test-ref-store.c: static int cmd_delete_refs(struct ref_store *refs, const char **argv)\n    @@ t/helper/test-ref-store.c: static int cmd_delete_refs(struct ref_store *refs, co\n      \t\tstring_list_append(&refnames, *argv++);\n      \n     -\tresult = refs_delete_refs(refs, msg, &refnames, flags);\n    -+\tresult = refs_delete_refs(refs, msg, &refnames, NULL, flags);\n    ++\tresult = refs_delete_refs(refs, msg, &refnames, NULL, NULL, flags);\n      \tstring_list_clear(&refnames, 0);\n      \treturn result;\n      }\n2:  2065188aa ! 2:  918c97d2b branch, tag: retain old OIDs in batched deletions\n    @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki\n      \tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n      \t    refs_delete_refs(get_main_ref_store(the_repository), NULL,\n     -\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n    -+\t\t\t     &refs_to_delete, &old_oids, REF_NO_DEREF))\n    ++\t\t\t     &refs_to_delete, &old_oids, NULL, REF_NO_DEREF))\n      \t\tret = 1;\n      \n      \tfor_each_string_list_item(item, &refs_to_delete) {\n    @@ builtin/tag.c: static int for_each_tag_name(const char **argv, each_tag_name_fn\n     +\tresult = for_each_tag_name(argv, collect_tags, &data);\n      \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n     -\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n    -+\t\t\t     &data.refs, &data.old_oids, REF_NO_DEREF))\n    ++\t\t\t     &data.refs, &data.old_oids, NULL, REF_NO_DEREF))\n      \t\tresult = 1;\n      \n     -\tfor_each_string_list_item(item, &refs_to_delete) {\n3:  3f3062252 ! 3:  6f34853c7 fetch, remote: retain old OIDs when pruning refs\n    @@ Commit message\n         Non-atomic pruning uses refs_delete_refs(), whose partial-failure mode still\n         deletes unaffected stale refs. An atomic fetch remains all-or-nothing.\n     \n    -    Reuse values collected while finding stale refs, avoiding additional ref\n    -    reads. Avoid reporting deletion status when pruning encounters a rejected\n    -    update.\n    +    Continue reporting successful non-atomic deletions when another deletion is\n    +    rejected, but do not report the rejected ref as deleted or use it when\n    +    checking for newly dangling symrefs. Use the rejected-ref list returned by\n    +    refs_delete_refs() so reporting reflects the transaction result without\n    +    additional ref reads.\n     \n         Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n     \n    @@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n     -\n     -\tfor (ref = stale_refs; ref; ref = ref->next)\n     -\t\tstring_list_append(&refnames, ref->name);\n    ++\tstruct string_list deleted_refs = STRING_LIST_INIT_NODUP;\n    ++\tstruct string_list failed_refs = STRING_LIST_INIT_DUP;\n     +\tstruct oid_array old_oids = OID_ARRAY_INIT;\n      \n      \tif (!dry_run) {\n    @@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n     -\t\t\t\tresult = ref_transaction_delete(transaction, ref->name, NULL,\n     -\t\t\t\t\t\t\t\tNULL, 0, \"fetch: prune\", &err);\n     +\t\t\t\tresult = ref_transaction_delete(transaction, ref->name,\n    -+\t\t\t\t\t\t\t&ref->new_oid, NULL, 0,\n    -+\t\t\t\t\t\t\t\"fetch: prune\", &err);\n    ++\t\t\t\t\t\t\t\t&ref->new_oid, NULL, 0,\n    ++\t\t\t\t\t\t\t\t\"fetch: prune\", &err);\n      \t\t\t\tif (result)\n      \t\t\t\t\tgoto cleanup;\n      \t\t\t}\n    @@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n      \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n      \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n     -\t\t\t\t\t\t  NULL, 0);\n    -+\t\t\t\t\t\t  &old_oids, 0);\n    ++\t\t\t\t\t\t  &old_oids, &failed_refs, 0);\n    ++\t\t\tif (result && !failed_refs.nr)\n    ++\t\t\t\tgoto cleanup;\n      \t\t}\n    -+\t\tif (result)\n    -+\t\t\tgoto cleanup;\n      \t}\n      \n    - \tif (verbosity >= 0) {\n    +@@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n      \t\tint summary_width = transport_summary_width(stale_refs);\n      \n    -+\t\tif (!refnames.nr)\n    -+\t\t\tfor (ref = stale_refs; ref; ref = ref->next)\n    -+\t\t\t\tstring_list_append(&refnames, ref->name);\n      \t\tfor (ref = stale_refs; ref; ref = ref->next) {\n    ++\t\t\tif (string_list_has_string(&failed_refs, ref->name))\n    ++\t\t\t\tcontinue;\n    ++\n      \t\t\tdisplay_ref_update(display_state, '-', _(\"[deleted]\"), NULL,\n      \t\t\t\t\t   _(\"(none)\"), ref->name,\n    -@@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n    + \t\t\t\t\t   &ref->new_oid, &ref->old_oid,\n    + \t\t\t\t\t   summary_width);\n    ++\t\t\tstring_list_append(&deleted_refs, ref->name);\n    + \t\t}\n    +-\t\tstring_list_sort(&refnames);\n    ++\t\tstring_list_sort(&deleted_refs);\n    + \t\trefs_warn_dangling_symrefs(get_main_ref_store(the_repository),\n    +-\t\t\t\t\t   stderr, \"   \", dry_run, &refnames);\n    ++\t\t\t\t\t   stderr, \"   \", dry_run, &deleted_refs);\n    + \t}\n      \n      cleanup:\n      \tstring_list_clear(&refnames, 0);\n    ++\tstring_list_clear(&deleted_refs, 0);\n    ++\tstring_list_clear(&failed_refs, 0);\n     +\toid_array_clear(&old_oids);\n      \tstrbuf_release(&err);\n      \tfree_refs(stale_refs);\n    @@ builtin/remote.c: static int prune_remote(const char *remote, int dry_run)\n      \tint result = 0;\n      \tstruct ref_states states = REF_STATES_INIT;\n      \tstruct string_list refs_to_prune = STRING_LIST_INIT_NODUP;\n    ++\tstruct string_list pruned_refs = STRING_LIST_INIT_NODUP;\n    ++\tstruct string_list failed_refs = STRING_LIST_INIT_DUP;\n     +\tstruct oid_array old_oids = OID_ARRAY_INIT;\n      \tstruct string_list_item *item;\n      \n    @@ builtin/remote.c: static int prune_remote(const char *remote, int dry_run)\n      \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n      \t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n     -\t\t\t\t\t   NULL, 0);\n    -+\t\t\t\t\t   &old_oids, 0);\n    -+\t\tif (result)\n    ++\t\t\t\t\t   &old_oids, &failed_refs, 0);\n    ++\t\tif (result && !failed_refs.nr)\n     +\t\t\tgoto cleanup;\n     +\t}\n      \n    @@ builtin/remote.c: static int prune_remote(const char *remote, int dry_run)\n     -\t\tconst char *refname = item->util;\n     +\t\tstruct stale_ref *stale_ref = item->util;\n     +\t\tconst char *refname = stale_ref->name;\n    ++\n    ++\t\tif (string_list_has_string(&failed_refs, refname))\n    ++\t\t\tcontinue;\n      \n      \t\tif (dry_run)\n      \t\t\tprintf_ln(_(\" * [would prune] %s\"),\n     @@ builtin/remote.c: static int prune_remote(const char *remote, int dry_run)\n    + \t\telse\n    + \t\t\tprintf_ln(_(\" * [pruned] %s\"),\n    + \t\t\t       abbrev_ref(refname, \"refs/remotes/\"));\n    ++\t\tstring_list_append(&pruned_refs, refname);\n    + \t}\n    + \n      \trefs_warn_dangling_symrefs(get_main_ref_store(the_repository),\n    - \t\t\t\t   stdout, \" \", dry_run, &refs_to_prune);\n    +-\t\t\t\t   stdout, \" \", dry_run, &refs_to_prune);\n    ++\t\t\t\t   stdout, \" \", dry_run, &pruned_refs);\n      \n     +cleanup:\n      \tstring_list_clear(&refs_to_prune, 0);\n    ++\tstring_list_clear(&pruned_refs, 0);\n    ++\tstring_list_clear(&failed_refs, 0);\n     +\toid_array_clear(&old_oids);\n      \tfree_remote_ref_states(&states);\n      \treturn result;\n    @@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'branch deletion rejects a\n     +\t)\n     +'\n     +\n    -+test_expect_success 'remote prune rejects a concurrent update' '\n    ++test_expect_success 'remote prune reports deletions around a concurrent update' '\n     +\ttest_when_finished \"rm -rf race-empty.git race-prune\" &&\n     +\tgit init --bare race-empty.git &&\n     +\tgit init race-prune &&\n    @@ t/t1416-ref-transaction-hooks.sh: test_expect_success 'branch deletion rejects a\n     +\t\ttest_must_fail git remote prune origin >out 2>err &&\n     +\t\ttest_cmp_rev \"$two\" refs/remotes/origin/race &&\n     +\t\ttest_must_fail git rev-parse --verify refs/remotes/origin/other &&\n    -+\t\ttest_grep ! \"\\[pruned\\]\" out\n    ++\t\ttest_grep \"\\[pruned\\].*origin/other\" out &&\n    ++\t\ttest_grep ! \"\\[pruned\\].*origin/race\" out &&\n    ++\t\ttest_grep \"could not delete reference refs/remotes/origin/race\" err\n    ++\t)\n    ++'\n    ++\n    ++test_expect_success 'fetch prune reports deletions around a concurrent update' '\n    ++\ttest_when_finished \"rm -rf fetch-empty.git fetch-prune\" &&\n    ++\tgit init --bare fetch-empty.git &&\n    ++\tgit init fetch-prune &&\n    ++\t(\n    ++\t\tcd fetch-prune &&\n    ++\t\tgit commit --allow-empty -m one &&\n    ++\t\tone=$(git rev-parse HEAD) &&\n    ++\t\tgit commit --allow-empty -m two &&\n    ++\t\tgit remote add origin ../fetch-empty.git &&\n    ++\t\tgit update-ref refs/remotes/origin/race \"$one\" &&\n    ++\t\tgit update-ref refs/remotes/origin/other \"$one\"\n    ++\t) &&\n    ++\ttest_hook -C fetch-prune reference-transaction <<-\\EOF &&\n    ++\t\tmarker=$(git rev-parse --git-path prune-race-once)\n    ++\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n    ++\t\tthen\n    ++\t\t\t>\"$marker\"\n    ++\t\t\tgit update-ref refs/remotes/origin/race HEAD\n    ++\t\tfi\n    ++\t\texit 0\n    ++\tEOF\n    ++\t(\n    ++\t\tcd fetch-prune &&\n    ++\t\ttwo=$(git rev-parse HEAD) &&\n    ++\t\ttest_must_fail git fetch --prune origin >out 2>err &&\n    ++\t\ttest_cmp_rev \"$two\" refs/remotes/origin/race &&\n    ++\t\ttest_must_fail git rev-parse --verify refs/remotes/origin/other &&\n    ++\t\ttest_grep \"\\[deleted\\].*origin/other\" err &&\n    ++\t\ttest_grep ! \"\\[deleted\\].*origin/race\" err &&\n    ++\t\ttest_grep \"could not delete reference refs/remotes/origin/race\" err\n     +\t)\n     +'\n     +\n-- \n2.39.3 (Apple Git-146)\n"},{"id":"553023","messageId":"f4a9d065c34451a5f6ade6a6c365baa18adeb780.1790113781.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790113781.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v4 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-22T22:31:08Z","receivedAt":"2026-09-22T22:31:14Z","isPatch":true,"body":"refs_delete_refs() performs unconditional deletions, so callers cannot\npreserve old values that they have already resolved. Consequently,\nreference-transaction hooks see a null old OID.\n\nLet callers provide an optional array of expected old OIDs in parallel with\nthe refname list. Delete the ref at position N only if it still points at\nthe OID at position N. Treat a null OID as an unconditional deletion in\nref_transaction_delete(), allowing callers to include broken refs whose old\nvalue cannot be resolved.\n\nrefs_delete_refs() has always promised best-effort deletion. Always use\nREF_TRANSACTION_ALLOW_FAILURE and report rejected updates so one failure\ndoes not prevent independent refs in the batch from being deleted. Let\ncallers request the exact set of failed refs when they need to report\npartial results. This also completes the conversion that was missed when\nbatched transaction failure support was introduced.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n bisect.c                  |  2 +-\n builtin/branch.c          |  3 +-\n builtin/fetch.c           |  2 +-\n builtin/remote.c          |  5 +--\n builtin/tag.c             |  3 +-\n refs.c                    | 67 +++++++++++++++++++++++++++++++--------\n refs.h                    | 31 +++++++++++++-----\n t/helper/test-ref-store.c |  2 +-\n 8 files changed, 86 insertions(+), 29 deletions(-)\n\ndiff --git a/bisect.c b/bisect.c\nindex 9cbb3dc67..c8ab16d1e 100644\n--- a/bisect.c\n+++ b/bisect.c\n@@ -1206,7 +1206,7 @@ int bisect_clean_state(void)\n \tstring_list_append(&refs_for_removal, \"BISECT_EXPECTED_REV\");\n \tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t  \"bisect: remove\", &refs_for_removal,\n-\t\t\t\t  REF_NO_DEREF);\n+\t\t\t\t  NULL, NULL, REF_NO_DEREF);\n \tstring_list_clear(&refs_for_removal, 0);\n \tunlink_or_warn(git_path_bisect_ancestors_ok());\n \tunlink_or_warn(git_path_bisect_log());\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex a613148fc..c9f259d04 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -351,7 +351,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t}\n \n \tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n-\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL,\n+\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex 533fdfe7d..b662216bf 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -1486,7 +1486,7 @@ static int prune_refs(struct display_state *display_state,\n \t\t} else {\n \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n-\t\t\t\t\t\t  0);\n+\t\t\t\t\t\t  NULL, 0);\n \t\t}\n \t}\n \ndiff --git a/builtin/remote.c b/builtin/remote.c\nindex de989ea3b..56b06845b 100644\n--- a/builtin/remote.c\n+++ b/builtin/remote.c\n@@ -1073,7 +1073,7 @@ static int rm(int argc, const char **argv, const char *prefix,\n \tif (!result)\n \t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t  \"remote: remove\", &branches,\n-\t\t\t\t\t  REF_NO_DEREF);\n+\t\t\t\t\t  NULL, NULL, REF_NO_DEREF);\n \tstring_list_clear(&branches, 0);\n \n \tif (skipped.nr) {\n@@ -1645,7 +1645,8 @@ static int prune_remote(const char *remote, int dry_run)\n \n \tif (!dry_run)\n \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n-\t\t\t\t\t   \"remote: prune\", &refs_to_prune, 0);\n+\t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n+\t\t\t\t\t   NULL, 0);\n \n \tfor_each_string_list_item(item, &states.stale) {\n \t\tconst char *refname = item->util;\ndiff --git a/builtin/tag.c b/builtin/tag.c\nindex 06c125b53..40874a292 100644\n--- a/builtin/tag.c\n+++ b/builtin/tag.c\n@@ -122,7 +122,8 @@ static int delete_tags(const char **argv)\n \tstruct string_list_item *item;\n \n \tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n+\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n \t\tresult = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\ndiff --git a/refs.c b/refs.c\nindex 92d5df5b7..13ee2d459 100644\n--- a/refs.c\n+++ b/refs.c\n@@ -16,6 +16,7 @@\n #include \"refs/refs-internal.h\"\n #include \"hook.h\"\n #include \"object-name.h\"\n+#include \"oid-array.h\"\n #include \"odb.h\"\n #include \"object.h\"\n #include \"path.h\"\n@@ -1523,7 +1524,7 @@ int ref_transaction_delete(struct ref_transaction *transaction,\n \t\t\t   struct strbuf *err)\n {\n \tif (old_oid && is_null_oid(old_oid))\n-\t\tBUG(\"delete called with old_oid set to zeros\");\n+\t\told_oid = NULL;\n \tif (old_oid && old_target)\n \t\tBUG(\"delete called with both old_oid and old_target set\");\n \tif (old_target && !(flags & REF_NO_DEREF))\n@@ -3069,39 +3070,73 @@ void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n \t}\n }\n \n+struct delete_refs_rejection_data {\n+\tint failures;\n+\tstruct string_list *failed_refs;\n+};\n+\n+static void delete_refs_rejection_handler(const char *refname,\n+\t\t\t\t\t  const struct object_id *old_oid UNUSED,\n+\t\t\t\t\t  const struct object_id *new_oid UNUSED,\n+\t\t\t\t\t  const char *old_target UNUSED,\n+\t\t\t\t\t  const char *new_target UNUSED,\n+\t\t\t\t\t  enum ref_transaction_error err,\n+\t\t\t\t\t  const char *details,\n+\t\t\t\t\t  void *cb_data)\n+{\n+\tstruct delete_refs_rejection_data *data = cb_data;\n+\n+\twarning(_(\"could not delete reference %s: %s\"), refname,\n+\t\tdetails ? details : ref_transaction_error_msg(err));\n+\tdata->failures++;\n+\tif (data->failed_refs)\n+\t\tstring_list_insert(data->failed_refs, refname);\n+}\n+\n int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n-\t\t     struct string_list *refnames, unsigned int flags)\n+\t\t     struct string_list *refnames,\n+\t\t     const struct oid_array *old_oids,\n+\t\t     struct string_list *failed_refs,\n+\t\t     unsigned int flags)\n {\n+\tstruct delete_refs_rejection_data rejection_data = {\n+\t\t.failed_refs = failed_refs,\n+\t};\n \tstruct ref_transaction *transaction;\n \tstruct strbuf err = STRBUF_INIT;\n-\tstruct string_list_item *item;\n-\tint ret = 0, failures = 0;\n+\tsize_t i;\n+\tint ret = 0;\n \tchar *msg;\n \n \tif (!refnames->nr)\n \t\treturn 0;\n+\tif (old_oids && old_oids->nr != refnames->nr)\n+\t\tBUG(\"refname and old OID counts do not match\");\n+\tif (failed_refs && !failed_refs->strdup_strings)\n+\t\tBUG(\"failed ref list does not duplicate strings\");\n \n \tmsg = normalize_reflog_message(logmsg);\n \n-\t/*\n-\t * Since we don't check the references' old_oids, the\n-\t * individual updates can't fail, so we can pack all of the\n-\t * updates into a single transaction.\n-\t */\n-\ttransaction = ref_store_transaction_begin(refs, 0, &err);\n+\ttransaction = ref_store_transaction_begin(refs,\n+\t\t\t\t\t\t  REF_TRANSACTION_ALLOW_FAILURE, &err);\n \tif (!transaction) {\n \t\tret = error(\"%s\", err.buf);\n \t\tgoto out;\n \t}\n \n-\tfor_each_string_list_item(item, refnames) {\n+\tfor (i = 0; i < refnames->nr; i++) {\n+\t\tstruct string_list_item *item = &refnames->items[i];\n+\t\tconst struct object_id *old_oid = old_oids ? &old_oids->oid[i] : NULL;\n+\n \t\tret = ref_transaction_delete(transaction, item->string,\n-\t\t\t\t\t     NULL, NULL, flags, msg, &err);\n+\t\t\t\t\t     old_oid, NULL, flags, msg, &err);\n \t\tif (ret) {\n \t\t\twarning(_(\"could not delete reference %s: %s\"),\n \t\t\t\titem->string, err.buf);\n \t\t\tstrbuf_reset(&err);\n-\t\t\tfailures = 1;\n+\t\t\trejection_data.failures++;\n+\t\t\tif (failed_refs)\n+\t\t\t\tstring_list_insert(failed_refs, item->string);\n \t\t}\n \t}\n \n@@ -3113,9 +3148,13 @@ int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n \t\telse\n \t\t\terror(_(\"could not delete references: %s\"), err.buf);\n \t}\n+\tif (!ret)\n+\t\tref_transaction_for_each_rejected_update(transaction,\n+\t\t\t\t\t\t\t delete_refs_rejection_handler,\n+\t\t\t\t\t\t\t &rejection_data);\n \n out:\n-\tif (!ret && failures)\n+\tif (!ret && rejection_data.failures)\n \t\tret = -1;\n \tref_transaction_free(transaction);\n \tstrbuf_release(&err);\ndiff --git a/refs.h b/refs.h\nindex 9979446d1..43f7a32f2 100644\n--- a/refs.h\n+++ b/refs.h\n@@ -9,6 +9,7 @@\n struct fsck_options;\n struct object_id;\n struct ref_store;\n+struct oid_array;\n struct strbuf;\n struct string_list;\n struct string_list_item;\n@@ -623,13 +624,26 @@ int refs_delete_ref(struct ref_store *refs, const char *msg,\n \t\t    unsigned int flags);\n \n /*\n- * Delete the specified references. If there are any problems, emit\n- * errors but attempt to keep going (i.e., the deletes are not done in\n- * an all-or-nothing transaction). msg and flags are passed through to\n- * ref_transaction_delete().\n+ * Delete the specified references. If old_oids is non-NULL, it must contain\n+ * an entry for each refname, in the same order. Each non-null OID is used to\n+ * verify the current value of the corresponding reference before deleting\n+ * it. A null OID requests an unconditional deletion, which allows callers to\n+ * include broken refs whose old value cannot be resolved.\n+ *\n+ * If failed_refs is non-NULL, it must be initialized with\n+ * STRING_LIST_INIT_DUP. The names of individual updates that cannot be queued\n+ * or are rejected while processing the best-effort batch are inserted into\n+ * it. A transaction-wide failure is returned without populating the list.\n+ *\n+ * If there are any problems, emit errors but attempt to keep going (i.e.,\n+ * the deletes are not done in an all-or-nothing transaction). msg and flags\n+ * are passed through to ref_transaction_delete().\n  */\n int refs_delete_refs(struct ref_store *refs, const char *msg,\n-\t\t     struct string_list *refnames, unsigned int flags);\n+\t\t     struct string_list *refnames,\n+\t\t     const struct oid_array *old_oids,\n+\t\t     struct string_list *failed_refs,\n+\t\t     unsigned int flags);\n \n /** Delete a reflog */\n int refs_delete_reflog(struct ref_store *refs, const char *refname);\n@@ -956,9 +970,10 @@ int ref_transaction_create(struct ref_transaction *transaction,\n \t\t\t   struct strbuf *err);\n \n /*\n- * Add a reference deletion to transaction. If old_oid is non-NULL,\n- * then it holds the value that the reference should have had before\n- * the update (which must not be null_oid).\n+ * Add a reference deletion to transaction. If old_oid is non-NULL and not\n+ * null_oid, then it holds the value that the reference should have had before\n+ * the update. Passing null_oid is equivalent to passing NULL and disables the\n+ * old value check.\n  *\n  * See the above comment \"Reference transaction updates\" for more\n  * information.\ndiff --git a/t/helper/test-ref-store.c b/t/helper/test-ref-store.c\nindex db58f0058..29945f2b8 100644\n--- a/t/helper/test-ref-store.c\n+++ b/t/helper/test-ref-store.c\n@@ -132,7 +132,7 @@ static int cmd_delete_refs(struct ref_store *refs, const char **argv)\n \twhile (*argv)\n \t\tstring_list_append(&refnames, *argv++);\n \n-\tresult = refs_delete_refs(refs, msg, &refnames, flags);\n+\tresult = refs_delete_refs(refs, msg, &refnames, NULL, NULL, flags);\n \tstring_list_clear(&refnames, 0);\n \treturn result;\n }\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"553024","messageId":"918c97d2b4589f6616de33dced7471119ba86fde.1790113781.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790113781.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v4 2/3] branch, tag: retain old OIDs in batched deletions","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-22T22:31:09Z","receivedAt":"2026-09-22T22:31:17Z","isPatch":true,"body":"Before 8198907795 (use delete_refs when deleting tags or branches,\n2021-01-21), branch and tag deletion passed each resolved old OID to\ndelete_ref(). This prevented the command from deleting a ref that another\nprocess had changed after it was inspected.\n\nThe conversion to batched deletion dropped those old OIDs. Besides making\nthe deletions unconditional, this causes reference-transaction hooks to\nreport zero as both the old and new OID.\n\nBoth commands still resolve the old OIDs before starting the deletion. Pass\nthose values to refs_delete_refs(). This restores the old race protection\nand lets hooks receive useful old values without adding ref reads. If a ref\nchanges concurrently, reject its deletion and preserve the new value.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n builtin/branch.c                 |  6 ++++-\n builtin/tag.c                    | 27 ++++++++++++++------\n t/t1416-ref-transaction-hooks.sh | 44 ++++++++++++++++++++++++++++++++\n 3 files changed, 68 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c9f259d04..4ce1407bc 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -16,6 +16,7 @@\n #include \"commit.h\"\n #include \"gettext.h\"\n #include \"object-name.h\"\n+#include \"oid-array.h\"\n #include \"remote.h\"\n #include \"parse-options.h\"\n #include \"branch.h\"\n@@ -248,6 +249,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \tstruct string_list_item *item;\n \tint branch_name_pos;\n \tconst char *fmt_remotes = \"refs/remotes/%s\";\n@@ -342,6 +344,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n+\t\toid_array_append(&old_oids, &oid);\n \t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n \t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n@@ -352,7 +355,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \n \tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n \t    refs_delete_refs(get_main_ref_store(the_repository), NULL,\n-\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n+\t\t\t     &refs_to_delete, &old_oids, NULL, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n@@ -377,6 +380,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(describe_ref);\n \t}\n \tstring_list_clear(&refs_to_delete, 0);\n+\toid_array_clear(&old_oids);\n \n \tfree(name);\n \tstrbuf_release(&bname);\ndiff --git a/builtin/tag.c b/builtin/tag.c\nindex 40874a292..07116664d 100644\n--- a/builtin/tag.c\n+++ b/builtin/tag.c\n@@ -105,28 +105,38 @@ static int for_each_tag_name(const char **argv, each_tag_name_fn fn,\n \treturn had_error;\n }\n \n+struct tags_to_delete {\n+\tstruct string_list refs;\n+\tstruct oid_array old_oids;\n+};\n+\n static int collect_tags(const char *name UNUSED, const char *ref,\n \t\t\tconst struct object_id *oid, void *cb_data)\n {\n-\tstruct string_list *ref_list = cb_data;\n+\tstruct tags_to_delete *data = cb_data;\n+\tstruct string_list_item *item;\n \n-\tstring_list_append(ref_list, ref);\n-\tref_list->items[ref_list->nr - 1].util = oiddup(oid);\n+\titem = string_list_append(&data->refs, ref);\n+\titem->util = oiddup(oid);\n+\toid_array_append(&data->old_oids, oid);\n \treturn 0;\n }\n \n static int delete_tags(const char **argv)\n {\n \tint result;\n-\tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n+\tstruct tags_to_delete data = {\n+\t\t.refs = STRING_LIST_INIT_DUP,\n+\t\t.old_oids = OID_ARRAY_INIT,\n+\t};\n \tstruct string_list_item *item;\n \n-\tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n+\tresult = for_each_tag_name(argv, collect_tags, &data);\n \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n-\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n+\t\t\t     &data.refs, &data.old_oids, NULL, REF_NO_DEREF))\n \t\tresult = 1;\n \n-\tfor_each_string_list_item(item, &refs_to_delete) {\n+\tfor_each_string_list_item(item, &data.refs) {\n \t\tconst char *name = item->string;\n \t\tstruct object_id *oid = item->util;\n \t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name))\n@@ -136,7 +146,8 @@ static int delete_tags(const char **argv)\n \n \t\tfree(oid);\n \t}\n-\tstring_list_clear(&refs_to_delete, 0);\n+\tstring_list_clear(&data.refs, 0);\n+\toid_array_clear(&data.old_oids);\n \treturn result;\n }\n \ndiff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\nindex 4fe9d9b23..01b5ba8c4 100755\n--- a/t/t1416-ref-transaction-hooks.sh\n+++ b/t/t1416-ref-transaction-hooks.sh\n@@ -14,6 +14,50 @@ test_expect_success setup '\n \tPOST_OID=$(git rev-parse POST)\n '\n \n+test_expect_success 'hook gets old values for batched branch/tag deletion' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\tgit branch to-delete PRE &&\n+\tgit tag delete-tag POST &&\n+\tgit pack-refs --all &&\n+\ttest_hook reference-transaction <<-\\EOF &&\n+\t\tif test \"$1\" = committed\n+\t\tthen\n+\t\t\t# Ignore backend-internal zero-to-zero records.\n+\t\t\twhile read -r old new ref\n+\t\t\tdo\n+\t\t\t\tcase \"$old\" in\n+\t\t\t\t*[!0]*)\n+\t\t\t\t\techo \"$old $new $ref\"\n+\t\t\t\t\t;;\n+\t\t\t\tesac\n+\t\t\tdone >>actual\n+\t\tfi\n+\tEOF\n+\tcat >expect <<-EOF &&\n+\t\t$PRE_OID $ZERO_OID refs/heads/to-delete\n+\t\t$POST_OID $ZERO_OID refs/tags/delete-tag\n+\tEOF\n+\tgit branch -D to-delete &&\n+\tgit tag -d delete-tag &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'branch deletion rejects a concurrent update' '\n+\tgit branch delete-race PRE &&\n+\ttest_hook reference-transaction <<-\\EOF &&\n+\t\tmarker=$(git rev-parse --git-path delete-race-once)\n+\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n+\t\tthen\n+\t\t\t>\"$marker\"\n+\t\t\tgit update-ref refs/heads/delete-race POST\n+\t\tfi\n+\t\texit 0\n+\tEOF\n+\ttest_must_fail git branch -D delete-race 2>err &&\n+\ttest_grep \"is at $POST_OID but expected $PRE_OID\" err &&\n+\ttest_cmp_rev POST refs/heads/delete-race\n+'\n+\n test_expect_success 'hook allows updating ref if successful' '\n \tgit reset --hard PRE &&\n \ttest_hook reference-transaction <<-\\EOF &&\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"553025","messageId":"6f34853c79625794d2eb364d227660be57d1539b.1790113781.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790113781.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v4 3/3] fetch, remote: retain old OIDs when pruning refs","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-22T22:31:10Z","receivedAt":"2026-09-22T22:31:17Z","isPatch":true,"body":"get_stale_heads() records the current value of each stale local ref in its\nnew_oid member. The pruning paths discard that value and request\nunconditional deletion, so reference-transaction hooks receive a null old\nOID.\n\nPass the recorded values into the deletion transactions. If a ref changes\nafter the stale scan, reject that deletion and preserve the new value.\nNon-atomic pruning uses refs_delete_refs(), whose partial-failure mode still\ndeletes unaffected stale refs. An atomic fetch remains all-or-nothing.\n\nContinue reporting successful non-atomic deletions when another deletion is\nrejected, but do not report the rejected ref as deleted or use it when\nchecking for newly dangling symrefs. Use the rejected-ref list returned by\nrefs_delete_refs() so reporting reflects the transaction result without\nadditional ref reads.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n builtin/fetch.c                  |  30 +++++---\n builtin/remote.c                 |  44 +++++++++---\n t/t1416-ref-transaction-hooks.sh | 116 +++++++++++++++++++++++++++++++\n 3 files changed, 174 insertions(+), 16 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex b662216bf..95789edb8 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -1471,22 +1471,29 @@ static int prune_refs(struct display_state *display_state,\n \tstruct ref *ref, *stale_refs = get_stale_heads(rs, ref_map);\n \tstruct strbuf err = STRBUF_INIT;\n \tstruct string_list refnames = STRING_LIST_INIT_NODUP;\n-\n-\tfor (ref = stale_refs; ref; ref = ref->next)\n-\t\tstring_list_append(&refnames, ref->name);\n+\tstruct string_list deleted_refs = STRING_LIST_INIT_NODUP;\n+\tstruct string_list failed_refs = STRING_LIST_INIT_DUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \n \tif (!dry_run) {\n \t\tif (transaction) {\n \t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n-\t\t\t\tresult = ref_transaction_delete(transaction, ref->name, NULL,\n-\t\t\t\t\t\t\t\tNULL, 0, \"fetch: prune\", &err);\n+\t\t\t\tresult = ref_transaction_delete(transaction, ref->name,\n+\t\t\t\t\t\t\t\t&ref->new_oid, NULL, 0,\n+\t\t\t\t\t\t\t\t\"fetch: prune\", &err);\n \t\t\t\tif (result)\n \t\t\t\t\tgoto cleanup;\n \t\t\t}\n \t\t} else {\n+\t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n+\t\t\t\tstring_list_append(&refnames, ref->name);\n+\t\t\t\toid_array_append(&old_oids, &ref->new_oid);\n+\t\t\t}\n \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n-\t\t\t\t\t\t  NULL, 0);\n+\t\t\t\t\t\t  &old_oids, &failed_refs, 0);\n+\t\t\tif (result && !failed_refs.nr)\n+\t\t\t\tgoto cleanup;\n \t\t}\n \t}\n \n@@ -1494,18 +1501,25 @@ static int prune_refs(struct display_state *display_state,\n \t\tint summary_width = transport_summary_width(stale_refs);\n \n \t\tfor (ref = stale_refs; ref; ref = ref->next) {\n+\t\t\tif (string_list_has_string(&failed_refs, ref->name))\n+\t\t\t\tcontinue;\n+\n \t\t\tdisplay_ref_update(display_state, '-', _(\"[deleted]\"), NULL,\n \t\t\t\t\t   _(\"(none)\"), ref->name,\n \t\t\t\t\t   &ref->new_oid, &ref->old_oid,\n \t\t\t\t\t   summary_width);\n+\t\t\tstring_list_append(&deleted_refs, ref->name);\n \t\t}\n-\t\tstring_list_sort(&refnames);\n+\t\tstring_list_sort(&deleted_refs);\n \t\trefs_warn_dangling_symrefs(get_main_ref_store(the_repository),\n-\t\t\t\t\t   stderr, \"   \", dry_run, &refnames);\n+\t\t\t\t\t   stderr, \"   \", dry_run, &deleted_refs);\n \t}\n \n cleanup:\n \tstring_list_clear(&refnames, 0);\n+\tstring_list_clear(&deleted_refs, 0);\n+\tstring_list_clear(&failed_refs, 0);\n+\toid_array_clear(&old_oids);\n \tstrbuf_release(&err);\n \tfree_refs(stale_refs);\n \treturn result;\ndiff --git a/builtin/remote.c b/builtin/remote.c\nindex 56b06845b..2d9ee6db1 100644\n--- a/builtin/remote.c\n+++ b/builtin/remote.c\n@@ -17,6 +17,7 @@\n #include \"refs.h\"\n #include \"refspec.h\"\n #include \"odb.h\"\n+#include \"oid-array.h\"\n #include \"strvec.h\"\n #include \"commit-reach.h\"\n #include \"progress.h\"\n@@ -380,6 +381,11 @@ struct ref_states {\n \tint queried;\n };\n \n+struct stale_ref {\n+\tstruct object_id oid;\n+\tchar name[FLEX_ARRAY];\n+};\n+\n #define REF_STATES_INIT { \\\n \t.new_refs = STRING_LIST_INIT_DUP, \\\n \t.skipped = STRING_LIST_INIT_DUP, \\\n@@ -410,9 +416,13 @@ static int get_ref_states(const struct ref *remote_refs, struct ref_states *stat\n \t}\n \tstale_refs = get_stale_heads(&states->remote->fetch, fetch_map);\n \tfor (ref = stale_refs; ref; ref = ref->next) {\n+\t\tstruct stale_ref *stale_ref;\n \t\tstruct string_list_item *item =\n \t\t\tstring_list_append(&states->stale, abbrev_branch(ref->name));\n-\t\titem->util = xstrdup(ref->name);\n+\n+\t\tFLEX_ALLOC_STR(stale_ref, name, ref->name);\n+\t\toidcpy(&stale_ref->oid, &ref->new_oid);\n+\t\titem->util = stale_ref;\n \t}\n \tfree_refs(stale_refs);\n \tfree_refs(fetch_map);\n@@ -1627,6 +1637,9 @@ static int prune_remote(const char *remote, int dry_run)\n \tint result = 0;\n \tstruct ref_states states = REF_STATES_INIT;\n \tstruct string_list refs_to_prune = STRING_LIST_INIT_NODUP;\n+\tstruct string_list pruned_refs = STRING_LIST_INIT_NODUP;\n+\tstruct string_list failed_refs = STRING_LIST_INIT_DUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \tstruct string_list_item *item;\n \n \tget_remote_ref_states(remote, &states, GET_REF_STATES);\n@@ -1639,17 +1652,27 @@ static int prune_remote(const char *remote, int dry_run)\n \tprintf_ln(_(\"Pruning %s\"), remote);\n \tprintf_ln(_(\"URL: %s\"), states.remote->url.v[0]);\n \n-\tfor_each_string_list_item(item, &states.stale)\n-\t\tstring_list_append(&refs_to_prune, item->util);\n-\tstring_list_sort(&refs_to_prune);\n+\tfor_each_string_list_item(item, &states.stale) {\n+\t\tstruct stale_ref *stale_ref = item->util;\n+\n+\t\tstring_list_append(&refs_to_prune, stale_ref->name);\n+\t\toid_array_append(&old_oids, &stale_ref->oid);\n+\t}\n \n-\tif (!dry_run)\n+\tif (!dry_run) {\n \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n-\t\t\t\t\t   NULL, 0);\n+\t\t\t\t\t   &old_oids, &failed_refs, 0);\n+\t\tif (result && !failed_refs.nr)\n+\t\t\tgoto cleanup;\n+\t}\n \n \tfor_each_string_list_item(item, &states.stale) {\n-\t\tconst char *refname = item->util;\n+\t\tstruct stale_ref *stale_ref = item->util;\n+\t\tconst char *refname = stale_ref->name;\n+\n+\t\tif (string_list_has_string(&failed_refs, refname))\n+\t\t\tcontinue;\n \n \t\tif (dry_run)\n \t\t\tprintf_ln(_(\" * [would prune] %s\"),\n@@ -1657,12 +1680,17 @@ static int prune_remote(const char *remote, int dry_run)\n \t\telse\n \t\t\tprintf_ln(_(\" * [pruned] %s\"),\n \t\t\t       abbrev_ref(refname, \"refs/remotes/\"));\n+\t\tstring_list_append(&pruned_refs, refname);\n \t}\n \n \trefs_warn_dangling_symrefs(get_main_ref_store(the_repository),\n-\t\t\t\t   stdout, \" \", dry_run, &refs_to_prune);\n+\t\t\t\t   stdout, \" \", dry_run, &pruned_refs);\n \n+cleanup:\n \tstring_list_clear(&refs_to_prune, 0);\n+\tstring_list_clear(&pruned_refs, 0);\n+\tstring_list_clear(&failed_refs, 0);\n+\toid_array_clear(&old_oids);\n \tfree_remote_ref_states(&states);\n \treturn result;\n }\ndiff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\nindex 01b5ba8c4..e7c16cd87 100755\n--- a/t/t1416-ref-transaction-hooks.sh\n+++ b/t/t1416-ref-transaction-hooks.sh\n@@ -58,6 +58,122 @@ test_expect_success 'branch deletion rejects a concurrent update' '\n \ttest_cmp_rev POST refs/heads/delete-race\n '\n \n+test_expect_success 'hook gets old values when pruning remote refs' '\n+\ttest_when_finished \"rm -rf empty.git prune\" &&\n+\tgit init --bare empty.git &&\n+\tgit init prune &&\n+\t(\n+\t\tcd prune &&\n+\t\tgit remote add origin ../empty.git &&\n+\t\tgit commit --allow-empty -m one &&\n+\t\tone=$(git rev-parse HEAD) &&\n+\t\tgit commit --allow-empty -m two &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\tgit update-ref refs/remotes/origin/remote-prune-z \"$one\" &&\n+\t\tgit update-ref refs/remotes/origin/remote-prune-a \"$two\"\n+\t) &&\n+\ttest_hook -C prune reference-transaction <<-\\EOF &&\n+\t\tif test \"$1\" = committed\n+\t\tthen\n+\t\t\t# Ignore backend-internal zero-to-zero records.\n+\t\t\twhile read -r old new ref\n+\t\t\tdo\n+\t\t\t\tcase \"$old\" in\n+\t\t\t\t*[!0]*)\n+\t\t\t\t\techo \"$old $new $ref\"\n+\t\t\t\t\t;;\n+\t\t\t\tesac\n+\t\t\tdone >>actual\n+\t\tfi\n+\tEOF\n+\t(\n+\t\tcd prune &&\n+\t\tone=$(git rev-parse HEAD^) &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\tgit remote prune origin &&\n+\t\tgit update-ref refs/remotes/origin/fetch-prune \"$one\" &&\n+\t\tgit fetch --prune origin &&\n+\t\tgit update-ref refs/remotes/origin/atomic-prune \"$one\" &&\n+\t\tgit fetch --atomic --prune origin &&\n+\t\tcat >expect <<-EOF &&\n+\t\t\t$two $ZERO_OID refs/remotes/origin/remote-prune-a\n+\t\t\t$one $ZERO_OID refs/remotes/origin/remote-prune-z\n+\t\t\t$one $ZERO_OID refs/remotes/origin/fetch-prune\n+\t\t\t$one $ZERO_OID refs/remotes/origin/atomic-prune\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success 'remote prune reports deletions around a concurrent update' '\n+\ttest_when_finished \"rm -rf race-empty.git race-prune\" &&\n+\tgit init --bare race-empty.git &&\n+\tgit init race-prune &&\n+\t(\n+\t\tcd race-prune &&\n+\t\tgit commit --allow-empty -m one &&\n+\t\tone=$(git rev-parse HEAD) &&\n+\t\tgit commit --allow-empty -m two &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\tgit remote add origin ../race-empty.git &&\n+\t\tgit update-ref refs/remotes/origin/race \"$one\" &&\n+\t\tgit update-ref refs/remotes/origin/other \"$one\"\n+\t) &&\n+\ttest_hook -C race-prune reference-transaction <<-\\EOF &&\n+\t\tmarker=$(git rev-parse --git-path prune-race-once)\n+\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n+\t\tthen\n+\t\t\t>\"$marker\"\n+\t\t\tgit update-ref refs/remotes/origin/race HEAD\n+\t\tfi\n+\t\texit 0\n+\tEOF\n+\t(\n+\t\tcd race-prune &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\ttest_must_fail git remote prune origin >out 2>err &&\n+\t\ttest_cmp_rev \"$two\" refs/remotes/origin/race &&\n+\t\ttest_must_fail git rev-parse --verify refs/remotes/origin/other &&\n+\t\ttest_grep \"\\[pruned\\].*origin/other\" out &&\n+\t\ttest_grep ! \"\\[pruned\\].*origin/race\" out &&\n+\t\ttest_grep \"could not delete reference refs/remotes/origin/race\" err\n+\t)\n+'\n+\n+test_expect_success 'fetch prune reports deletions around a concurrent update' '\n+\ttest_when_finished \"rm -rf fetch-empty.git fetch-prune\" &&\n+\tgit init --bare fetch-empty.git &&\n+\tgit init fetch-prune &&\n+\t(\n+\t\tcd fetch-prune &&\n+\t\tgit commit --allow-empty -m one &&\n+\t\tone=$(git rev-parse HEAD) &&\n+\t\tgit commit --allow-empty -m two &&\n+\t\tgit remote add origin ../fetch-empty.git &&\n+\t\tgit update-ref refs/remotes/origin/race \"$one\" &&\n+\t\tgit update-ref refs/remotes/origin/other \"$one\"\n+\t) &&\n+\ttest_hook -C fetch-prune reference-transaction <<-\\EOF &&\n+\t\tmarker=$(git rev-parse --git-path prune-race-once)\n+\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n+\t\tthen\n+\t\t\t>\"$marker\"\n+\t\t\tgit update-ref refs/remotes/origin/race HEAD\n+\t\tfi\n+\t\texit 0\n+\tEOF\n+\t(\n+\t\tcd fetch-prune &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\ttest_must_fail git fetch --prune origin >out 2>err &&\n+\t\ttest_cmp_rev \"$two\" refs/remotes/origin/race &&\n+\t\ttest_must_fail git rev-parse --verify refs/remotes/origin/other &&\n+\t\ttest_grep \"\\[deleted\\].*origin/other\" err &&\n+\t\ttest_grep ! \"\\[deleted\\].*origin/race\" err &&\n+\t\ttest_grep \"could not delete reference refs/remotes/origin/race\" err\n+\t)\n+'\n+\n test_expect_success 'hook allows updating ref if successful' '\n \tgit reset --hard PRE &&\n \ttest_hook reference-transaction <<-\\EOF &&\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"553028","messageId":"xmqqfqz0dfrn.fsf@gitster.g","threadId":"66351","inReplyTo":"CACQ=SRGf=cKQooiSQD+ZsG8tCAdHkCrxoW5vyPSnT=UMjSajmw@mail.gmail.com","subject":"Re: [PATCH v3 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-22T23:31:56Z","receivedAt":"2026-09-22T23:31:59Z","isPatch":true,"body":"Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n\n> On Tue, Sep 22, 2026 at 8:55 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n>> I think there was a comment by another reviewer on the previous\n>> round around this area, which was never answered.  In general, it is\n>> a polite thing to respond to review messages and see that your\n>> response is acknowledged before you send an updated patch.\n>\n> I'm very sorry, I didn't check my email before submitting the patch.\n> I'll take a look at this. By the way, I expected the review process to\n> be tough, but I'm starting to wonder if I'll ever get through it :).\n\nI do not think it is the review process, but the fact that the\nproblem you chose to tackle is not trivial to solve cleanly to begin\nwith, and you are doing very well.  Often non-trivial topics take\nmultiple iterations to get right.\n\n> You mentioned earlier that you could prepare a patch. Is that offer\n> still on the table?\n\nI do not recall that, or I do not know a patch to do what was being\ndiscussed back then, sorry.\n\n"},{"id":"553106","messageId":"xmqq4iffag6k.fsf@gitster.g","threadId":"66351","inReplyTo":"6f34853c79625794d2eb364d227660be57d1539b.1790113781.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v4 3/3] fetch, remote: retain old OIDs when pruning refs","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-23T20:03:31Z","receivedAt":"2026-09-23T20:03:34Z","isPatch":true,"body":"Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n\n> diff --git a/builtin/fetch.c b/builtin/fetch.c\n> index b662216bf..95789edb8 100644\n> --- a/builtin/fetch.c\n> +++ b/builtin/fetch.c\n> @@ -1471,22 +1471,29 @@ static int prune_refs(struct display_state *display_state,\n> ...\n>  \t\t} else {\n> +\t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n> +\t\t\t\tstring_list_append(&refnames, ref->name);\n> +\t\t\t\toid_array_append(&old_oids, &ref->new_oid);\n> +\t\t\t}\n>  \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n>  \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n> -\t\t\t\t\t\t  NULL, 0);\n> +\t\t\t\t\t\t  &old_oids, &failed_refs, 0);\n\nIsn't adding a new parameter to refs_delete_refs() needed before\nthis step?  The corresponding changes to refs.[ch] was done in\n[1/3], and the fact that the callsite receives this update to add an\nextra parameter this late in the series means [1/3] and [2/3] does\nnot even compile, right?\n\nThanks.\n"},{"id":"553110","messageId":"20260923210205.59543-1-maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"xmqq4iffag6k.fsf@gitster.g","subject":"Re: [PATCH v4 3/3] fetch, remote: retain old OIDs when pruning refs","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-23T21:02:05Z","receivedAt":"2026-09-23T21:02:09Z","isPatch":true,"body":"> Isn't adding a new parameter to refs_delete_refs() needed before\n> this step?  The corresponding changes to refs.[ch] was done in\n> [1/3], and the fact that the callsite receives this update to add an\n> extra parameter this late in the series means [1/3] and [2/3] does\n> not even compile, right?\n\nYou are right. I updated only some of the call sites in 1/3 and left the\nothers for the patches that begin supplying the new arguments. As a result,\nthe intermediate commits do not compile.\n\nI have corrected the local series by updating every call site for the new\nsignature in 1/3, with NULL for optional data that is supplied only by the\nlater patches. I also built the tree after each patch. The final tree remains\nidentical to v4.\n\nThanks for catching this.\n"},{"id":"553111","messageId":"cover.1790196627.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790113781.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v5 0/3] refs: report old OIDs for batched deletions","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-23T21:04:39Z","receivedAt":"2026-09-23T21:04:46Z","isPatch":true,"body":"Thanks for catching the broken intermediate commits. This version updates\nevery refs_delete_refs() call site in 1/3, using NULL for the optional data\nthat is not supplied until the later patches. Each patch now builds on its\npredecessor. The final tree is identical to v4.\n\nThis follows up on the reference-transaction bug report at [1].\n\nThe reference-transaction hook receives zero as both the old and new OID\nwhen branch, tag, fetch, and remote delete refs through refs_delete_refs().\nThose callers already know the values that they selected for deletion.\n\nTeach refs_delete_refs() to accept a parallel array of expected old OIDs and\npass them into the transaction. Besides making hook records useful, this\nrestores conditional deletion for branch and tag and adds it to pruning\nwithout additional ref reads. Non-atomic batches preserve best-effort\nbehavior, while atomic fetches remain all-or-nothing.\n\nChanges since v4:\n\n * Update all refs_delete_refs() call sites in 1/3 for the new signature.\n * Verify that 1/3, 1/3--2/3, and the complete series each build with\n   DEVELOPER=1.\n\nThe focused reference-transaction tests pass with SHA-1 and SHA-256 using\nboth the files and reftable backends. The full test suite passed on the\nidentical final tree in v4.\n\n[1] https://lore.kernel.org/git/CACQ=SRGTTdQ+dHXhN6F52dBv5KxZBRfk_Em2fvmEmGJDoB6oTg@mail.gmail.com/\n\nMaciej Ciemborowicz (3):\n  refs: allow callers to supply old OIDs for batch deletion\n  branch, tag: retain old OIDs in batched deletions\n  fetch, remote: retain old OIDs when pruning refs\n\n bisect.c                         |   2 +-\n builtin/branch.c                 |   7 +-\n builtin/fetch.c                  |  30 ++++--\n builtin/remote.c                 |  47 +++++++--\n builtin/tag.c                    |  28 ++++--\n refs.c                           |  67 ++++++++++---\n refs.h                           |  31 ++++--\n t/helper/test-ref-store.c        |   2 +-\n t/t1416-ref-transaction-hooks.sh | 160 +++++++++++++++++++++++++++++++\n 9 files changed, 324 insertions(+), 50 deletions(-)\n\nRange-diff against v4:\n1:  f4a9d065c ! 1:  9b76cc2c4 refs: allow callers to supply old OIDs for batch deletion\n    @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki\n      \tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n     -\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n     +\t    refs_delete_refs(get_main_ref_store(the_repository), NULL,\n    -+\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n    ++\t\t\t     &refs_to_delete, NULL, NULL, REF_NO_DEREF))\n      \t\tret = 1;\n      \n      \tfor_each_string_list_item(item, &refs_to_delete) {\n    @@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n      \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n      \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n     -\t\t\t\t\t\t  0);\n    -+\t\t\t\t\t\t  NULL, 0);\n    ++\t\t\t\t\t\t  NULL, NULL, 0);\n      \t\t}\n      \t}\n      \n    @@ builtin/remote.c: static int prune_remote(const char *remote, int dry_run)\n      \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n     -\t\t\t\t\t   \"remote: prune\", &refs_to_prune, 0);\n     +\t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n    -+\t\t\t\t\t   NULL, 0);\n    ++\t\t\t\t\t   NULL, NULL, 0);\n      \n      \tfor_each_string_list_item(item, &states.stale) {\n      \t\tconst char *refname = item->util;\n    @@ builtin/tag.c: static int delete_tags(const char **argv)\n      \tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n     -\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n     +\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n    -+\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n    ++\t\t\t     &refs_to_delete, NULL, NULL, REF_NO_DEREF))\n      \t\tresult = 1;\n      \n      \tfor_each_string_list_item(item, &refs_to_delete) {\n2:  918c97d2b ! 2:  6a8401c44 branch, tag: retain old OIDs in batched deletions\n    @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki\n      \n      \tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n      \t    refs_delete_refs(get_main_ref_store(the_repository), NULL,\n    --\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n    +-\t\t\t     &refs_to_delete, NULL, NULL, REF_NO_DEREF))\n     +\t\t\t     &refs_to_delete, &old_oids, NULL, REF_NO_DEREF))\n      \t\tret = 1;\n      \n    @@ builtin/tag.c: static int for_each_tag_name(const char **argv, each_tag_name_fn\n     -\tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n     +\tresult = for_each_tag_name(argv, collect_tags, &data);\n      \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n    --\t\t\t     &refs_to_delete, NULL, REF_NO_DEREF))\n    +-\t\t\t     &refs_to_delete, NULL, NULL, REF_NO_DEREF))\n     +\t\t\t     &data.refs, &data.old_oids, NULL, REF_NO_DEREF))\n      \t\tresult = 1;\n      \n3:  6f34853c7 ! 3:  541da44c3 fetch, remote: retain old OIDs when pruning refs\n    @@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n     +\t\t\t}\n      \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n      \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n    --\t\t\t\t\t\t  NULL, 0);\n    +-\t\t\t\t\t\t  NULL, NULL, 0);\n     +\t\t\t\t\t\t  &old_oids, &failed_refs, 0);\n     +\t\t\tif (result && !failed_refs.nr)\n     +\t\t\t\tgoto cleanup;\n    @@ builtin/remote.c: static int prune_remote(const char *remote, int dry_run)\n     +\tif (!dry_run) {\n      \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n      \t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n    --\t\t\t\t\t   NULL, 0);\n    +-\t\t\t\t\t   NULL, NULL, 0);\n     +\t\t\t\t\t   &old_oids, &failed_refs, 0);\n     +\t\tif (result && !failed_refs.nr)\n     +\t\t\tgoto cleanup;\n-- \n2.39.3 (Apple Git-146)\n"},{"id":"553113","messageId":"9b76cc2c40a2b1fe727677a9400e3b26ec1ab437.1790196627.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790196627.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v5 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-23T21:04:40Z","receivedAt":"2026-09-23T21:04:47Z","isPatch":true,"body":"refs_delete_refs() performs unconditional deletions, so callers cannot\npreserve old values that they have already resolved. Consequently,\nreference-transaction hooks see a null old OID.\n\nLet callers provide an optional array of expected old OIDs in parallel with\nthe refname list. Delete the ref at position N only if it still points at\nthe OID at position N. Treat a null OID as an unconditional deletion in\nref_transaction_delete(), allowing callers to include broken refs whose old\nvalue cannot be resolved.\n\nrefs_delete_refs() has always promised best-effort deletion. Always use\nREF_TRANSACTION_ALLOW_FAILURE and report rejected updates so one failure\ndoes not prevent independent refs in the batch from being deleted. Let\ncallers request the exact set of failed refs when they need to report\npartial results. This also completes the conversion that was missed when\nbatched transaction failure support was introduced.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n bisect.c                  |  2 +-\n builtin/branch.c          |  3 +-\n builtin/fetch.c           |  2 +-\n builtin/remote.c          |  5 +--\n builtin/tag.c             |  3 +-\n refs.c                    | 67 +++++++++++++++++++++++++++++++--------\n refs.h                    | 31 +++++++++++++-----\n t/helper/test-ref-store.c |  2 +-\n 8 files changed, 86 insertions(+), 29 deletions(-)\n\ndiff --git a/bisect.c b/bisect.c\nindex 9cbb3dc67..c8ab16d1e 100644\n--- a/bisect.c\n+++ b/bisect.c\n@@ -1206,7 +1206,7 @@ int bisect_clean_state(void)\n \tstring_list_append(&refs_for_removal, \"BISECT_EXPECTED_REV\");\n \tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t  \"bisect: remove\", &refs_for_removal,\n-\t\t\t\t  REF_NO_DEREF);\n+\t\t\t\t  NULL, NULL, REF_NO_DEREF);\n \tstring_list_clear(&refs_for_removal, 0);\n \tunlink_or_warn(git_path_bisect_ancestors_ok());\n \tunlink_or_warn(git_path_bisect_log());\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex a613148fc..baccefc77 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -351,7 +351,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t}\n \n \tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n-\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL,\n+\t\t\t     &refs_to_delete, NULL, NULL, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex 533fdfe7d..11caa6b4a 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -1486,7 +1486,7 @@ static int prune_refs(struct display_state *display_state,\n \t\t} else {\n \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n-\t\t\t\t\t\t  0);\n+\t\t\t\t\t\t  NULL, NULL, 0);\n \t\t}\n \t}\n \ndiff --git a/builtin/remote.c b/builtin/remote.c\nindex de989ea3b..840c842e2 100644\n--- a/builtin/remote.c\n+++ b/builtin/remote.c\n@@ -1073,7 +1073,7 @@ static int rm(int argc, const char **argv, const char *prefix,\n \tif (!result)\n \t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t  \"remote: remove\", &branches,\n-\t\t\t\t\t  REF_NO_DEREF);\n+\t\t\t\t\t  NULL, NULL, REF_NO_DEREF);\n \tstring_list_clear(&branches, 0);\n \n \tif (skipped.nr) {\n@@ -1645,7 +1645,8 @@ static int prune_remote(const char *remote, int dry_run)\n \n \tif (!dry_run)\n \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n-\t\t\t\t\t   \"remote: prune\", &refs_to_prune, 0);\n+\t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n+\t\t\t\t\t   NULL, NULL, 0);\n \n \tfor_each_string_list_item(item, &states.stale) {\n \t\tconst char *refname = item->util;\ndiff --git a/builtin/tag.c b/builtin/tag.c\nindex 06c125b53..40157e834 100644\n--- a/builtin/tag.c\n+++ b/builtin/tag.c\n@@ -122,7 +122,8 @@ static int delete_tags(const char **argv)\n \tstruct string_list_item *item;\n \n \tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n+\t\t\t     &refs_to_delete, NULL, NULL, REF_NO_DEREF))\n \t\tresult = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\ndiff --git a/refs.c b/refs.c\nindex 92d5df5b7..13ee2d459 100644\n--- a/refs.c\n+++ b/refs.c\n@@ -16,6 +16,7 @@\n #include \"refs/refs-internal.h\"\n #include \"hook.h\"\n #include \"object-name.h\"\n+#include \"oid-array.h\"\n #include \"odb.h\"\n #include \"object.h\"\n #include \"path.h\"\n@@ -1523,7 +1524,7 @@ int ref_transaction_delete(struct ref_transaction *transaction,\n \t\t\t   struct strbuf *err)\n {\n \tif (old_oid && is_null_oid(old_oid))\n-\t\tBUG(\"delete called with old_oid set to zeros\");\n+\t\told_oid = NULL;\n \tif (old_oid && old_target)\n \t\tBUG(\"delete called with both old_oid and old_target set\");\n \tif (old_target && !(flags & REF_NO_DEREF))\n@@ -3069,39 +3070,73 @@ void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n \t}\n }\n \n+struct delete_refs_rejection_data {\n+\tint failures;\n+\tstruct string_list *failed_refs;\n+};\n+\n+static void delete_refs_rejection_handler(const char *refname,\n+\t\t\t\t\t  const struct object_id *old_oid UNUSED,\n+\t\t\t\t\t  const struct object_id *new_oid UNUSED,\n+\t\t\t\t\t  const char *old_target UNUSED,\n+\t\t\t\t\t  const char *new_target UNUSED,\n+\t\t\t\t\t  enum ref_transaction_error err,\n+\t\t\t\t\t  const char *details,\n+\t\t\t\t\t  void *cb_data)\n+{\n+\tstruct delete_refs_rejection_data *data = cb_data;\n+\n+\twarning(_(\"could not delete reference %s: %s\"), refname,\n+\t\tdetails ? details : ref_transaction_error_msg(err));\n+\tdata->failures++;\n+\tif (data->failed_refs)\n+\t\tstring_list_insert(data->failed_refs, refname);\n+}\n+\n int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n-\t\t     struct string_list *refnames, unsigned int flags)\n+\t\t     struct string_list *refnames,\n+\t\t     const struct oid_array *old_oids,\n+\t\t     struct string_list *failed_refs,\n+\t\t     unsigned int flags)\n {\n+\tstruct delete_refs_rejection_data rejection_data = {\n+\t\t.failed_refs = failed_refs,\n+\t};\n \tstruct ref_transaction *transaction;\n \tstruct strbuf err = STRBUF_INIT;\n-\tstruct string_list_item *item;\n-\tint ret = 0, failures = 0;\n+\tsize_t i;\n+\tint ret = 0;\n \tchar *msg;\n \n \tif (!refnames->nr)\n \t\treturn 0;\n+\tif (old_oids && old_oids->nr != refnames->nr)\n+\t\tBUG(\"refname and old OID counts do not match\");\n+\tif (failed_refs && !failed_refs->strdup_strings)\n+\t\tBUG(\"failed ref list does not duplicate strings\");\n \n \tmsg = normalize_reflog_message(logmsg);\n \n-\t/*\n-\t * Since we don't check the references' old_oids, the\n-\t * individual updates can't fail, so we can pack all of the\n-\t * updates into a single transaction.\n-\t */\n-\ttransaction = ref_store_transaction_begin(refs, 0, &err);\n+\ttransaction = ref_store_transaction_begin(refs,\n+\t\t\t\t\t\t  REF_TRANSACTION_ALLOW_FAILURE, &err);\n \tif (!transaction) {\n \t\tret = error(\"%s\", err.buf);\n \t\tgoto out;\n \t}\n \n-\tfor_each_string_list_item(item, refnames) {\n+\tfor (i = 0; i < refnames->nr; i++) {\n+\t\tstruct string_list_item *item = &refnames->items[i];\n+\t\tconst struct object_id *old_oid = old_oids ? &old_oids->oid[i] : NULL;\n+\n \t\tret = ref_transaction_delete(transaction, item->string,\n-\t\t\t\t\t     NULL, NULL, flags, msg, &err);\n+\t\t\t\t\t     old_oid, NULL, flags, msg, &err);\n \t\tif (ret) {\n \t\t\twarning(_(\"could not delete reference %s: %s\"),\n \t\t\t\titem->string, err.buf);\n \t\t\tstrbuf_reset(&err);\n-\t\t\tfailures = 1;\n+\t\t\trejection_data.failures++;\n+\t\t\tif (failed_refs)\n+\t\t\t\tstring_list_insert(failed_refs, item->string);\n \t\t}\n \t}\n \n@@ -3113,9 +3148,13 @@ int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n \t\telse\n \t\t\terror(_(\"could not delete references: %s\"), err.buf);\n \t}\n+\tif (!ret)\n+\t\tref_transaction_for_each_rejected_update(transaction,\n+\t\t\t\t\t\t\t delete_refs_rejection_handler,\n+\t\t\t\t\t\t\t &rejection_data);\n \n out:\n-\tif (!ret && failures)\n+\tif (!ret && rejection_data.failures)\n \t\tret = -1;\n \tref_transaction_free(transaction);\n \tstrbuf_release(&err);\ndiff --git a/refs.h b/refs.h\nindex 9979446d1..43f7a32f2 100644\n--- a/refs.h\n+++ b/refs.h\n@@ -9,6 +9,7 @@\n struct fsck_options;\n struct object_id;\n struct ref_store;\n+struct oid_array;\n struct strbuf;\n struct string_list;\n struct string_list_item;\n@@ -623,13 +624,26 @@ int refs_delete_ref(struct ref_store *refs, const char *msg,\n \t\t    unsigned int flags);\n \n /*\n- * Delete the specified references. If there are any problems, emit\n- * errors but attempt to keep going (i.e., the deletes are not done in\n- * an all-or-nothing transaction). msg and flags are passed through to\n- * ref_transaction_delete().\n+ * Delete the specified references. If old_oids is non-NULL, it must contain\n+ * an entry for each refname, in the same order. Each non-null OID is used to\n+ * verify the current value of the corresponding reference before deleting\n+ * it. A null OID requests an unconditional deletion, which allows callers to\n+ * include broken refs whose old value cannot be resolved.\n+ *\n+ * If failed_refs is non-NULL, it must be initialized with\n+ * STRING_LIST_INIT_DUP. The names of individual updates that cannot be queued\n+ * or are rejected while processing the best-effort batch are inserted into\n+ * it. A transaction-wide failure is returned without populating the list.\n+ *\n+ * If there are any problems, emit errors but attempt to keep going (i.e.,\n+ * the deletes are not done in an all-or-nothing transaction). msg and flags\n+ * are passed through to ref_transaction_delete().\n  */\n int refs_delete_refs(struct ref_store *refs, const char *msg,\n-\t\t     struct string_list *refnames, unsigned int flags);\n+\t\t     struct string_list *refnames,\n+\t\t     const struct oid_array *old_oids,\n+\t\t     struct string_list *failed_refs,\n+\t\t     unsigned int flags);\n \n /** Delete a reflog */\n int refs_delete_reflog(struct ref_store *refs, const char *refname);\n@@ -956,9 +970,10 @@ int ref_transaction_create(struct ref_transaction *transaction,\n \t\t\t   struct strbuf *err);\n \n /*\n- * Add a reference deletion to transaction. If old_oid is non-NULL,\n- * then it holds the value that the reference should have had before\n- * the update (which must not be null_oid).\n+ * Add a reference deletion to transaction. If old_oid is non-NULL and not\n+ * null_oid, then it holds the value that the reference should have had before\n+ * the update. Passing null_oid is equivalent to passing NULL and disables the\n+ * old value check.\n  *\n  * See the above comment \"Reference transaction updates\" for more\n  * information.\ndiff --git a/t/helper/test-ref-store.c b/t/helper/test-ref-store.c\nindex db58f0058..29945f2b8 100644\n--- a/t/helper/test-ref-store.c\n+++ b/t/helper/test-ref-store.c\n@@ -132,7 +132,7 @@ static int cmd_delete_refs(struct ref_store *refs, const char **argv)\n \twhile (*argv)\n \t\tstring_list_append(&refnames, *argv++);\n \n-\tresult = refs_delete_refs(refs, msg, &refnames, flags);\n+\tresult = refs_delete_refs(refs, msg, &refnames, NULL, NULL, flags);\n \tstring_list_clear(&refnames, 0);\n \treturn result;\n }\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"553112","messageId":"6a8401c448f527fd80c162908a2736811a723096.1790196627.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790196627.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v5 2/3] branch, tag: retain old OIDs in batched deletions","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-23T21:04:41Z","receivedAt":"2026-09-23T21:04:48Z","isPatch":true,"body":"Before 8198907795 (use delete_refs when deleting tags or branches,\n2021-01-21), branch and tag deletion passed each resolved old OID to\ndelete_ref(). This prevented the command from deleting a ref that another\nprocess had changed after it was inspected.\n\nThe conversion to batched deletion dropped those old OIDs. Besides making\nthe deletions unconditional, this causes reference-transaction hooks to\nreport zero as both the old and new OID.\n\nBoth commands still resolve the old OIDs before starting the deletion. Pass\nthose values to refs_delete_refs(). This restores the old race protection\nand lets hooks receive useful old values without adding ref reads. If a ref\nchanges concurrently, reject its deletion and preserve the new value.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n builtin/branch.c                 |  6 ++++-\n builtin/tag.c                    | 27 ++++++++++++++------\n t/t1416-ref-transaction-hooks.sh | 44 ++++++++++++++++++++++++++++++++\n 3 files changed, 68 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex baccefc77..4ce1407bc 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -16,6 +16,7 @@\n #include \"commit.h\"\n #include \"gettext.h\"\n #include \"object-name.h\"\n+#include \"oid-array.h\"\n #include \"remote.h\"\n #include \"parse-options.h\"\n #include \"branch.h\"\n@@ -248,6 +249,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \tstruct string_list_item *item;\n \tint branch_name_pos;\n \tconst char *fmt_remotes = \"refs/remotes/%s\";\n@@ -342,6 +344,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n+\t\toid_array_append(&old_oids, &oid);\n \t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n \t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n@@ -352,7 +355,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \n \tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n \t    refs_delete_refs(get_main_ref_store(the_repository), NULL,\n-\t\t\t     &refs_to_delete, NULL, NULL, REF_NO_DEREF))\n+\t\t\t     &refs_to_delete, &old_oids, NULL, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n@@ -377,6 +380,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(describe_ref);\n \t}\n \tstring_list_clear(&refs_to_delete, 0);\n+\toid_array_clear(&old_oids);\n \n \tfree(name);\n \tstrbuf_release(&bname);\ndiff --git a/builtin/tag.c b/builtin/tag.c\nindex 40157e834..07116664d 100644\n--- a/builtin/tag.c\n+++ b/builtin/tag.c\n@@ -105,28 +105,38 @@ static int for_each_tag_name(const char **argv, each_tag_name_fn fn,\n \treturn had_error;\n }\n \n+struct tags_to_delete {\n+\tstruct string_list refs;\n+\tstruct oid_array old_oids;\n+};\n+\n static int collect_tags(const char *name UNUSED, const char *ref,\n \t\t\tconst struct object_id *oid, void *cb_data)\n {\n-\tstruct string_list *ref_list = cb_data;\n+\tstruct tags_to_delete *data = cb_data;\n+\tstruct string_list_item *item;\n \n-\tstring_list_append(ref_list, ref);\n-\tref_list->items[ref_list->nr - 1].util = oiddup(oid);\n+\titem = string_list_append(&data->refs, ref);\n+\titem->util = oiddup(oid);\n+\toid_array_append(&data->old_oids, oid);\n \treturn 0;\n }\n \n static int delete_tags(const char **argv)\n {\n \tint result;\n-\tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n+\tstruct tags_to_delete data = {\n+\t\t.refs = STRING_LIST_INIT_DUP,\n+\t\t.old_oids = OID_ARRAY_INIT,\n+\t};\n \tstruct string_list_item *item;\n \n-\tresult = for_each_tag_name(argv, collect_tags, (void *)&refs_to_delete);\n+\tresult = for_each_tag_name(argv, collect_tags, &data);\n \tif (refs_delete_refs(get_main_ref_store(the_repository), NULL,\n-\t\t\t     &refs_to_delete, NULL, NULL, REF_NO_DEREF))\n+\t\t\t     &data.refs, &data.old_oids, NULL, REF_NO_DEREF))\n \t\tresult = 1;\n \n-\tfor_each_string_list_item(item, &refs_to_delete) {\n+\tfor_each_string_list_item(item, &data.refs) {\n \t\tconst char *name = item->string;\n \t\tstruct object_id *oid = item->util;\n \t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name))\n@@ -136,7 +146,8 @@ static int delete_tags(const char **argv)\n \n \t\tfree(oid);\n \t}\n-\tstring_list_clear(&refs_to_delete, 0);\n+\tstring_list_clear(&data.refs, 0);\n+\toid_array_clear(&data.old_oids);\n \treturn result;\n }\n \ndiff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\nindex 4fe9d9b23..01b5ba8c4 100755\n--- a/t/t1416-ref-transaction-hooks.sh\n+++ b/t/t1416-ref-transaction-hooks.sh\n@@ -14,6 +14,50 @@ test_expect_success setup '\n \tPOST_OID=$(git rev-parse POST)\n '\n \n+test_expect_success 'hook gets old values for batched branch/tag deletion' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\tgit branch to-delete PRE &&\n+\tgit tag delete-tag POST &&\n+\tgit pack-refs --all &&\n+\ttest_hook reference-transaction <<-\\EOF &&\n+\t\tif test \"$1\" = committed\n+\t\tthen\n+\t\t\t# Ignore backend-internal zero-to-zero records.\n+\t\t\twhile read -r old new ref\n+\t\t\tdo\n+\t\t\t\tcase \"$old\" in\n+\t\t\t\t*[!0]*)\n+\t\t\t\t\techo \"$old $new $ref\"\n+\t\t\t\t\t;;\n+\t\t\t\tesac\n+\t\t\tdone >>actual\n+\t\tfi\n+\tEOF\n+\tcat >expect <<-EOF &&\n+\t\t$PRE_OID $ZERO_OID refs/heads/to-delete\n+\t\t$POST_OID $ZERO_OID refs/tags/delete-tag\n+\tEOF\n+\tgit branch -D to-delete &&\n+\tgit tag -d delete-tag &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'branch deletion rejects a concurrent update' '\n+\tgit branch delete-race PRE &&\n+\ttest_hook reference-transaction <<-\\EOF &&\n+\t\tmarker=$(git rev-parse --git-path delete-race-once)\n+\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n+\t\tthen\n+\t\t\t>\"$marker\"\n+\t\t\tgit update-ref refs/heads/delete-race POST\n+\t\tfi\n+\t\texit 0\n+\tEOF\n+\ttest_must_fail git branch -D delete-race 2>err &&\n+\ttest_grep \"is at $POST_OID but expected $PRE_OID\" err &&\n+\ttest_cmp_rev POST refs/heads/delete-race\n+'\n+\n test_expect_success 'hook allows updating ref if successful' '\n \tgit reset --hard PRE &&\n \ttest_hook reference-transaction <<-\\EOF &&\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"553114","messageId":"541da44c371807e22a368cbc60b6fc26be7c64a3.1790196627.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790196627.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v5 3/3] fetch, remote: retain old OIDs when pruning refs","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-23T21:04:42Z","receivedAt":"2026-09-23T21:04:49Z","isPatch":true,"body":"get_stale_heads() records the current value of each stale local ref in its\nnew_oid member. The pruning paths discard that value and request\nunconditional deletion, so reference-transaction hooks receive a null old\nOID.\n\nPass the recorded values into the deletion transactions. If a ref changes\nafter the stale scan, reject that deletion and preserve the new value.\nNon-atomic pruning uses refs_delete_refs(), whose partial-failure mode still\ndeletes unaffected stale refs. An atomic fetch remains all-or-nothing.\n\nContinue reporting successful non-atomic deletions when another deletion is\nrejected, but do not report the rejected ref as deleted or use it when\nchecking for newly dangling symrefs. Use the rejected-ref list returned by\nrefs_delete_refs() so reporting reflects the transaction result without\nadditional ref reads.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n builtin/fetch.c                  |  30 +++++---\n builtin/remote.c                 |  44 +++++++++---\n t/t1416-ref-transaction-hooks.sh | 116 +++++++++++++++++++++++++++++++\n 3 files changed, 174 insertions(+), 16 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex 11caa6b4a..95789edb8 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -1471,22 +1471,29 @@ static int prune_refs(struct display_state *display_state,\n \tstruct ref *ref, *stale_refs = get_stale_heads(rs, ref_map);\n \tstruct strbuf err = STRBUF_INIT;\n \tstruct string_list refnames = STRING_LIST_INIT_NODUP;\n-\n-\tfor (ref = stale_refs; ref; ref = ref->next)\n-\t\tstring_list_append(&refnames, ref->name);\n+\tstruct string_list deleted_refs = STRING_LIST_INIT_NODUP;\n+\tstruct string_list failed_refs = STRING_LIST_INIT_DUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \n \tif (!dry_run) {\n \t\tif (transaction) {\n \t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n-\t\t\t\tresult = ref_transaction_delete(transaction, ref->name, NULL,\n-\t\t\t\t\t\t\t\tNULL, 0, \"fetch: prune\", &err);\n+\t\t\t\tresult = ref_transaction_delete(transaction, ref->name,\n+\t\t\t\t\t\t\t\t&ref->new_oid, NULL, 0,\n+\t\t\t\t\t\t\t\t\"fetch: prune\", &err);\n \t\t\t\tif (result)\n \t\t\t\t\tgoto cleanup;\n \t\t\t}\n \t\t} else {\n+\t\t\tfor (ref = stale_refs; ref; ref = ref->next) {\n+\t\t\t\tstring_list_append(&refnames, ref->name);\n+\t\t\t\toid_array_append(&old_oids, &ref->new_oid);\n+\t\t\t}\n \t\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t\t  \"fetch: prune\", &refnames,\n-\t\t\t\t\t\t  NULL, NULL, 0);\n+\t\t\t\t\t\t  &old_oids, &failed_refs, 0);\n+\t\t\tif (result && !failed_refs.nr)\n+\t\t\t\tgoto cleanup;\n \t\t}\n \t}\n \n@@ -1494,18 +1501,25 @@ static int prune_refs(struct display_state *display_state,\n \t\tint summary_width = transport_summary_width(stale_refs);\n \n \t\tfor (ref = stale_refs; ref; ref = ref->next) {\n+\t\t\tif (string_list_has_string(&failed_refs, ref->name))\n+\t\t\t\tcontinue;\n+\n \t\t\tdisplay_ref_update(display_state, '-', _(\"[deleted]\"), NULL,\n \t\t\t\t\t   _(\"(none)\"), ref->name,\n \t\t\t\t\t   &ref->new_oid, &ref->old_oid,\n \t\t\t\t\t   summary_width);\n+\t\t\tstring_list_append(&deleted_refs, ref->name);\n \t\t}\n-\t\tstring_list_sort(&refnames);\n+\t\tstring_list_sort(&deleted_refs);\n \t\trefs_warn_dangling_symrefs(get_main_ref_store(the_repository),\n-\t\t\t\t\t   stderr, \"   \", dry_run, &refnames);\n+\t\t\t\t\t   stderr, \"   \", dry_run, &deleted_refs);\n \t}\n \n cleanup:\n \tstring_list_clear(&refnames, 0);\n+\tstring_list_clear(&deleted_refs, 0);\n+\tstring_list_clear(&failed_refs, 0);\n+\toid_array_clear(&old_oids);\n \tstrbuf_release(&err);\n \tfree_refs(stale_refs);\n \treturn result;\ndiff --git a/builtin/remote.c b/builtin/remote.c\nindex 840c842e2..2d9ee6db1 100644\n--- a/builtin/remote.c\n+++ b/builtin/remote.c\n@@ -17,6 +17,7 @@\n #include \"refs.h\"\n #include \"refspec.h\"\n #include \"odb.h\"\n+#include \"oid-array.h\"\n #include \"strvec.h\"\n #include \"commit-reach.h\"\n #include \"progress.h\"\n@@ -380,6 +381,11 @@ struct ref_states {\n \tint queried;\n };\n \n+struct stale_ref {\n+\tstruct object_id oid;\n+\tchar name[FLEX_ARRAY];\n+};\n+\n #define REF_STATES_INIT { \\\n \t.new_refs = STRING_LIST_INIT_DUP, \\\n \t.skipped = STRING_LIST_INIT_DUP, \\\n@@ -410,9 +416,13 @@ static int get_ref_states(const struct ref *remote_refs, struct ref_states *stat\n \t}\n \tstale_refs = get_stale_heads(&states->remote->fetch, fetch_map);\n \tfor (ref = stale_refs; ref; ref = ref->next) {\n+\t\tstruct stale_ref *stale_ref;\n \t\tstruct string_list_item *item =\n \t\t\tstring_list_append(&states->stale, abbrev_branch(ref->name));\n-\t\titem->util = xstrdup(ref->name);\n+\n+\t\tFLEX_ALLOC_STR(stale_ref, name, ref->name);\n+\t\toidcpy(&stale_ref->oid, &ref->new_oid);\n+\t\titem->util = stale_ref;\n \t}\n \tfree_refs(stale_refs);\n \tfree_refs(fetch_map);\n@@ -1627,6 +1637,9 @@ static int prune_remote(const char *remote, int dry_run)\n \tint result = 0;\n \tstruct ref_states states = REF_STATES_INIT;\n \tstruct string_list refs_to_prune = STRING_LIST_INIT_NODUP;\n+\tstruct string_list pruned_refs = STRING_LIST_INIT_NODUP;\n+\tstruct string_list failed_refs = STRING_LIST_INIT_DUP;\n+\tstruct oid_array old_oids = OID_ARRAY_INIT;\n \tstruct string_list_item *item;\n \n \tget_remote_ref_states(remote, &states, GET_REF_STATES);\n@@ -1639,17 +1652,27 @@ static int prune_remote(const char *remote, int dry_run)\n \tprintf_ln(_(\"Pruning %s\"), remote);\n \tprintf_ln(_(\"URL: %s\"), states.remote->url.v[0]);\n \n-\tfor_each_string_list_item(item, &states.stale)\n-\t\tstring_list_append(&refs_to_prune, item->util);\n-\tstring_list_sort(&refs_to_prune);\n+\tfor_each_string_list_item(item, &states.stale) {\n+\t\tstruct stale_ref *stale_ref = item->util;\n+\n+\t\tstring_list_append(&refs_to_prune, stale_ref->name);\n+\t\toid_array_append(&old_oids, &stale_ref->oid);\n+\t}\n \n-\tif (!dry_run)\n+\tif (!dry_run) {\n \t\tresult |= refs_delete_refs(get_main_ref_store(the_repository),\n \t\t\t\t\t   \"remote: prune\", &refs_to_prune,\n-\t\t\t\t\t   NULL, NULL, 0);\n+\t\t\t\t\t   &old_oids, &failed_refs, 0);\n+\t\tif (result && !failed_refs.nr)\n+\t\t\tgoto cleanup;\n+\t}\n \n \tfor_each_string_list_item(item, &states.stale) {\n-\t\tconst char *refname = item->util;\n+\t\tstruct stale_ref *stale_ref = item->util;\n+\t\tconst char *refname = stale_ref->name;\n+\n+\t\tif (string_list_has_string(&failed_refs, refname))\n+\t\t\tcontinue;\n \n \t\tif (dry_run)\n \t\t\tprintf_ln(_(\" * [would prune] %s\"),\n@@ -1657,12 +1680,17 @@ static int prune_remote(const char *remote, int dry_run)\n \t\telse\n \t\t\tprintf_ln(_(\" * [pruned] %s\"),\n \t\t\t       abbrev_ref(refname, \"refs/remotes/\"));\n+\t\tstring_list_append(&pruned_refs, refname);\n \t}\n \n \trefs_warn_dangling_symrefs(get_main_ref_store(the_repository),\n-\t\t\t\t   stdout, \" \", dry_run, &refs_to_prune);\n+\t\t\t\t   stdout, \" \", dry_run, &pruned_refs);\n \n+cleanup:\n \tstring_list_clear(&refs_to_prune, 0);\n+\tstring_list_clear(&pruned_refs, 0);\n+\tstring_list_clear(&failed_refs, 0);\n+\toid_array_clear(&old_oids);\n \tfree_remote_ref_states(&states);\n \treturn result;\n }\ndiff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\nindex 01b5ba8c4..e7c16cd87 100755\n--- a/t/t1416-ref-transaction-hooks.sh\n+++ b/t/t1416-ref-transaction-hooks.sh\n@@ -58,6 +58,122 @@ test_expect_success 'branch deletion rejects a concurrent update' '\n \ttest_cmp_rev POST refs/heads/delete-race\n '\n \n+test_expect_success 'hook gets old values when pruning remote refs' '\n+\ttest_when_finished \"rm -rf empty.git prune\" &&\n+\tgit init --bare empty.git &&\n+\tgit init prune &&\n+\t(\n+\t\tcd prune &&\n+\t\tgit remote add origin ../empty.git &&\n+\t\tgit commit --allow-empty -m one &&\n+\t\tone=$(git rev-parse HEAD) &&\n+\t\tgit commit --allow-empty -m two &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\tgit update-ref refs/remotes/origin/remote-prune-z \"$one\" &&\n+\t\tgit update-ref refs/remotes/origin/remote-prune-a \"$two\"\n+\t) &&\n+\ttest_hook -C prune reference-transaction <<-\\EOF &&\n+\t\tif test \"$1\" = committed\n+\t\tthen\n+\t\t\t# Ignore backend-internal zero-to-zero records.\n+\t\t\twhile read -r old new ref\n+\t\t\tdo\n+\t\t\t\tcase \"$old\" in\n+\t\t\t\t*[!0]*)\n+\t\t\t\t\techo \"$old $new $ref\"\n+\t\t\t\t\t;;\n+\t\t\t\tesac\n+\t\t\tdone >>actual\n+\t\tfi\n+\tEOF\n+\t(\n+\t\tcd prune &&\n+\t\tone=$(git rev-parse HEAD^) &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\tgit remote prune origin &&\n+\t\tgit update-ref refs/remotes/origin/fetch-prune \"$one\" &&\n+\t\tgit fetch --prune origin &&\n+\t\tgit update-ref refs/remotes/origin/atomic-prune \"$one\" &&\n+\t\tgit fetch --atomic --prune origin &&\n+\t\tcat >expect <<-EOF &&\n+\t\t\t$two $ZERO_OID refs/remotes/origin/remote-prune-a\n+\t\t\t$one $ZERO_OID refs/remotes/origin/remote-prune-z\n+\t\t\t$one $ZERO_OID refs/remotes/origin/fetch-prune\n+\t\t\t$one $ZERO_OID refs/remotes/origin/atomic-prune\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success 'remote prune reports deletions around a concurrent update' '\n+\ttest_when_finished \"rm -rf race-empty.git race-prune\" &&\n+\tgit init --bare race-empty.git &&\n+\tgit init race-prune &&\n+\t(\n+\t\tcd race-prune &&\n+\t\tgit commit --allow-empty -m one &&\n+\t\tone=$(git rev-parse HEAD) &&\n+\t\tgit commit --allow-empty -m two &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\tgit remote add origin ../race-empty.git &&\n+\t\tgit update-ref refs/remotes/origin/race \"$one\" &&\n+\t\tgit update-ref refs/remotes/origin/other \"$one\"\n+\t) &&\n+\ttest_hook -C race-prune reference-transaction <<-\\EOF &&\n+\t\tmarker=$(git rev-parse --git-path prune-race-once)\n+\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n+\t\tthen\n+\t\t\t>\"$marker\"\n+\t\t\tgit update-ref refs/remotes/origin/race HEAD\n+\t\tfi\n+\t\texit 0\n+\tEOF\n+\t(\n+\t\tcd race-prune &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\ttest_must_fail git remote prune origin >out 2>err &&\n+\t\ttest_cmp_rev \"$two\" refs/remotes/origin/race &&\n+\t\ttest_must_fail git rev-parse --verify refs/remotes/origin/other &&\n+\t\ttest_grep \"\\[pruned\\].*origin/other\" out &&\n+\t\ttest_grep ! \"\\[pruned\\].*origin/race\" out &&\n+\t\ttest_grep \"could not delete reference refs/remotes/origin/race\" err\n+\t)\n+'\n+\n+test_expect_success 'fetch prune reports deletions around a concurrent update' '\n+\ttest_when_finished \"rm -rf fetch-empty.git fetch-prune\" &&\n+\tgit init --bare fetch-empty.git &&\n+\tgit init fetch-prune &&\n+\t(\n+\t\tcd fetch-prune &&\n+\t\tgit commit --allow-empty -m one &&\n+\t\tone=$(git rev-parse HEAD) &&\n+\t\tgit commit --allow-empty -m two &&\n+\t\tgit remote add origin ../fetch-empty.git &&\n+\t\tgit update-ref refs/remotes/origin/race \"$one\" &&\n+\t\tgit update-ref refs/remotes/origin/other \"$one\"\n+\t) &&\n+\ttest_hook -C fetch-prune reference-transaction <<-\\EOF &&\n+\t\tmarker=$(git rev-parse --git-path prune-race-once)\n+\t\tif test \"$1\" = preparing && test ! -e \"$marker\"\n+\t\tthen\n+\t\t\t>\"$marker\"\n+\t\t\tgit update-ref refs/remotes/origin/race HEAD\n+\t\tfi\n+\t\texit 0\n+\tEOF\n+\t(\n+\t\tcd fetch-prune &&\n+\t\ttwo=$(git rev-parse HEAD) &&\n+\t\ttest_must_fail git fetch --prune origin >out 2>err &&\n+\t\ttest_cmp_rev \"$two\" refs/remotes/origin/race &&\n+\t\ttest_must_fail git rev-parse --verify refs/remotes/origin/other &&\n+\t\ttest_grep \"\\[deleted\\].*origin/other\" err &&\n+\t\ttest_grep ! \"\\[deleted\\].*origin/race\" err &&\n+\t\ttest_grep \"could not delete reference refs/remotes/origin/race\" err\n+\t)\n+'\n+\n test_expect_success 'hook allows updating ref if successful' '\n \tgit reset --hard PRE &&\n \ttest_hook reference-transaction <<-\\EOF &&\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"553123","messageId":"xmqqse2z63ak.fsf@gitster.g","threadId":"66351","inReplyTo":"cover.1790196627.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v5 0/3] refs: report old OIDs for batched deletions","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-23T21:55:31Z","receivedAt":"2026-09-23T21:55:34Z","isPatch":true,"body":"Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n\n> Changes since v4:\n>\n>  * Update all refs_delete_refs() call sites in 1/3 for the new signature.\n>  * Verify that 1/3, 1/3--2/3, and the complete series each build with\n>    DEVELOPER=1.\n\nThanks.\n\nIn the past few weeks, I've been trying a new element in my workflow\nto try compiling each and every step of a new round of patches (I\ncannot afford cycles to run full test suite on them, which would\nslow me down too much), after getting scolded by a long-time\ncontributor for queuing a topic whose end state built OK but\nintermediate states did not compile.  This time three patches all\nbuilt OK.\n\n"},{"id":"553163","messageId":"CAOLa=ZTWq6eiqCwUyUhCffTn1=f9pdAip7nsYJMnaPPUuccB8g@mail.gmail.com","threadId":"66351","inReplyTo":"9b76cc2c40a2b1fe727677a9400e3b26ec1ab437.1790196627.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v5 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Karthik Nayak","fromEmail":"karthik.188@gmail.com","sentAt":"2026-09-24T10:04:59Z","receivedAt":"2026-09-24T10:05:03Z","isPatch":true,"body":"Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com> writes:\n\n> refs_delete_refs() performs unconditional deletions, so callers cannot\n> preserve old values that they have already resolved. Consequently,\n> reference-transaction hooks see a null old OID.\n>\n> Let callers provide an optional array of expected old OIDs in parallel with\n> the refname list. Delete the ref at position N only if it still points at\n> the OID at position N. Treat a null OID as an unconditional deletion in\n> ref_transaction_delete(), allowing callers to include broken refs whose old\n> value cannot be resolved.\n\nOkay this makes sense.\n\n> refs_delete_refs() has always promised best-effort deletion. Always use\n> REF_TRANSACTION_ALLOW_FAILURE and report rejected updates so one failure\n> does not prevent independent refs in the batch from being deleted.\n\nYup, this seems in line with what we discussed earlier.\n\n> Let\n> callers request the exact set of failed refs when they need to report\n> partial results\n\nSo callers to `refs_delete_refs()` need to request the set of failed\nrefs? Okay reading on.\n\n> This also completes the conversion that was missed when\n> batched transaction failure support was introduced.\n>\n\nNot sure what you're trying to say here. What conversion was missed and\nhow is that fixed in this commit?\n\n[snip]\n\n> diff --git a/refs.c b/refs.c\n> index 92d5df5b7..13ee2d459 100644\n> --- a/refs.c\n> +++ b/refs.c\n> @@ -16,6 +16,7 @@\n>  #include \"refs/refs-internal.h\"\n>  #include \"hook.h\"\n>  #include \"object-name.h\"\n> +#include \"oid-array.h\"\n>  #include \"odb.h\"\n>  #include \"object.h\"\n>  #include \"path.h\"\n> @@ -1523,7 +1524,7 @@ int ref_transaction_delete(struct ref_transaction *transaction,\n>  \t\t\t   struct strbuf *err)\n>  {\n>  \tif (old_oid && is_null_oid(old_oid))\n> -\t\tBUG(\"delete called with old_oid set to zeros\");\n> +\t\told_oid = NULL;\n\nThis change is totally different from the rest of the commit, I think it\nshould be a precursor with adequate explanation regarding why this is\ndone and why that's okay.\n\nI'm also still of the opinion that this shouldn't be done. A zeroed out\nnull_oid is usually a user bug, where they haven't initialized a `struct\nobject_id` correctly or ignored the return code while reading a ref.\nThis BUG() captures that. We break safety without it.\n\nAnother point is that `old_oid = NULL` is used to say, I don't care what\nthe value of the ref is, delete it. Whereas `old_oid = null_oid` is more\nof, the ref shouldn't exist in the first place. Are we mixing up\nconcerns here?\n\n>  \tif (old_oid && old_target)\n>  \t\tBUG(\"delete called with both old_oid and old_target set\");\n>  \tif (old_target && !(flags & REF_NO_DEREF))\n> @@ -3069,39 +3070,73 @@ void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n>  \t}\n>  }\n>\n> +struct delete_refs_rejection_data {\n> +\tint failures;\n> +\tstruct string_list *failed_refs;\n\nI'm assuming failures is to count the number of refs which failed,\nwouldn't `failed_refs->nr` give us the same result?\n\n> +};\n> +\n> +static void delete_refs_rejection_handler(const char *refname,\n> +\t\t\t\t\t  const struct object_id *old_oid UNUSED,\n> +\t\t\t\t\t  const struct object_id *new_oid UNUSED,\n> +\t\t\t\t\t  const char *old_target UNUSED,\n> +\t\t\t\t\t  const char *new_target UNUSED,\n> +\t\t\t\t\t  enum ref_transaction_error err,\n> +\t\t\t\t\t  const char *details,\n> +\t\t\t\t\t  void *cb_data)\n> +{\n> +\tstruct delete_refs_rejection_data *data = cb_data;\n> +\n> +\twarning(_(\"could not delete reference %s: %s\"), refname,\n> +\t\tdetails ? details : ref_transaction_error_msg(err));\n> +\tdata->failures++;\n> +\tif (data->failed_refs)\n> +\t\tstring_list_insert(data->failed_refs, refname);\n> +}\n\nOh so failed_refs is optional?\n\n> +\n>  int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n> -\t\t     struct string_list *refnames, unsigned int flags)\n> +\t\t     struct string_list *refnames,\n> +\t\t     const struct oid_array *old_oids,\n> +\t\t     struct string_list *failed_refs,\n> +\t\t     unsigned int flags)\n>  {\n> +\tstruct delete_refs_rejection_data rejection_data = {\n> +\t\t.failed_refs = failed_refs,\n> +\t};\n>  \tstruct ref_transaction *transaction;\n>  \tstruct strbuf err = STRBUF_INIT;\n> -\tstruct string_list_item *item;\n> -\tint ret = 0, failures = 0;\n> +\tsize_t i;\n> +\tint ret = 0;\n>  \tchar *msg;\n>\n>  \tif (!refnames->nr)\n>  \t\treturn 0;\n> +\tif (old_oids && old_oids->nr != refnames->nr)\n> +\t\tBUG(\"refname and old OID counts do not match\");\n> +\tif (failed_refs && !failed_refs->strdup_strings)\n> +\t\tBUG(\"failed ref list does not duplicate strings\");\n>\n>  \tmsg = normalize_reflog_message(logmsg);\n>\n> -\t/*\n> -\t * Since we don't check the references' old_oids, the\n> -\t * individual updates can't fail, so we can pack all of the\n> -\t * updates into a single transaction.\n> -\t */\n> -\ttransaction = ref_store_transaction_begin(refs, 0, &err);\n> +\ttransaction = ref_store_transaction_begin(refs,\n> +\t\t\t\t\t\t  REF_TRANSACTION_ALLOW_FAILURE, &err);\n>  \tif (!transaction) {\n>  \t\tret = error(\"%s\", err.buf);\n>  \t\tgoto out;\n>  \t}\n>\n> -\tfor_each_string_list_item(item, refnames) {\n> +\tfor (i = 0; i < refnames->nr; i++) {\n\nNit: we could inline the `size_t` here.\n\n> +\t\tstruct string_list_item *item = &refnames->items[i];\n> +\t\tconst struct object_id *old_oid = old_oids ? &old_oids->oid[i] : NULL;\n> +\n>  \t\tret = ref_transaction_delete(transaction, item->string,\n> -\t\t\t\t\t     NULL, NULL, flags, msg, &err);\n> +\t\t\t\t\t     old_oid, NULL, flags, msg, &err);\n>  \t\tif (ret) {\n>  \t\t\twarning(_(\"could not delete reference %s: %s\"),\n>  \t\t\t\titem->string, err.buf);\n>  \t\t\tstrbuf_reset(&err);\n> -\t\t\tfailures = 1;\n> +\t\t\trejection_data.failures++;\n> +\t\t\tif (failed_refs)\n> +\t\t\t\tstring_list_insert(failed_refs, item->string);\n>  \t\t}\n>  \t}\n>\n> @@ -3113,9 +3148,13 @@ int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n>  \t\telse\n>  \t\t\terror(_(\"could not delete references: %s\"), err.buf);\n>  \t}\n> +\tif (!ret)\n> +\t\tref_transaction_for_each_rejected_update(transaction,\n> +\t\t\t\t\t\t\t delete_refs_rejection_handler,\n> +\t\t\t\t\t\t\t &rejection_data);\n>\n>  out:\n> -\tif (!ret && failures)\n> +\tif (!ret && rejection_data.failures)\n>  \t\tret = -1;\n>  \tref_transaction_free(transaction);\n>  \tstrbuf_release(&err);\n> diff --git a/refs.h b/refs.h\n> index 9979446d1..43f7a32f2 100644\n> --- a/refs.h\n> +++ b/refs.h\n> @@ -9,6 +9,7 @@\n>  struct fsck_options;\n>  struct object_id;\n>  struct ref_store;\n> +struct oid_array;\n>  struct strbuf;\n>  struct string_list;\n>  struct string_list_item;\n> @@ -623,13 +624,26 @@ int refs_delete_ref(struct ref_store *refs, const char *msg,\n>  \t\t    unsigned int flags);\n>\n>  /*\n> - * Delete the specified references. If there are any problems, emit\n> - * errors but attempt to keep going (i.e., the deletes are not done in\n> - * an all-or-nothing transaction). msg and flags are passed through to\n> - * ref_transaction_delete().\n> + * Delete the specified references. If old_oids is non-NULL, it must contain\n> + * an entry for each refname, in the same order. Each non-null OID is used to\n> + * verify the current value of the corresponding reference before deleting\n> + * it. A null OID requests an unconditional deletion, which allows callers to\n> + * include broken refs whose old value cannot be resolved.\n> + *\n> + * If failed_refs is non-NULL, it must be initialized with\n> + * STRING_LIST_INIT_DUP. The names of individual updates that cannot be queued\n> + * or are rejected while processing the best-effort batch are inserted into\n> + * it. A transaction-wide failure is returned without populating the list.\n> + *\n> + * If there are any problems, emit errors but attempt to keep going (i.e.,\n> + * the deletes are not done in an all-or-nothing transaction). msg and flags\n> + * are passed through to ref_transaction_delete().\n>\n>   */\n>  int refs_delete_refs(struct ref_store *refs, const char *msg,\n> -\t\t     struct string_list *refnames, unsigned int flags);\n> +\t\t     struct string_list *refnames,\n> +\t\t     const struct oid_array *old_oids,\n> +\t\t     struct string_list *failed_refs,\n> +\t\t     unsigned int flags);\n>\n>  /** Delete a reflog */\n>  int refs_delete_reflog(struct ref_store *refs, const char *refname);\n> @@ -956,9 +970,10 @@ int ref_transaction_create(struct ref_transaction *transaction,\n>  \t\t\t   struct strbuf *err);\n>\n>  /*\n> - * Add a reference deletion to transaction. If old_oid is non-NULL,\n> - * then it holds the value that the reference should have had before\n> - * the update (which must not be null_oid).\n> + * Add a reference deletion to transaction. If old_oid is non-NULL and not\n> + * null_oid, then it holds the value that the reference should have had before\n> + * the update. Passing null_oid is equivalent to passing NULL and disables the\n> + * old value check.\n>   *\n>   * See the above comment \"Reference transaction updates\" for more\n>   * information.\n> diff --git a/t/helper/test-ref-store.c b/t/helper/test-ref-store.c\n> index db58f0058..29945f2b8 100644\n> --- a/t/helper/test-ref-store.c\n> +++ b/t/helper/test-ref-store.c\n> @@ -132,7 +132,7 @@ static int cmd_delete_refs(struct ref_store *refs, const char **argv)\n>  \twhile (*argv)\n>  \t\tstring_list_append(&refnames, *argv++);\n>\n> -\tresult = refs_delete_refs(refs, msg, &refnames, flags);\n> +\tresult = refs_delete_refs(refs, msg, &refnames, NULL, NULL, flags);\n>  \tstring_list_clear(&refnames, 0);\n>  \treturn result;\n>  }\n> --\n> 2.39.3 (Apple Git-146)\n"},{"id":"553164","messageId":"arUEhkuC448hUTCw@pks.im","threadId":"66351","inReplyTo":"9b76cc2c40a2b1fe727677a9400e3b26ec1ab437.1790196627.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v5 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-09-24T11:07:50Z","receivedAt":"2026-09-24T11:07:57Z","isPatch":true,"body":"On Wed, Sep 23, 2026 at 11:04:40PM +0200, Maciej Ciemborowicz wrote:\n> refs_delete_refs() performs unconditional deletions, so callers cannot\n> preserve old values that they have already resolved. Consequently,\n> reference-transaction hooks see a null old OID.\n> \n> Let callers provide an optional array of expected old OIDs in parallel with\n> the refname list. Delete the ref at position N only if it still points at\n> the OID at position N. Treat a null OID as an unconditional deletion in\n> ref_transaction_delete(), allowing callers to include broken refs whose old\n> value cannot be resolved.\n> \n> refs_delete_refs() has always promised best-effort deletion. Always use\n> REF_TRANSACTION_ALLOW_FAILURE and report rejected updates so one failure\n> does not prevent independent refs in the batch from being deleted. Let\n> callers request the exact set of failed refs when they need to report\n> partial results. This also completes the conversion that was missed when\n> batched transaction failure support was introduced.\n\nTaking a step back though... the only reason that this function really\nexists is to provide a convenience wrapper that deletes references while\nwe don't care for the old state. If we want to not do that anymore and\ninstead want to expect a specific old OID, is this function still the\nright function to use?\n\nIn other words, shouldn't the callers instead be updated to drive their\nown transaction if they want more complex behaviour?\n\n> diff --git a/refs.c b/refs.c\n> index 92d5df5b7..13ee2d459 100644\n> --- a/refs.c\n> +++ b/refs.c\n> @@ -1523,7 +1524,7 @@ int ref_transaction_delete(struct ref_transaction *transaction,\n>  \t\t\t   struct strbuf *err)\n>  {\n>  \tif (old_oid && is_null_oid(old_oid))\n> -\t\tBUG(\"delete called with old_oid set to zeros\");\n> +\t\told_oid = NULL;\n>  \tif (old_oid && old_target)\n>  \t\tBUG(\"delete called with both old_oid and old_target set\");\n>  \tif (old_target && !(flags & REF_NO_DEREF))\n\nI'm not a huge fan of starting to treat a null OID as something other\nthan \"this branch should not exist\". Everywhere else it still does, so\nmixing this feels fishy to me.\n\nAlso, this change wouldn't have to exist if we instead started to drive\na proper transaction.\n\n> @@ -3069,39 +3070,73 @@ void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n>  \t}\n>  }\n>  \n> +struct delete_refs_rejection_data {\n> +\tint failures;\n> +\tstruct string_list *failed_refs;\n> +};\n> +\n> +static void delete_refs_rejection_handler(const char *refname,\n> +\t\t\t\t\t  const struct object_id *old_oid UNUSED,\n> +\t\t\t\t\t  const struct object_id *new_oid UNUSED,\n> +\t\t\t\t\t  const char *old_target UNUSED,\n> +\t\t\t\t\t  const char *new_target UNUSED,\n> +\t\t\t\t\t  enum ref_transaction_error err,\n> +\t\t\t\t\t  const char *details,\n> +\t\t\t\t\t  void *cb_data)\n> +{\n> +\tstruct delete_refs_rejection_data *data = cb_data;\n> +\n> +\twarning(_(\"could not delete reference %s: %s\"), refname,\n> +\t\tdetails ? details : ref_transaction_error_msg(err));\n> +\tdata->failures++;\n> +\tif (data->failed_refs)\n> +\t\tstring_list_insert(data->failed_refs, refname);\n> +}\n> +\n>  int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n> -\t\t     struct string_list *refnames, unsigned int flags)\n> +\t\t     struct string_list *refnames,\n> +\t\t     const struct oid_array *old_oids,\n> +\t\t     struct string_list *failed_refs,\n> +\t\t     unsigned int flags)\n\nAnd here we also have to yield failed refs now because we don't have a\nbetter mechanism. Same as before though, if we used a ref transaction\nwe'd already have that mechanism.\n\nSo overall I'm not quite on board with this change, as I think it's going\ndown the wrong route. If you want more complex behaviour when deleting\nrefs you should use a ref transaction, as it would already handle all of\nwhat you're trying to do here.\n\nPatrick\n"},{"id":"553165","messageId":"arUEo7bzFJfOTTFY@pks.im","threadId":"66351","inReplyTo":"6a8401c448f527fd80c162908a2736811a723096.1790196627.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v5 2/3] branch, tag: retain old OIDs in batched deletions","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-09-24T11:08:19Z","receivedAt":"2026-09-24T11:08:25Z","isPatch":true,"body":"On Wed, Sep 23, 2026 at 11:04:41PM +0200, Maciej Ciemborowicz wrote:\n> Before 8198907795 (use delete_refs when deleting tags or branches,\n> 2021-01-21), branch and tag deletion passed each resolved old OID to\n> delete_ref(). This prevented the command from deleting a ref that another\n> process had changed after it was inspected.\n> \n> The conversion to batched deletion dropped those old OIDs. Besides making\n> the deletions unconditional, this causes reference-transaction hooks to\n> report zero as both the old and new OID.\n> \n> Both commands still resolve the old OIDs before starting the deletion. Pass\n> those values to refs_delete_refs(). This restores the old race protection\n> and lets hooks receive useful old values without adding ref reads. If a ref\n> changes concurrently, reject its deletion and preserve the new value.\n\nHm. The motivation makes sense to me, but I have to wonder whether we're\napproaching it on the wrong level. With your proposed changes, we're now\nnot force-deleting the refs anymore, which is a user-visible change in\nbehaviour.\n\nWhat you're after though is to always have an old object ID available\nwhen the reference-transaction hook kicks in. But if that's the goal,\nshouldn't we consider whether we can instead resolve the old value\nduring the transaction and queue that for the reftx hook, regardless of\nwhether or not the user has asked for an old object ID? That would now\ncover _all_ users that modify refs without us having to update every\nsingle callsite.\n\nSure, strictly speaking it's a backwards-incompatible change. But we've\nalways considered the reftx hook to be exposing internals, so we aren't\nall that strict about retaining its behaviour and have allowed changes\nin behaviour in the past. So I wouldn't mind if we adapted the hook to\nalways yield the old object ID.\n\nPatrick\n"},{"id":"553206","messageId":"xmqq8q4q4nh4.fsf@gitster.g","threadId":"66351","inReplyTo":"CAOLa=ZTWq6eiqCwUyUhCffTn1=f9pdAip7nsYJMnaPPUuccB8g@mail.gmail.com","subject":"Re: [PATCH v5 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-24T16:34:47Z","receivedAt":"2026-09-24T16:34:49Z","isPatch":true,"body":"Karthik Nayak <karthik.188@gmail.com> writes:\n\n> I'm also still of the opinion that this shouldn't be done. A zeroed out\n> null_oid is usually a user bug, where they haven't initialized a `struct\n> object_id` correctly or ignored the return code while reading a ref.\n\n... unless they are using an element in an object_array and want to\nselectively have object names to some but not all of the elements in\nthe array.  In such a use case, a pointer to a null_oid is just as\ngood a representation as a NULL pointer of \"N/A\" for a parameter to\na function like this one that takes an optional object name.  You\ncould force each such callers to notice the entry they are about to\ncall this function with has a null_oid and pass a NULL instead, but\nwhy force the caller to do so when the callee is capable of doing so\ncentrally?\n\n"},{"id":"553209","messageId":"xmqq4ife4mzc.fsf@gitster.g","threadId":"66351","inReplyTo":"arUEhkuC448hUTCw@pks.im","subject":"Re: [PATCH v5 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-24T16:45:27Z","receivedAt":"2026-09-24T16:45:30Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> On Wed, Sep 23, 2026 at 11:04:40PM +0200, Maciej Ciemborowicz wrote:\n>> refs_delete_refs() performs unconditional deletions, so callers cannot\n>> preserve old values that they have already resolved. Consequently,\n>> reference-transaction hooks see a null old OID.\n\nI wasn't paying attention when I gave my reviews, but the above\npuzzles me.\n\n\"callers cannot preserve\", meaning \"after deletion the values cannot\nbe read anymore\"?  Of course, but then callers can read them\nbeforehand and use the stored value when calling hooks later.\n\nPatrick, do you understand these three lines above?  I don't, and I\nam asking you because below what you say mostly seems to make sense.\n\n>> refs_delete_refs() has always promised best-effort deletion. Always use\n>> REF_TRANSACTION_ALLOW_FAILURE and report rejected updates so one failure\n>> does not prevent independent refs in the batch from being deleted. Let\n>> callers request the exact set of failed refs when they need to report\n>> partial results. This also completes the conversion that was missed when\n>> batched transaction failure support was introduced.\n>\n> Taking a step back though... the only reason that this function really\n> exists is to provide a convenience wrapper that deletes references while\n> we don't care for the old state. If we want to not do that anymore and\n> instead want to expect a specific old OID, is this function still the\n> right function to use?\n>\n> In other words, shouldn't the callers instead be updated to drive their\n> own transaction if they want more complex behaviour?\n\nThat is a valid question to ask.\n\nI think the bulk deletion of refs is done via this function, so you\ncertainly can update those callers of it that wants to protect\nreferences that are being updated from getting deleted with their\nown transaction and remember what refs are and are not removed, but\nI am not so convinced as you seem to be that adding an optional\ntransaction support to the existing function they all call to do so,\nas the amount of the necessary call would be more or less the same.\n\n>>  int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n>> -\t\t     struct string_list *refnames, unsigned int flags)\n>> +\t\t     struct string_list *refnames,\n>> +\t\t     const struct oid_array *old_oids,\n>> +\t\t     struct string_list *failed_refs,\n>> +\t\t     unsigned int flags)\n>\n> And here we also have to yield failed refs now because we don't have a\n> better mechanism. Same as before though, if we used a ref transaction\n> we'd already have that mechanism.\n>\n> So overall I'm not quite on board with this change, as I think it's going\n> down the wrong route. If you want more complex behaviour when deleting\n> refs you should use a ref transaction, as it would already handle all of\n> what you're trying to do here.\n\nI am neutral and would need to see what the code would look like to\ndecide which one is more reasonable.\n\nThanks.\n"},{"id":"553229","messageId":"CACQ=SRH2ZRgy9RY=kXcJFwBwkHdoqCHuCturg4B1XqRWCSD_eg@mail.gmail.com","threadId":"66351","inReplyTo":"CAOLa=ZTWq6eiqCwUyUhCffTn1=f9pdAip7nsYJMnaPPUuccB8g@mail.gmail.com","subject":"Re: [PATCH v5 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-24T19:43:42Z","receivedAt":"2026-09-24T19:43:56Z","isPatch":true,"body":"On Thu, Sep 24, 2026 at 12:05 PM Karthik Nayak <karthik.188@gmail.com> wrote:\n\n> This also completes the conversion that was missed when\n> batched transaction failure support was introduced.\n\nAgreed, that sentence is unclear. I will try to remove the batch deletion\nAPI changes, along with this sentence and the caller-specific approach.\n\n> This change is totally different from the rest of the commit, I think it\n> should be a precursor with adequate explanation regarding why this is\n> done and why that's okay.\n>\n> I'm also still of the opinion that this shouldn't be done.\n\nI will try to leave ref_transaction_delete() and its null_oid\nBUG() unchanged. Instead, store the value observed for the\nhook in separate fields that do not set REF_HAVE_OLD and therefore do not\nconstrain the transaction.\n\n> I'm assuming failures is to count the number of refs which failed,\n> wouldn't failed_refs->nr give us the same result?\n\nfailed_refs is optional, which is why a separate counter is needed. If I\nmove the solution into the common transaction layer, both the failed_refs\nparameter and the counter should no longer be needed.\n\n> Nit: we could inline the size_t here.\n\nI will fix this in the next version.\n\nThanks for the review.\n- Maciej Ciemborowicz\n"},{"id":"553231","messageId":"CACQ=SRG4GEOzym27GxkR7Cu+Rq5o0Wbr75AU5sA7awKa2eHuUQ@mail.gmail.com","threadId":"66351","inReplyTo":"arUEhkuC448hUTCw@pks.im","subject":"Re: [PATCH v5 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-24T19:56:49Z","receivedAt":"2026-09-24T19:57:07Z","isPatch":true,"body":"> Taking a step back though... the only reason that this function really\n> exists is to provide a convenience wrapper that deletes references while\n> we don't care for the old state. If we want to not do that anymore and\n> instead want to expect a specific old OID, is this function still the\n> right function to use?\n\nAgreed. Extending refs_delete_refs() seems to be the wrong layer for this.\n\nI will try to rework the patch so that refs_delete_refs(),\nref_transaction_delete(), and callers remain unchanged. Instead, the\ncommon transaction hook layer could record a separate observed old value\nfor updates whose callers did not supply one. That value would be used only\nas hook input and would not set REF_HAVE_OLD or otherwise constrain the\nupdate.\n\nThis should also allow me to remove the failed_refs interface and the\nspecial handling of null_oid from the current version.\n\nThanks,\n- Maciej Ciemborowicz\n\n\nOn Thu, Sep 24, 2026 at 1:07 PM Patrick Steinhardt <ps@pks.im> wrote:\n>\n> On Wed, Sep 23, 2026 at 11:04:40PM +0200, Maciej Ciemborowicz wrote:\n> > refs_delete_refs() performs unconditional deletions, so callers cannot\n> > preserve old values that they have already resolved. Consequently,\n> > reference-transaction hooks see a null old OID.\n> >\n> > Let callers provide an optional array of expected old OIDs in parallel with\n> > the refname list. Delete the ref at position N only if it still points at\n> > the OID at position N. Treat a null OID as an unconditional deletion in\n> > ref_transaction_delete(), allowing callers to include broken refs whose old\n> > value cannot be resolved.\n> >\n> > refs_delete_refs() has always promised best-effort deletion. Always use\n> > REF_TRANSACTION_ALLOW_FAILURE and report rejected updates so one failure\n> > does not prevent independent refs in the batch from being deleted. Let\n> > callers request the exact set of failed refs when they need to report\n> > partial results. This also completes the conversion that was missed when\n> > batched transaction failure support was introduced.\n>\n> Taking a step back though... the only reason that this function really\n> exists is to provide a convenience wrapper that deletes references while\n> we don't care for the old state. If we want to not do that anymore and\n> instead want to expect a specific old OID, is this function still the\n> right function to use?\n>\n> In other words, shouldn't the callers instead be updated to drive their\n> own transaction if they want more complex behaviour?\n>\n> > diff --git a/refs.c b/refs.c\n> > index 92d5df5b7..13ee2d459 100644\n> > --- a/refs.c\n> > +++ b/refs.c\n> > @@ -1523,7 +1524,7 @@ int ref_transaction_delete(struct ref_transaction *transaction,\n> >                          struct strbuf *err)\n> >  {\n> >       if (old_oid && is_null_oid(old_oid))\n> > -             BUG(\"delete called with old_oid set to zeros\");\n> > +             old_oid = NULL;\n> >       if (old_oid && old_target)\n> >               BUG(\"delete called with both old_oid and old_target set\");\n> >       if (old_target && !(flags & REF_NO_DEREF))\n>\n> I'm not a huge fan of starting to treat a null OID as something other\n> than \"this branch should not exist\". Everywhere else it still does, so\n> mixing this feels fishy to me.\n>\n> Also, this change wouldn't have to exist if we instead started to drive\n> a proper transaction.\n>\n> > @@ -3069,39 +3070,73 @@ void ref_transaction_for_each_rejected_update(struct ref_transaction *transactio\n> >       }\n> >  }\n> >\n> > +struct delete_refs_rejection_data {\n> > +     int failures;\n> > +     struct string_list *failed_refs;\n> > +};\n> > +\n> > +static void delete_refs_rejection_handler(const char *refname,\n> > +                                       const struct object_id *old_oid UNUSED,\n> > +                                       const struct object_id *new_oid UNUSED,\n> > +                                       const char *old_target UNUSED,\n> > +                                       const char *new_target UNUSED,\n> > +                                       enum ref_transaction_error err,\n> > +                                       const char *details,\n> > +                                       void *cb_data)\n> > +{\n> > +     struct delete_refs_rejection_data *data = cb_data;\n> > +\n> > +     warning(_(\"could not delete reference %s: %s\"), refname,\n> > +             details ? details : ref_transaction_error_msg(err));\n> > +     data->failures++;\n> > +     if (data->failed_refs)\n> > +             string_list_insert(data->failed_refs, refname);\n> > +}\n> > +\n> >  int refs_delete_refs(struct ref_store *refs, const char *logmsg,\n> > -                  struct string_list *refnames, unsigned int flags)\n> > +                  struct string_list *refnames,\n> > +                  const struct oid_array *old_oids,\n> > +                  struct string_list *failed_refs,\n> > +                  unsigned int flags)\n>\n> And here we also have to yield failed refs now because we don't have a\n> better mechanism. Same as before though, if we used a ref transaction\n> we'd already have that mechanism.\n>\n> So overall I'm not quite on board with this change, as I think it's going\n> down the wrong route. If you want more complex behaviour when deleting\n> refs you should use a ref transaction, as it would already handle all of\n> what you're trying to do here.\n>\n> Patrick\n"},{"id":"553238","messageId":"CACQ=SRGA5j9ChJ0uM4=5iCwEDgWQEdhhrD8OF9=RJ7XBxqb0dQ@mail.gmail.com","threadId":"66351","inReplyTo":"xmqq4ife4mzc.fsf@gitster.g","subject":"Re: [PATCH v5 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-24T20:13:33Z","receivedAt":"2026-09-24T20:13:47Z","isPatch":true,"body":"On Thu, Sep 24, 2026 at 6:45 PM Junio C Hamano <gitster@pobox.com> wrote:\n\n> \"callers cannot preserve\", meaning \"after deletion the values cannot\n> be read anymore\"?  Of course, but then callers can read them\n> beforehand and use the stored value when calling hooks later.\n\nI meant that refs_delete_refs() has no parameter for\nthe values its callers have already resolved, so those values are not\ncarried into the transaction and are therefore not available to the hook.\n\nPatrick's later suggestion to resolve missing old values in the common hook\nlayer seems to avoid this API question altogether.\n\nThanks,\n- Maciej Ciemborowicz\n"},{"id":"553248","messageId":"cover.1790269745.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790196627.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v6 0/1] refs: report old values to transaction hooks","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-24T22:33:09Z","receivedAt":"2026-09-24T22:33:15Z","isPatch":true,"body":"This follows up on the reference-transaction bug report at [1].\n\nThe reference-transaction hook currently reports an all-zero old object ID\nwhen a caller queues an unconditional update. As a result, batched branch,\ntag, and remote-ref deletions report zero for both the old and new values.\n\nEarlier versions changed refs_delete_refs() and its callers to pass expected\nold OIDs. As Patrick pointed out, that makes previously unconditional\ndeletions conditional and adds complexity at the wrong layer.\n\nResolve the old value in the common transaction hook layer instead. Keep it\nseparate from the caller-supplied old_oid and old_target so it cannot\nconstrain the update. Resolve it before the \"preparing\" hook and refresh it\nafter the backend locks the references, allowing later phases to report the\nvalue protected by those locks. Do this work only when a\nreference-transaction hook exists.\n\nChanges since v5:\n\n * Replace the three-patch caller-specific approach with one transaction\n   layer change.\n * Leave refs_delete_refs(), ref_transaction_delete(), and all command call\n   sites unchanged.\n * Preserve unconditional deletion semantics and test a concurrent update\n   from the \"preparing\" hook.\n * Report observed old values for all unconditional transactions, including\n   symbolic ref targets.\n * Document that the unlocked value reported in \"preparing\" may differ from\n   later phases if the reference changes before it is locked.\n\nThe full test suite passes (1060 files, 34660 tests). The focused\nreference-transaction tests also pass with SHA-1 and SHA-256 using both the\nfiles and reftable backends.\n\n[1] https://lore.kernel.org/git/CACQ=SRGTTdQ+dHXhN6F52dBv5KxZBRfk_Em2fvmEmGJDoB6oTg@mail.gmail.com/\n\nMaciej Ciemborowicz (1):\n  refs: report old values to transaction hooks\n\n Documentation/githooks.adoc      | 17 +++++----\n refs.c                           | 54 ++++++++++++++++++++++++---\n refs/refs-internal.h             |  8 ++++\n t/t1416-ref-transaction-hooks.sh | 64 +++++++++++++++++++++++++++++++-\n 4 files changed, 128 insertions(+), 15 deletions(-)\n\nRange-diff against v5:\n1:  9b76cc2c40 < -:  ---------- refs: allow callers to supply old OIDs for batch deletion\n2:  6a8401c448 < -:  ---------- branch, tag: retain old OIDs in batched deletions\n3:  541da44c37 < -:  ---------- fetch, remote: retain old OIDs when pruning refs\n-:  ---------- > 1:  2af3eeadd1 refs: report old values to transaction hooks\n-- \n2.39.3 (Apple Git-146)\n"},{"id":"553249","messageId":"2af3eeadd18806c5298d53072428656885cff89d.1790269745.git.maciej.ciemborowicz@gmail.com","threadId":"66351","inReplyTo":"cover.1790269745.git.maciej.ciemborowicz@gmail.com","subject":"[PATCH v6 1/1] refs: report old values to transaction hooks","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-24T22:33:10Z","receivedAt":"2026-09-24T22:33:18Z","isPatch":true,"body":"The reference-transaction hook reports an all-zero old object ID whenever\nthe caller does not supply an expected old value. Consequently, batched\nbranch, tag, and remote-ref deletions report zero as both the old and new\nobject IDs because refs_delete_refs() intentionally queues unconditional\ndeletions.\n\nChanging those callers to provide expected old values would make the\ndeletions conditional and alter existing command behavior. Instead, record\nthe current raw ref value separately for the hook. Read it before the\n\"preparing\" hook, then refresh it after the backend has locked the refs so\nthat the \"prepared\" and later phases report the value protected by the\ntransaction's locks. Keep this value separate from old_oid and old_target so\nit does not set REF_HAVE_OLD or otherwise constrain the update.\n\nOnly resolve these values when a reference-transaction hook exists. Preserve\nsymbolic refs as targets, consistent with the hook's existing symref format.\nDocument that an unlocked \"preparing\" value may differ from later phases if\nthe ref changes before it is locked.\n\nAdd coverage for batched branch deletion, tag deletion, and remote pruning.\nAlso exercise a concurrent update from the \"preparing\" hook to verify that\nthe deletion remains unconditional while later hook phases report the value\nactually removed.\n\nSigned-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n---\n Documentation/githooks.adoc      | 17 +++++----\n refs.c                           | 54 ++++++++++++++++++++++++---\n refs/refs-internal.h             |  8 ++++\n t/t1416-ref-transaction-hooks.sh | 64 +++++++++++++++++++++++++++++++-\n 4 files changed, 128 insertions(+), 15 deletions(-)\n\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex ed045940d1..f60dd1d582 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -509,14 +509,15 @@ receives on standard input a line of the format:\n   <old-value> SP <new-value> SP <ref-name> LF\n \n where `<old-value>` is the old object name passed into the reference\n-transaction, `<new-value>` is the new object name to be stored in the\n-ref and `<ref-name>` is the full name of the ref. When force updating\n-the reference regardless of its current value or when the reference is\n-to be created anew, `<old-value>` is the all-zeroes object name. To\n-distinguish these cases, you can inspect the current value of\n-`<ref-name>` via `git rev-parse`. During the \"preparing\" state, symbolic\n-references are not resolved: `<ref-name>` will reflect the symbolic reference\n-itself rather than the object it points to.\n+transaction, or the value observed while preparing the transaction if no\n+old object name was passed. `<new-value>` is the new object name to be\n+stored in the ref and `<ref-name>` is the full name of the ref. When the\n+reference does not exist, `<old-value>` is the all-zeroes object name.\n+Because references are not yet locked in the \"preparing\" state, its observed\n+old value may differ from the value reported in subsequent states if the\n+reference changes before it is locked. During the \"preparing\" state,\n+symbolic references are not resolved: `<ref-name>` will reflect the symbolic\n+reference itself rather than the object it points to.\n \n For symbolic reference updates the `<old_value>` and `<new-value>`\n fields could denote references instead of objects. A reference will be\ndiff --git a/refs.c b/refs.c\nindex 92d5df5b71..d2d25402c3 100644\n--- a/refs.c\n+++ b/refs.c\n@@ -1260,6 +1260,7 @@ void ref_transaction_free(struct ref_transaction *transaction)\n \t\tfree(transaction->updates[i]->committer_info);\n \t\tfree((char *)transaction->updates[i]->new_target);\n \t\tfree((char *)transaction->updates[i]->old_target);\n+\t\tfree(transaction->updates[i]->hook_old_target);\n \t\tfree((char *)transaction->updates[i]->rejection_details);\n \t\tfree(transaction->updates[i]);\n \t}\n@@ -2606,6 +2607,8 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_\n \tstruct transaction_feed_cb_data *feed_cb_data = pp_task_cb;\n \tstruct strbuf *buf = &feed_cb_data->buf;\n \tstruct ref_update *update;\n+\tconst struct object_id *old_oid;\n+\tconst char *old_target;\n \tsize_t i = feed_cb_data->index++;\n \tint ret;\n \n@@ -2619,12 +2622,18 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_\n \n \tstrbuf_reset(buf);\n \n-\tif (!(update->flags & REF_HAVE_OLD))\n-\t\tstrbuf_addf(buf, \"%s \", oid_to_hex(null_oid(transaction->ref_store->repo->hash_algo)));\n-\telse if (update->old_target)\n-\t\tstrbuf_addf(buf, \"ref:%s \", update->old_target);\n+\tif (update->flags & REF_HAVE_OLD) {\n+\t\told_oid = &update->old_oid;\n+\t\told_target = update->old_target;\n+\t} else {\n+\t\told_oid = &update->hook_old_oid;\n+\t\told_target = update->hook_old_target;\n+\t}\n+\n+\tif (old_target)\n+\t\tstrbuf_addf(buf, \"ref:%s \", old_target);\n \telse\n-\t\tstrbuf_addf(buf, \"%s \", oid_to_hex(&update->old_oid));\n+\t\tstrbuf_addf(buf, \"%s \", oid_to_hex(old_oid));\n \n \tif (!(update->flags & REF_HAVE_NEW))\n \t\tstrbuf_addf(buf, \"%s \", oid_to_hex(null_oid(transaction->ref_store->repo->hash_algo)));\n@@ -2660,6 +2669,36 @@ static void transaction_feed_cb_data_free(void *data)\n \tfree(d);\n }\n \n+static void resolve_transaction_hook_old_values(struct ref_transaction *transaction)\n+{\n+\tstruct ref_store *refs = transaction->ref_store;\n+\tstruct strbuf referent = STRBUF_INIT;\n+\n+\tif (!hook_exists(refs->repo, \"reference-transaction\"))\n+\t\treturn;\n+\n+\tfor (size_t i = 0; i < transaction->nr; i++) {\n+\t\tstruct ref_update *update = transaction->updates[i];\n+\t\tunsigned int type = 0;\n+\t\tint failure_errno;\n+\n+\t\tif (update->flags & (REF_HAVE_OLD | REF_LOG_ONLY))\n+\t\t\tcontinue;\n+\n+\t\toidclr(&update->hook_old_oid, refs->repo->hash_algo);\n+\t\tFREE_AND_NULL(update->hook_old_target);\n+\t\tstrbuf_reset(&referent);\n+\n+\t\tif (!refs_read_raw_ref(refs, update->refname,\n+\t\t\t\t       &update->hook_old_oid, &referent,\n+\t\t\t\t       &type, &failure_errno) &&\n+\t\t    (type & REF_ISSYMREF))\n+\t\t\tupdate->hook_old_target = xstrdup(referent.buf);\n+\t}\n+\n+\tstrbuf_release(&referent);\n+}\n+\n static int run_transaction_hook(struct ref_transaction *transaction,\n \t\t\t\tconst char *state)\n {\n@@ -2709,6 +2748,8 @@ int ref_transaction_prepare(struct ref_transaction *transaction,\n \tif (ref_update_reject_duplicates(&transaction->refnames, err))\n \t\treturn REF_TRANSACTION_ERROR_GENERIC;\n \n+\tresolve_transaction_hook_old_values(transaction);\n+\n \t/* Preparing checks before locking references */\n \tret = run_transaction_hook(transaction, \"preparing\");\n \tif (ret) {\n@@ -2720,6 +2761,9 @@ int ref_transaction_prepare(struct ref_transaction *transaction,\n \tif (ret)\n \t\treturn ret;\n \n+\t/* Refresh old values now that the references are locked. */\n+\tresolve_transaction_hook_old_values(transaction);\n+\n \tret = run_transaction_hook(transaction, \"prepared\");\n \tif (ret) {\n \t\tref_transaction_abort(transaction, err);\ndiff --git a/refs/refs-internal.h b/refs/refs-internal.h\nindex c3ac7b556f..a7471b2481 100644\n--- a/refs/refs-internal.h\n+++ b/refs/refs-internal.h\n@@ -99,6 +99,14 @@ struct ref_update {\n \t */\n \tstruct object_id old_oid;\n \n+\t/*\n+\t * The old value observed for the reference-transaction hook when the\n+\t * caller did not provide an expected old value. Unlike old_oid and\n+\t * old_target, these fields do not constrain the update.\n+\t */\n+\tstruct object_id hook_old_oid;\n+\tchar *hook_old_target;\n+\n \t/*\n \t * If the new_oid points to a tag object, set this to the peeled\n \t * object ID for optimized retrieval without needed to hit the odb.\ndiff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\nindex 4fe9d9b234..fcc7404943 100755\n--- a/t/t1416-ref-transaction-hooks.sh\n+++ b/t/t1416-ref-transaction-hooks.sh\n@@ -14,6 +14,66 @@ test_expect_success setup '\n \tPOST_OID=$(git rev-parse POST)\n '\n \n+test_expect_success 'hook gets old values for batched unconditional deletion' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_when_finished \"git remote remove origin && rm -rf empty.git\" &&\n+\tgit init --bare empty.git &&\n+\tgit remote add origin ./empty.git &&\n+\tgit branch delete-a PRE &&\n+\tgit branch delete-b POST &&\n+\tgit tag delete-tag POST &&\n+\tgit update-ref refs/remotes/origin/to-prune $PRE_OID &&\n+\ttest_hook reference-transaction <<-\\EOF &&\n+\t\tif test \"$1\" = committed\n+\t\tthen\n+\t\t\tcat >>actual\n+\t\tfi\n+\tEOF\n+\tgit branch -D delete-a delete-b &&\n+\tgit tag -d delete-tag &&\n+\tgit remote prune origin &&\n+\tcat >expect <<-EOF &&\n+\t\t$PRE_OID $ZERO_OID refs/heads/delete-a\n+\t\t$POST_OID $ZERO_OID refs/heads/delete-b\n+\t\t$POST_OID $ZERO_OID refs/tags/delete-tag\n+\t\t$PRE_OID $ZERO_OID refs/remotes/origin/to-prune\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'unconditional deletion remains unconditional' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_when_finished \"rm -f \\\"$(git rev-parse --git-path delete-race-once)\\\"\" &&\n+\tgit branch delete-race PRE &&\n+\ttest_hook reference-transaction <<-\\EOF &&\n+\t\tstate=$1\n+\t\twhile read -r old new ref\n+\t\tdo\n+\t\t\tif test \"$state\" != aborted\n+\t\t\tthen\n+\t\t\t\tcase \"$new\" in\n+\t\t\t\t*[!0]*) ;;\n+\t\t\t\t*) echo \"$state $old $new $ref\" >>actual ;;\n+\t\t\t\tesac\n+\t\t\tfi\n+\t\tdone\n+\t\tmarker=$(git rev-parse --git-path delete-race-once)\n+\t\tif test \"$state\" = preparing && test ! -e \"$marker\"\n+\t\tthen\n+\t\t\t>\"$marker\"\n+\t\t\tgit update-ref refs/heads/delete-race POST\n+\t\tfi\n+\tEOF\n+\tgit branch -D delete-race &&\n+\tcat >expect <<-EOF &&\n+\t\tpreparing $PRE_OID $ZERO_OID refs/heads/delete-race\n+\t\tprepared $POST_OID $ZERO_OID refs/heads/delete-race\n+\t\tcommitted $POST_OID $ZERO_OID refs/heads/delete-race\n+\tEOF\n+\ttest_cmp expect actual &&\n+\ttest_must_fail git show-ref --verify refs/heads/delete-race\n+'\n+\n test_expect_success 'hook allows updating ref if successful' '\n \tgit reset --hard PRE &&\n \ttest_hook reference-transaction <<-\\EOF &&\n@@ -65,7 +125,7 @@ test_expect_success 'hook gets all queued updates in prepared state' '\n \t\tfi\n \tEOF\n \tcat >expect <<-EOF &&\n-\t\t$ZERO_OID $POST_OID refs/heads/main\n+\t\t$PRE_OID $POST_OID refs/heads/main\n \tEOF\n \tgit update-ref HEAD POST <<-EOF &&\n \t\tupdate HEAD $ZERO_OID $POST_OID\n@@ -87,7 +147,7 @@ test_expect_success 'hook gets all queued updates in committed state' '\n \t\tfi\n \tEOF\n \tcat >expect <<-EOF &&\n-\t\t$ZERO_OID $POST_OID refs/heads/main\n+\t\t$PRE_OID $POST_OID refs/heads/main\n \tEOF\n \tgit update-ref HEAD POST &&\n \ttest_cmp expect actual\n-- \n2.39.3 (Apple Git-146)\n\n"},{"id":"553404","messageId":"aroMrCUN44YdKA2h@pks.im","threadId":"66351","inReplyTo":"xmqq4ife4mzc.fsf@gitster.g","subject":"Re: [PATCH v5 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-09-28T06:43:56Z","receivedAt":"2026-09-28T06:44:02Z","isPatch":true,"body":"On Thu, Sep 24, 2026 at 09:45:27AM -0700, Junio C Hamano wrote:\n> Patrick Steinhardt <ps@pks.im> writes:\n> \n> > On Wed, Sep 23, 2026 at 11:04:40PM +0200, Maciej Ciemborowicz wrote:\n> >> refs_delete_refs() performs unconditional deletions, so callers cannot\n> >> preserve old values that they have already resolved. Consequently,\n> >> reference-transaction hooks see a null old OID.\n> \n> I wasn't paying attention when I gave my reviews, but the above\n> puzzles me.\n> \n> \"callers cannot preserve\", meaning \"after deletion the values cannot\n> be read anymore\"?  Of course, but then callers can read them\n> beforehand and use the stored value when calling hooks later.\n> \n> Patrick, do you understand these three lines above?  I don't, and I\n> am asking you because below what you say mostly seems to make sense.\n\nYeah, I think it's less of a \"cannot\" but more of a \"we do not\". I\nmentioned this in a later patch, but I think the proper fix for what the\nauthor is after to have reference transactions always resolve the status\nquo and provide old object IDs regardless of whether the user provided\none or not. If so we wouldn't have to change any of the interfaces at\nall, and we make sure that the reftx hook always gets invoked with\nproper old and new OIDs.\n\nPatrick\n"},{"id":"553405","messageId":"aroMsk9VdUm8u8nb@pks.im","threadId":"66351","inReplyTo":"CACQ=SRGA5j9ChJ0uM4=5iCwEDgWQEdhhrD8OF9=RJ7XBxqb0dQ@mail.gmail.com","subject":"Re: [PATCH v5 1/3] refs: allow callers to supply old OIDs for batch deletion","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-09-28T06:44:02Z","receivedAt":"2026-09-28T06:44:07Z","isPatch":true,"body":"On Thu, Sep 24, 2026 at 10:13:33PM +0200, Maciej Ciemborowicz wrote:\n> On Thu, Sep 24, 2026 at 6:45 PM Junio C Hamano <gitster@pobox.com> wrote:\n> \n> > \"callers cannot preserve\", meaning \"after deletion the values cannot\n> > be read anymore\"?  Of course, but then callers can read them\n> > beforehand and use the stored value when calling hooks later.\n> \n> I meant that refs_delete_refs() has no parameter for\n> the values its callers have already resolved, so those values are not\n> carried into the transaction and are therefore not available to the hook.\n> \n> Patrick's later suggestion to resolve missing old values in the common hook\n> layer seems to avoid this API question altogether.\n\nYup, exactly. All users of reference transactions would always supply\nboth old and new object ID to the reftx hook without changes to any of\nthe callers. And I think that's a sensible change to make.\n\nPatrick\n"},{"id":"553666","messageId":"CACQ=SRFWAJSRO7d5PcTK_FZBJrhdcr1ff_FfTUWmnQNB-WBnUA@mail.gmail.com","threadId":"66351","inReplyTo":"2af3eeadd18806c5298d53072428656885cff89d.1790269745.git.maciej.ciemborowicz@gmail.com","subject":"Re: [PATCH v6 1/1] refs: report old values to transaction hooks","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-09-30T03:11:30Z","receivedAt":"2026-09-30T03:11:43Z","isPatch":true,"body":"If anyone finds the time, I’d appreciate a code review. I’d like to\nclose this chapter (hopefully get it upstream) and move on to working\non the next bug.\n\nThanks,\n- Maciej Ciemborowicz\n\nOn Fri, Sep 25, 2026 at 12:33 AM Maciej Ciemborowicz\n<maciej.ciemborowicz@gmail.com> wrote:\n>\n> The reference-transaction hook reports an all-zero old object ID whenever\n> the caller does not supply an expected old value. Consequently, batched\n> branch, tag, and remote-ref deletions report zero as both the old and new\n> object IDs because refs_delete_refs() intentionally queues unconditional\n> deletions.\n>\n> Changing those callers to provide expected old values would make the\n> deletions conditional and alter existing command behavior. Instead, record\n> the current raw ref value separately for the hook. Read it before the\n> \"preparing\" hook, then refresh it after the backend has locked the refs so\n> that the \"prepared\" and later phases report the value protected by the\n> transaction's locks. Keep this value separate from old_oid and old_target so\n> it does not set REF_HAVE_OLD or otherwise constrain the update.\n>\n> Only resolve these values when a reference-transaction hook exists. Preserve\n> symbolic refs as targets, consistent with the hook's existing symref format.\n> Document that an unlocked \"preparing\" value may differ from later phases if\n> the ref changes before it is locked.\n>\n> Add coverage for batched branch deletion, tag deletion, and remote pruning.\n> Also exercise a concurrent update from the \"preparing\" hook to verify that\n> the deletion remains unconditional while later hook phases report the value\n> actually removed.\n>\n> Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n> ---\n>  Documentation/githooks.adoc      | 17 +++++----\n>  refs.c                           | 54 ++++++++++++++++++++++++---\n>  refs/refs-internal.h             |  8 ++++\n>  t/t1416-ref-transaction-hooks.sh | 64 +++++++++++++++++++++++++++++++-\n>  4 files changed, 128 insertions(+), 15 deletions(-)\n>\n> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> index ed045940d1..f60dd1d582 100644\n> --- a/Documentation/githooks.adoc\n> +++ b/Documentation/githooks.adoc\n> @@ -509,14 +509,15 @@ receives on standard input a line of the format:\n>    <old-value> SP <new-value> SP <ref-name> LF\n>\n>  where `<old-value>` is the old object name passed into the reference\n> -transaction, `<new-value>` is the new object name to be stored in the\n> -ref and `<ref-name>` is the full name of the ref. When force updating\n> -the reference regardless of its current value or when the reference is\n> -to be created anew, `<old-value>` is the all-zeroes object name. To\n> -distinguish these cases, you can inspect the current value of\n> -`<ref-name>` via `git rev-parse`. During the \"preparing\" state, symbolic\n> -references are not resolved: `<ref-name>` will reflect the symbolic reference\n> -itself rather than the object it points to.\n> +transaction, or the value observed while preparing the transaction if no\n> +old object name was passed. `<new-value>` is the new object name to be\n> +stored in the ref and `<ref-name>` is the full name of the ref. When the\n> +reference does not exist, `<old-value>` is the all-zeroes object name.\n> +Because references are not yet locked in the \"preparing\" state, its observed\n> +old value may differ from the value reported in subsequent states if the\n> +reference changes before it is locked. During the \"preparing\" state,\n> +symbolic references are not resolved: `<ref-name>` will reflect the symbolic\n> +reference itself rather than the object it points to.\n>\n>  For symbolic reference updates the `<old_value>` and `<new-value>`\n>  fields could denote references instead of objects. A reference will be\n> diff --git a/refs.c b/refs.c\n> index 92d5df5b71..d2d25402c3 100644\n> --- a/refs.c\n> +++ b/refs.c\n> @@ -1260,6 +1260,7 @@ void ref_transaction_free(struct ref_transaction *transaction)\n>                 free(transaction->updates[i]->committer_info);\n>                 free((char *)transaction->updates[i]->new_target);\n>                 free((char *)transaction->updates[i]->old_target);\n> +               free(transaction->updates[i]->hook_old_target);\n>                 free((char *)transaction->updates[i]->rejection_details);\n>                 free(transaction->updates[i]);\n>         }\n> @@ -2606,6 +2607,8 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_\n>         struct transaction_feed_cb_data *feed_cb_data = pp_task_cb;\n>         struct strbuf *buf = &feed_cb_data->buf;\n>         struct ref_update *update;\n> +       const struct object_id *old_oid;\n> +       const char *old_target;\n>         size_t i = feed_cb_data->index++;\n>         int ret;\n>\n> @@ -2619,12 +2622,18 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_\n>\n>         strbuf_reset(buf);\n>\n> -       if (!(update->flags & REF_HAVE_OLD))\n> -               strbuf_addf(buf, \"%s \", oid_to_hex(null_oid(transaction->ref_store->repo->hash_algo)));\n> -       else if (update->old_target)\n> -               strbuf_addf(buf, \"ref:%s \", update->old_target);\n> +       if (update->flags & REF_HAVE_OLD) {\n> +               old_oid = &update->old_oid;\n> +               old_target = update->old_target;\n> +       } else {\n> +               old_oid = &update->hook_old_oid;\n> +               old_target = update->hook_old_target;\n> +       }\n> +\n> +       if (old_target)\n> +               strbuf_addf(buf, \"ref:%s \", old_target);\n>         else\n> -               strbuf_addf(buf, \"%s \", oid_to_hex(&update->old_oid));\n> +               strbuf_addf(buf, \"%s \", oid_to_hex(old_oid));\n>\n>         if (!(update->flags & REF_HAVE_NEW))\n>                 strbuf_addf(buf, \"%s \", oid_to_hex(null_oid(transaction->ref_store->repo->hash_algo)));\n> @@ -2660,6 +2669,36 @@ static void transaction_feed_cb_data_free(void *data)\n>         free(d);\n>  }\n>\n> +static void resolve_transaction_hook_old_values(struct ref_transaction *transaction)\n> +{\n> +       struct ref_store *refs = transaction->ref_store;\n> +       struct strbuf referent = STRBUF_INIT;\n> +\n> +       if (!hook_exists(refs->repo, \"reference-transaction\"))\n> +               return;\n> +\n> +       for (size_t i = 0; i < transaction->nr; i++) {\n> +               struct ref_update *update = transaction->updates[i];\n> +               unsigned int type = 0;\n> +               int failure_errno;\n> +\n> +               if (update->flags & (REF_HAVE_OLD | REF_LOG_ONLY))\n> +                       continue;\n> +\n> +               oidclr(&update->hook_old_oid, refs->repo->hash_algo);\n> +               FREE_AND_NULL(update->hook_old_target);\n> +               strbuf_reset(&referent);\n> +\n> +               if (!refs_read_raw_ref(refs, update->refname,\n> +                                      &update->hook_old_oid, &referent,\n> +                                      &type, &failure_errno) &&\n> +                   (type & REF_ISSYMREF))\n> +                       update->hook_old_target = xstrdup(referent.buf);\n> +       }\n> +\n> +       strbuf_release(&referent);\n> +}\n> +\n>  static int run_transaction_hook(struct ref_transaction *transaction,\n>                                 const char *state)\n>  {\n> @@ -2709,6 +2748,8 @@ int ref_transaction_prepare(struct ref_transaction *transaction,\n>         if (ref_update_reject_duplicates(&transaction->refnames, err))\n>                 return REF_TRANSACTION_ERROR_GENERIC;\n>\n> +       resolve_transaction_hook_old_values(transaction);\n> +\n>         /* Preparing checks before locking references */\n>         ret = run_transaction_hook(transaction, \"preparing\");\n>         if (ret) {\n> @@ -2720,6 +2761,9 @@ int ref_transaction_prepare(struct ref_transaction *transaction,\n>         if (ret)\n>                 return ret;\n>\n> +       /* Refresh old values now that the references are locked. */\n> +       resolve_transaction_hook_old_values(transaction);\n> +\n>         ret = run_transaction_hook(transaction, \"prepared\");\n>         if (ret) {\n>                 ref_transaction_abort(transaction, err);\n> diff --git a/refs/refs-internal.h b/refs/refs-internal.h\n> index c3ac7b556f..a7471b2481 100644\n> --- a/refs/refs-internal.h\n> +++ b/refs/refs-internal.h\n> @@ -99,6 +99,14 @@ struct ref_update {\n>          */\n>         struct object_id old_oid;\n>\n> +       /*\n> +        * The old value observed for the reference-transaction hook when the\n> +        * caller did not provide an expected old value. Unlike old_oid and\n> +        * old_target, these fields do not constrain the update.\n> +        */\n> +       struct object_id hook_old_oid;\n> +       char *hook_old_target;\n> +\n>         /*\n>          * If the new_oid points to a tag object, set this to the peeled\n>          * object ID for optimized retrieval without needed to hit the odb.\n> diff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\n> index 4fe9d9b234..fcc7404943 100755\n> --- a/t/t1416-ref-transaction-hooks.sh\n> +++ b/t/t1416-ref-transaction-hooks.sh\n> @@ -14,6 +14,66 @@ test_expect_success setup '\n>         POST_OID=$(git rev-parse POST)\n>  '\n>\n> +test_expect_success 'hook gets old values for batched unconditional deletion' '\n> +       test_when_finished \"rm -f actual\" &&\n> +       test_when_finished \"git remote remove origin && rm -rf empty.git\" &&\n> +       git init --bare empty.git &&\n> +       git remote add origin ./empty.git &&\n> +       git branch delete-a PRE &&\n> +       git branch delete-b POST &&\n> +       git tag delete-tag POST &&\n> +       git update-ref refs/remotes/origin/to-prune $PRE_OID &&\n> +       test_hook reference-transaction <<-\\EOF &&\n> +               if test \"$1\" = committed\n> +               then\n> +                       cat >>actual\n> +               fi\n> +       EOF\n> +       git branch -D delete-a delete-b &&\n> +       git tag -d delete-tag &&\n> +       git remote prune origin &&\n> +       cat >expect <<-EOF &&\n> +               $PRE_OID $ZERO_OID refs/heads/delete-a\n> +               $POST_OID $ZERO_OID refs/heads/delete-b\n> +               $POST_OID $ZERO_OID refs/tags/delete-tag\n> +               $PRE_OID $ZERO_OID refs/remotes/origin/to-prune\n> +       EOF\n> +       test_cmp expect actual\n> +'\n> +\n> +test_expect_success 'unconditional deletion remains unconditional' '\n> +       test_when_finished \"rm -f actual\" &&\n> +       test_when_finished \"rm -f \\\"$(git rev-parse --git-path delete-race-once)\\\"\" &&\n> +       git branch delete-race PRE &&\n> +       test_hook reference-transaction <<-\\EOF &&\n> +               state=$1\n> +               while read -r old new ref\n> +               do\n> +                       if test \"$state\" != aborted\n> +                       then\n> +                               case \"$new\" in\n> +                               *[!0]*) ;;\n> +                               *) echo \"$state $old $new $ref\" >>actual ;;\n> +                               esac\n> +                       fi\n> +               done\n> +               marker=$(git rev-parse --git-path delete-race-once)\n> +               if test \"$state\" = preparing && test ! -e \"$marker\"\n> +               then\n> +                       >\"$marker\"\n> +                       git update-ref refs/heads/delete-race POST\n> +               fi\n> +       EOF\n> +       git branch -D delete-race &&\n> +       cat >expect <<-EOF &&\n> +               preparing $PRE_OID $ZERO_OID refs/heads/delete-race\n> +               prepared $POST_OID $ZERO_OID refs/heads/delete-race\n> +               committed $POST_OID $ZERO_OID refs/heads/delete-race\n> +       EOF\n> +       test_cmp expect actual &&\n> +       test_must_fail git show-ref --verify refs/heads/delete-race\n> +'\n> +\n>  test_expect_success 'hook allows updating ref if successful' '\n>         git reset --hard PRE &&\n>         test_hook reference-transaction <<-\\EOF &&\n> @@ -65,7 +125,7 @@ test_expect_success 'hook gets all queued updates in prepared state' '\n>                 fi\n>         EOF\n>         cat >expect <<-EOF &&\n> -               $ZERO_OID $POST_OID refs/heads/main\n> +               $PRE_OID $POST_OID refs/heads/main\n>         EOF\n>         git update-ref HEAD POST <<-EOF &&\n>                 update HEAD $ZERO_OID $POST_OID\n> @@ -87,7 +147,7 @@ test_expect_success 'hook gets all queued updates in committed state' '\n>                 fi\n>         EOF\n>         cat >expect <<-EOF &&\n> -               $ZERO_OID $POST_OID refs/heads/main\n> +               $PRE_OID $POST_OID refs/heads/main\n>         EOF\n>         git update-ref HEAD POST &&\n>         test_cmp expect actual\n> --\n> 2.39.3 (Apple Git-146)\n>\n"},{"id":"553853","messageId":"CACQ=SRGuk2+b2o=xG=fYVM4-RZw1bg_Lw002w1Ytv4-1GfnXKA@mail.gmail.com","threadId":"66351","inReplyTo":"CACQ=SRFWAJSRO7d5PcTK_FZBJrhdcr1ff_FfTUWmnQNB-WBnUA@mail.gmail.com","subject":"Re: [PATCH v6 1/1] refs: report old values to transaction hooks","fromName":"Maciej Ciemborowicz","fromEmail":"maciej.ciemborowicz@gmail.com","sentAt":"2026-10-01T17:37:05Z","receivedAt":"2026-10-01T17:37:19Z","isPatch":true,"body":"Change of priorities. I originally reported this bug while working on\ngit-hooks-ext and semantic events for reference transactions:\n\nhttps://github.com/ciembor/git-hooks-ext\n\nHowever, today I managed to resolve the issues I had encountered with\nbranch and tag deletion by using the data returned earlier by the\ntransaction in the prepared state.\n\nAs a result, this bug is now a much lower priority for me. The last\nissue I still need to resolve (and I don't see a reasonable way to\naddress it without fixing the bug in Git) is branch renaming:\n\nhttps://lore.kernel.org/git/CAOLa=ZTN1TU2A1sgEhiw=ymMYr6Ge11cMEubSaeKqr4WNU=2EQ@mail.gmail.com/T/#t\n\nI would appreciate it if more attention could be given to that bug instead.\n\nThanks,\nMaciej Ciemborowicz\n\nOn Wed, Sep 30, 2026 at 5:11 AM Maciej Ciemborowicz\n<maciej.ciemborowicz@gmail.com> wrote:\n>\n> If anyone finds the time, I’d appreciate a code review. I’d like to\n> close this chapter (hopefully get it upstream) and move on to working\n> on the next bug.\n>\n> Thanks,\n> - Maciej Ciemborowicz\n>\n> On Fri, Sep 25, 2026 at 12:33 AM Maciej Ciemborowicz\n> <maciej.ciemborowicz@gmail.com> wrote:\n> >\n> > The reference-transaction hook reports an all-zero old object ID whenever\n> > the caller does not supply an expected old value. Consequently, batched\n> > branch, tag, and remote-ref deletions report zero as both the old and new\n> > object IDs because refs_delete_refs() intentionally queues unconditional\n> > deletions.\n> >\n> > Changing those callers to provide expected old values would make the\n> > deletions conditional and alter existing command behavior. Instead, record\n> > the current raw ref value separately for the hook. Read it before the\n> > \"preparing\" hook, then refresh it after the backend has locked the refs so\n> > that the \"prepared\" and later phases report the value protected by the\n> > transaction's locks. Keep this value separate from old_oid and old_target so\n> > it does not set REF_HAVE_OLD or otherwise constrain the update.\n> >\n> > Only resolve these values when a reference-transaction hook exists. Preserve\n> > symbolic refs as targets, consistent with the hook's existing symref format.\n> > Document that an unlocked \"preparing\" value may differ from later phases if\n> > the ref changes before it is locked.\n> >\n> > Add coverage for batched branch deletion, tag deletion, and remote pruning.\n> > Also exercise a concurrent update from the \"preparing\" hook to verify that\n> > the deletion remains unconditional while later hook phases report the value\n> > actually removed.\n> >\n> > Signed-off-by: Maciej Ciemborowicz <maciej.ciemborowicz@gmail.com>\n> > ---\n> >  Documentation/githooks.adoc      | 17 +++++----\n> >  refs.c                           | 54 ++++++++++++++++++++++++---\n> >  refs/refs-internal.h             |  8 ++++\n> >  t/t1416-ref-transaction-hooks.sh | 64 +++++++++++++++++++++++++++++++-\n> >  4 files changed, 128 insertions(+), 15 deletions(-)\n> >\n> > diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> > index ed045940d1..f60dd1d582 100644\n> > --- a/Documentation/githooks.adoc\n> > +++ b/Documentation/githooks.adoc\n> > @@ -509,14 +509,15 @@ receives on standard input a line of the format:\n> >    <old-value> SP <new-value> SP <ref-name> LF\n> >\n> >  where `<old-value>` is the old object name passed into the reference\n> > -transaction, `<new-value>` is the new object name to be stored in the\n> > -ref and `<ref-name>` is the full name of the ref. When force updating\n> > -the reference regardless of its current value or when the reference is\n> > -to be created anew, `<old-value>` is the all-zeroes object name. To\n> > -distinguish these cases, you can inspect the current value of\n> > -`<ref-name>` via `git rev-parse`. During the \"preparing\" state, symbolic\n> > -references are not resolved: `<ref-name>` will reflect the symbolic reference\n> > -itself rather than the object it points to.\n> > +transaction, or the value observed while preparing the transaction if no\n> > +old object name was passed. `<new-value>` is the new object name to be\n> > +stored in the ref and `<ref-name>` is the full name of the ref. When the\n> > +reference does not exist, `<old-value>` is the all-zeroes object name.\n> > +Because references are not yet locked in the \"preparing\" state, its observed\n> > +old value may differ from the value reported in subsequent states if the\n> > +reference changes before it is locked. During the \"preparing\" state,\n> > +symbolic references are not resolved: `<ref-name>` will reflect the symbolic\n> > +reference itself rather than the object it points to.\n> >\n> >  For symbolic reference updates the `<old_value>` and `<new-value>`\n> >  fields could denote references instead of objects. A reference will be\n> > diff --git a/refs.c b/refs.c\n> > index 92d5df5b71..d2d25402c3 100644\n> > --- a/refs.c\n> > +++ b/refs.c\n> > @@ -1260,6 +1260,7 @@ void ref_transaction_free(struct ref_transaction *transaction)\n> >                 free(transaction->updates[i]->committer_info);\n> >                 free((char *)transaction->updates[i]->new_target);\n> >                 free((char *)transaction->updates[i]->old_target);\n> > +               free(transaction->updates[i]->hook_old_target);\n> >                 free((char *)transaction->updates[i]->rejection_details);\n> >                 free(transaction->updates[i]);\n> >         }\n> > @@ -2606,6 +2607,8 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_\n> >         struct transaction_feed_cb_data *feed_cb_data = pp_task_cb;\n> >         struct strbuf *buf = &feed_cb_data->buf;\n> >         struct ref_update *update;\n> > +       const struct object_id *old_oid;\n> > +       const char *old_target;\n> >         size_t i = feed_cb_data->index++;\n> >         int ret;\n> >\n> > @@ -2619,12 +2622,18 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_\n> >\n> >         strbuf_reset(buf);\n> >\n> > -       if (!(update->flags & REF_HAVE_OLD))\n> > -               strbuf_addf(buf, \"%s \", oid_to_hex(null_oid(transaction->ref_store->repo->hash_algo)));\n> > -       else if (update->old_target)\n> > -               strbuf_addf(buf, \"ref:%s \", update->old_target);\n> > +       if (update->flags & REF_HAVE_OLD) {\n> > +               old_oid = &update->old_oid;\n> > +               old_target = update->old_target;\n> > +       } else {\n> > +               old_oid = &update->hook_old_oid;\n> > +               old_target = update->hook_old_target;\n> > +       }\n> > +\n> > +       if (old_target)\n> > +               strbuf_addf(buf, \"ref:%s \", old_target);\n> >         else\n> > -               strbuf_addf(buf, \"%s \", oid_to_hex(&update->old_oid));\n> > +               strbuf_addf(buf, \"%s \", oid_to_hex(old_oid));\n> >\n> >         if (!(update->flags & REF_HAVE_NEW))\n> >                 strbuf_addf(buf, \"%s \", oid_to_hex(null_oid(transaction->ref_store->repo->hash_algo)));\n> > @@ -2660,6 +2669,36 @@ static void transaction_feed_cb_data_free(void *data)\n> >         free(d);\n> >  }\n> >\n> > +static void resolve_transaction_hook_old_values(struct ref_transaction *transaction)\n> > +{\n> > +       struct ref_store *refs = transaction->ref_store;\n> > +       struct strbuf referent = STRBUF_INIT;\n> > +\n> > +       if (!hook_exists(refs->repo, \"reference-transaction\"))\n> > +               return;\n> > +\n> > +       for (size_t i = 0; i < transaction->nr; i++) {\n> > +               struct ref_update *update = transaction->updates[i];\n> > +               unsigned int type = 0;\n> > +               int failure_errno;\n> > +\n> > +               if (update->flags & (REF_HAVE_OLD | REF_LOG_ONLY))\n> > +                       continue;\n> > +\n> > +               oidclr(&update->hook_old_oid, refs->repo->hash_algo);\n> > +               FREE_AND_NULL(update->hook_old_target);\n> > +               strbuf_reset(&referent);\n> > +\n> > +               if (!refs_read_raw_ref(refs, update->refname,\n> > +                                      &update->hook_old_oid, &referent,\n> > +                                      &type, &failure_errno) &&\n> > +                   (type & REF_ISSYMREF))\n> > +                       update->hook_old_target = xstrdup(referent.buf);\n> > +       }\n> > +\n> > +       strbuf_release(&referent);\n> > +}\n> > +\n> >  static int run_transaction_hook(struct ref_transaction *transaction,\n> >                                 const char *state)\n> >  {\n> > @@ -2709,6 +2748,8 @@ int ref_transaction_prepare(struct ref_transaction *transaction,\n> >         if (ref_update_reject_duplicates(&transaction->refnames, err))\n> >                 return REF_TRANSACTION_ERROR_GENERIC;\n> >\n> > +       resolve_transaction_hook_old_values(transaction);\n> > +\n> >         /* Preparing checks before locking references */\n> >         ret = run_transaction_hook(transaction, \"preparing\");\n> >         if (ret) {\n> > @@ -2720,6 +2761,9 @@ int ref_transaction_prepare(struct ref_transaction *transaction,\n> >         if (ret)\n> >                 return ret;\n> >\n> > +       /* Refresh old values now that the references are locked. */\n> > +       resolve_transaction_hook_old_values(transaction);\n> > +\n> >         ret = run_transaction_hook(transaction, \"prepared\");\n> >         if (ret) {\n> >                 ref_transaction_abort(transaction, err);\n> > diff --git a/refs/refs-internal.h b/refs/refs-internal.h\n> > index c3ac7b556f..a7471b2481 100644\n> > --- a/refs/refs-internal.h\n> > +++ b/refs/refs-internal.h\n> > @@ -99,6 +99,14 @@ struct ref_update {\n> >          */\n> >         struct object_id old_oid;\n> >\n> > +       /*\n> > +        * The old value observed for the reference-transaction hook when the\n> > +        * caller did not provide an expected old value. Unlike old_oid and\n> > +        * old_target, these fields do not constrain the update.\n> > +        */\n> > +       struct object_id hook_old_oid;\n> > +       char *hook_old_target;\n> > +\n> >         /*\n> >          * If the new_oid points to a tag object, set this to the peeled\n> >          * object ID for optimized retrieval without needed to hit the odb.\n> > diff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh\n> > index 4fe9d9b234..fcc7404943 100755\n> > --- a/t/t1416-ref-transaction-hooks.sh\n> > +++ b/t/t1416-ref-transaction-hooks.sh\n> > @@ -14,6 +14,66 @@ test_expect_success setup '\n> >         POST_OID=$(git rev-parse POST)\n> >  '\n> >\n> > +test_expect_success 'hook gets old values for batched unconditional deletion' '\n> > +       test_when_finished \"rm -f actual\" &&\n> > +       test_when_finished \"git remote remove origin && rm -rf empty.git\" &&\n> > +       git init --bare empty.git &&\n> > +       git remote add origin ./empty.git &&\n> > +       git branch delete-a PRE &&\n> > +       git branch delete-b POST &&\n> > +       git tag delete-tag POST &&\n> > +       git update-ref refs/remotes/origin/to-prune $PRE_OID &&\n> > +       test_hook reference-transaction <<-\\EOF &&\n> > +               if test \"$1\" = committed\n> > +               then\n> > +                       cat >>actual\n> > +               fi\n> > +       EOF\n> > +       git branch -D delete-a delete-b &&\n> > +       git tag -d delete-tag &&\n> > +       git remote prune origin &&\n> > +       cat >expect <<-EOF &&\n> > +               $PRE_OID $ZERO_OID refs/heads/delete-a\n> > +               $POST_OID $ZERO_OID refs/heads/delete-b\n> > +               $POST_OID $ZERO_OID refs/tags/delete-tag\n> > +               $PRE_OID $ZERO_OID refs/remotes/origin/to-prune\n> > +       EOF\n> > +       test_cmp expect actual\n> > +'\n> > +\n> > +test_expect_success 'unconditional deletion remains unconditional' '\n> > +       test_when_finished \"rm -f actual\" &&\n> > +       test_when_finished \"rm -f \\\"$(git rev-parse --git-path delete-race-once)\\\"\" &&\n> > +       git branch delete-race PRE &&\n> > +       test_hook reference-transaction <<-\\EOF &&\n> > +               state=$1\n> > +               while read -r old new ref\n> > +               do\n> > +                       if test \"$state\" != aborted\n> > +                       then\n> > +                               case \"$new\" in\n> > +                               *[!0]*) ;;\n> > +                               *) echo \"$state $old $new $ref\" >>actual ;;\n> > +                               esac\n> > +                       fi\n> > +               done\n> > +               marker=$(git rev-parse --git-path delete-race-once)\n> > +               if test \"$state\" = preparing && test ! -e \"$marker\"\n> > +               then\n> > +                       >\"$marker\"\n> > +                       git update-ref refs/heads/delete-race POST\n> > +               fi\n> > +       EOF\n> > +       git branch -D delete-race &&\n> > +       cat >expect <<-EOF &&\n> > +               preparing $PRE_OID $ZERO_OID refs/heads/delete-race\n> > +               prepared $POST_OID $ZERO_OID refs/heads/delete-race\n> > +               committed $POST_OID $ZERO_OID refs/heads/delete-race\n> > +       EOF\n> > +       test_cmp expect actual &&\n> > +       test_must_fail git show-ref --verify refs/heads/delete-race\n> > +'\n> > +\n> >  test_expect_success 'hook allows updating ref if successful' '\n> >         git reset --hard PRE &&\n> >         test_hook reference-transaction <<-\\EOF &&\n> > @@ -65,7 +125,7 @@ test_expect_success 'hook gets all queued updates in prepared state' '\n> >                 fi\n> >         EOF\n> >         cat >expect <<-EOF &&\n> > -               $ZERO_OID $POST_OID refs/heads/main\n> > +               $PRE_OID $POST_OID refs/heads/main\n> >         EOF\n> >         git update-ref HEAD POST <<-EOF &&\n> >                 update HEAD $ZERO_OID $POST_OID\n> > @@ -87,7 +147,7 @@ test_expect_success 'hook gets all queued updates in committed state' '\n> >                 fi\n> >         EOF\n> >         cat >expect <<-EOF &&\n> > -               $ZERO_OID $POST_OID refs/heads/main\n> > +               $PRE_OID $POST_OID refs/heads/main\n> >         EOF\n> >         git update-ref HEAD POST &&\n> >         test_cmp expect actual\n> > --\n> > 2.39.3 (Apple Git-146)\n> >\n"}]}