{"thread":{"id":"66497","subject":"[PATCH 00/15] push: speed up client-side refspec matching","startedAt":"2026-10-09T19:29:38Z","lastAt":"2026-10-11T02:35:33Z","messageCount":18,"participants":["Jon Simons","Kristoffer Haugsbakk"],"isPatch":true,"patchVersion":1,"patchTotal":15},"messages":[{"id":"554637","messageId":"20261009192953.81794-1-jon@jonsimons.org","threadId":"66497","inReplyTo":null,"subject":"[PATCH 00/15] push: speed up client-side refspec matching","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:38Z","receivedAt":"2026-10-09T19:29:38Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"This series speeds up client-side refspec matching in push, focused on\npushing lots of branch deletes to a server that advertises lots of refs\n(100k in the benchmark below).\n\nBefore this series, a 'git push --dry-run' of 100 delete refspecs to a\nserver with 100k refs takes ~2.5s from an empty client and ~6.6s from a\nmirror; using one --force-with-lease per refspec takes ~4.8s and ~8.9s\nrespectively.  After, the same pushes take ~0.15s to ~0.19s.\n\nrefname_match() is updated to reuse the existing match_parse_rule(),\nand a few client-side linear ref traversals are converted to\nstrmap lookups in remote.c.\n\nGuide to changes:\n\n - Commits are ordered to introduce failing tests for behavioral\n   changes and bugfixes, before the code change that fixes them.\n\n - Prep refname_match() call sites for behavioral change of mkpath()\n   removal, and introduce benchmark:\n\n   (1) remote: validate --force-with-lease <refname> argument\n   (2) t5516: demonstrate push with \"./\"-prefixed source\n   (3) t5510: document fetch with \"./\"-prefixed branch.<name>.merge\n   (4) t/perf: add explicit delete refspec matching test\n\n - The first speedup, best viewed with `--color-moved`, is where\n   refname_match() changes behavior: mkpath() stripped a leading \"./\"\n   from the name being matched, so \"./refs/heads/foo\" used to match\n   \"refs/heads/foo\".  It no longer does.\n\n   (5) refs: stop using mkpath() in refname_match()\n\n - The next speedup establishes the conversion pattern of using\n   expand_ref_prefix() with strmap lookups (the same pattern is used\n   in two more spots that follow):\n\n   (6) remote: use strmap for check_push_refs()\n\n - Fix a long-standing buglet where pushing two refspecs with the same\n   destination to an empty remote is not rejected client-side, as it is\n   when the remote has any ref.\n\n   t5408 and t5410 now cover server-side duplicate rejection and\n   send-pack's handling of a ref reported twice, without relying\n   on the client missing the duplicates.\n\n   Fixing this separately keeps the behavior change out of the strmap\n   conversion that immediately follows, which would otherwise fix it\n   as a side-effect:\n\n   (7) t5516: test pushing two refspecs creating the same new branch\n   (8) t5408, t5410: test duplicate updates without relying on the client\n   (9) t5408: check refspec order with distinct destinations\n   (10) t5408: expect client-side error for duplicate destinations\n   (11) remote: reject duplicate destinations on an empty remote\n\n - Convert match_explicit_refs():\n\n   (12) remote: use strmap for match_explicit_refs()\n\n - Measure and convert --force-with-lease paths:\n\n   (13) t/perf: measure --force-with-lease in p5516\n   (14) remote: restructure apply_push_cas() loops\n   (15) remote: use strmap for apply_push_cas()\n\nNumbers:\n\nA new benchmark demonstrates each performance change.  These numbers\nwere gathered running the final form of p5516 against the earlier\ntrees on my machine:\n\n  BASELINE             origin/master\n  REFNAME_MATCH        (5)  refs: stop using mkpath() in refname_match()\n  MAP_CHECK_PUSH_REFS  (6)  remote: use strmap for check_push_refs()\n  MAP_EXPLICIT_REFS    (12) remote: use strmap for match_explicit_refs()\n  MAP_APPLY_PUSH_CAS   (15) remote: use strmap for apply_push_cas()\n\n  Test                           BASELINE          REFNAME_MATCH            MAP_CHECK_PUSH_REFS      MAP_EXPLICIT_REFS        MAP_APPLY_PUSH_CAS\n  --------------------------------------------------------------------------------------------------------------------------------------------------\n  5516.3: empty:refspecs:1       0.16(0.09+0.11)   0.14(0.07+0.11) -12.5%   0.14(0.07+0.11) -12.5%   0.14(0.07+0.11) -12.5%   0.14(0.08+0.11) -12.5%\n  5516.5: empty:refspecs:10      0.37(0.30+0.11)   0.17(0.10+0.11) -54.1%   0.17(0.11+0.11) -54.1%   0.13(0.07+0.11) -64.9%   0.14(0.07+0.11) -62.2%\n  5516.7: empty:refspecs:100     2.48(2.41+0.11)   0.49(0.43+0.11) -80.2%   0.47(0.40+0.11) -81.0%   0.15(0.08+0.11) -94.0%   0.15(0.09+0.11) -94.0%\n  5516.9: mirror:refspecs:1      0.22(0.15+0.11)   0.16(0.09+0.11) -27.3%   0.16(0.09+0.11) -27.3%   0.16(0.09+0.11) -27.3%   0.17(0.10+0.11) -22.7%\n  5516.11: mirror:refspecs:10    0.81(0.74+0.11)   0.26(0.19+0.11) -67.9%   0.23(0.16+0.11) -71.6%   0.16(0.09+0.11) -80.2%   0.17(0.10+0.12) -79.0%\n  5516.13: mirror:refspecs:100   6.64(6.57+0.12)   1.21(1.13+0.12) -81.8%   0.86(0.79+0.12) -87.0%   0.18(0.11+0.11) -97.3%   0.18(0.11+0.11) -97.3%\n  5516.16: empty:lease:1         0.17(0.11+0.11)   0.14(0.07+0.11) -17.6%   0.14(0.07+0.11) -17.6%   0.14(0.07+0.11) -17.6%   0.15(0.08+0.11) -11.8%\n  5516.19: empty:lease:10        0.71(0.65+0.11)   0.20(0.14+0.11) -71.8%   0.20(0.13+0.11) -71.8%   0.17(0.10+0.11) -76.1%   0.15(0.08+0.11) -78.9%\n  5516.22: empty:lease:100       4.79(4.71+0.12)   0.78(0.71+0.12) -83.7%   0.80(0.73+0.12) -83.3%   0.45(0.38+0.11) -90.6%   0.16(0.09+0.11) -96.7%\n  5516.25: mirror:lease:1        0.24(0.17+0.11)   0.17(0.10+0.11) -29.2%   0.17(0.10+0.12) -29.2%   0.17(0.10+0.12) -29.2%   0.18(0.11+0.12) -25.0%\n  5516.28: mirror:lease:10       1.04(0.97+0.11)   0.29(0.22+0.11) -72.1%   0.26(0.19+0.12) -75.0%   0.20(0.13+0.11) -80.8%   0.18(0.11+0.12) -82.7%\n  5516.31: mirror:lease:100      8.93(8.85+0.12)   1.47(1.40+0.12) -83.5%   1.11(1.04+0.12) -87.6%   0.49(0.42+0.12) -94.5%   0.19(0.12+0.12) -97.9%\n\nJon Simons (15):\n  remote: validate --force-with-lease <refname> argument\n  t5516: demonstrate push with \"./\"-prefixed source\n  t5510: document fetch with \"./\"-prefixed branch.<name>.merge\n  t/perf: add explicit delete refspec matching test\n  refs: stop using mkpath() in refname_match()\n  remote: use strmap for check_push_refs()\n  t5516: test pushing two refspecs creating the same new branch\n  t5408, t5410: test duplicate updates without relying on the client\n  t5408: check refspec order with distinct destinations\n  t5408: expect client-side error for duplicate destinations\n  remote: reject duplicate destinations on an empty remote\n  remote: use strmap for match_explicit_refs()\n  t/perf: measure --force-with-lease in p5516\n  remote: restructure apply_push_cas() loops\n  remote: use strmap for apply_push_cas()\n\n refs.c                              |  88 +++++-----\n remote.c                            | 257 +++++++++++++++++++---------\n t/perf/p5516-push-delete-refspec.sh |  67 ++++++++\n t/t5408-send-pack-stdin.sh          |  56 +++++-\n t/t5410-receive-pack.sh             |  35 ++++\n t/t5510-fetch.sh                    |  44 +++++\n t/t5516-fetch-push.sh               |  42 +++++\n t/t5533-push-cas.sh                 |   9 +\n 8 files changed, 465 insertions(+), 133 deletions(-)\n create mode 100755 t/perf/p5516-push-delete-refspec.sh\n\n-- \n2.55.0\n\n\n"},{"id":"554638","messageId":"20261009192953.81794-2-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 01/15] remote: validate --force-with-lease <refname> argument","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:39Z","receivedAt":"2026-10-09T19:29:39Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"Use check_refname_format() to validate the <refname> component of\n'git push --force-with-lease=<refname>[:<expect>]'.\n\napply_push_cas() will silently ignore a lease entry that does not match\nany remote ref.  And today \"./refs/heads/main\" will happen to be matched\nwith \"refs/heads/main\" due to refname_match() usage of mkpath(), whose\ncleanup_path() strips leading \"./\".\n\nAn upcoming commit removes mkpath() from refname_match(), after which\nthere is no unintentional match in this situation, so that the lease\nis ignored instead of applied.  Reject an invalid refname now to make\nthis situation fail fast both before and after that change.\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n remote.c            | 2 ++\n t/t5533-push-cas.sh | 9 +++++++++\n 2 files changed, 11 insertions(+)\n\ndiff --git a/remote.c b/remote.c\nindex 71170f36a9..114d4d983c 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2740,6 +2740,8 @@ static int parse_push_cas_option(struct push_cas_option *cas, const char *arg, i\n \t/* \"--<option>=refname\" or \"--<option>=refname:value\" */\n \tcolon = strchrnul(arg, ':');\n \tentry = add_cas_entry(cas, arg, colon - arg);\n+\tif (check_refname_format(entry->refname, REFNAME_ALLOW_ONELEVEL))\n+\t\treturn error(_(\"'%s' is not a valid refname\"), entry->refname);\n \tif (!*colon)\n \t\tentry->use_tracking = 1;\n \telse if (!colon[1])\ndiff --git a/t/t5533-push-cas.sh b/t/t5533-push-cas.sh\nindex e6e1360a42..f6eafee7ad 100755\n--- a/t/t5533-push-cas.sh\n+++ b/t/t5533-push-cas.sh\n@@ -63,6 +63,15 @@ test_expect_success setup '\n \ttest_commit C\n '\n \n+test_expect_success 'push --force-with-lease rejects invalid refname' '\n+\tsetup_srcdst_basic &&\n+\t(\n+\t\tcd dst &&\n+\t\ttest_must_fail git push --force-with-lease=./refs/heads/main origin main 2>err &&\n+\t\ttest_grep \"is not a valid refname\" err\n+\t)\n+'\n+\n test_expect_success 'push to update (protected)' '\n \tsetup_srcdst_basic &&\n \t(\n-- \n2.55.0\n\n\n"},{"id":"554639","messageId":"20261009192953.81794-3-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 02/15] t5516: demonstrate push with \"./\"-prefixed source","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:40Z","receivedAt":"2026-10-09T19:29:40Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"'git push <repo> ./refs/heads/main:refs/heads/frotz' succeeds today\nand pushes refs/heads/main, although \"./refs/heads/main\" is not a\nvalid refname.  count_refspec_match() compares the source to each\nlocal ref using refname_match(), which formats its given name with\nmkpath(), whose cleanup_path() strips leading \"./\".\n\nAdd a test_expect_failure asserting that such a source is rejected\nwith \"src refspec ./refs/heads/main does not match any\".  An upcoming\ncommit stops using mkpath() in refname_match(), at which point the\ntest is toggled to test_expect_success.\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n t/t5516-fetch-push.sh | 6 ++++++\n 1 file changed, 6 insertions(+)\n\ndiff --git a/t/t5516-fetch-push.sh b/t/t5516-fetch-push.sh\nindex b982b209bf..aaeb251e2f 100755\n--- a/t/t5516-fetch-push.sh\n+++ b/t/t5516-fetch-push.sh\n@@ -433,6 +433,12 @@ test_expect_success 'push with onelevel ref' '\n \ttest_must_fail git push testrepo HEAD:refs/onelevel\n '\n \n+test_expect_failure 'push with \"./\"-prefixed src does not match any ref' '\n+\tmk_test testrepo heads/main &&\n+\ttest_must_fail git push testrepo ./refs/heads/main:refs/heads/frotz 2>err &&\n+\ttest_grep \"src refspec ./refs/heads/main does not match any\" err\n+'\n+\n test_expect_success 'push with colon-less refspec (1)' '\n \tmk_test testrepo heads/frotz tags/frotz &&\n \tgit branch -f frotz main &&\n-- \n2.55.0\n\n\n"},{"id":"554640","messageId":"20261009192953.81794-4-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 03/15] t5510: document fetch with \"./\"-prefixed branch.<name>.merge","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:41Z","receivedAt":"2026-10-09T19:29:41Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"refname_match() formats each candidate through mkpath(), which strips\na leading \"./\", so a branch.<name>.merge value of \"./refs/heads/main\"\nmatches \"refs/heads/main\" in both places that fetch compares them:\n\n - branch_merge_matches() when marking fetched refs for merge\n   in FETCH_HEAD\n\n - find_ref_by_name_abbrev() when the merge source is not covered by the\n   fetch refspec and is looked up amongst the remote advertised refs\n\nOnly a hand-edited config reaches these paths: clone, branch\n--set-upstream-to, fetch --set-upstream, and push -u write only names\nthat have passed check_refname_format(), which rejects a leading \"./\".\n\nAdd two fetch tests asserting that such a \"./\"-prefixed value does not\nmatch for these cases.  Both are test_expect_failure because the name\nmatches today:\n\n - The default fetch refspec should not match \"./refs/heads/main\"\n   with fetched \"refs/heads/main\" and mark the ref for merge.\n\n - A protocol v0 fetch with refspec that omits \"refs/heads/main\"\n   should not match \"./refs/heads/main\" against the remote\n   advertised \"refs/heads/main\".\n\nAn upcoming commit stops using mkpath() in refname_match(), at which\npoint the tests are toggled to test_expect_success.\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n t/t5510-fetch.sh | 44 ++++++++++++++++++++++++++++++++++++++++++++\n 1 file changed, 44 insertions(+)\n\ndiff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh\nindex 300bd5396d..0303784b1f 100755\n--- a/t/t5510-fetch.sh\n+++ b/t/t5510-fetch.sh\n@@ -1074,6 +1074,50 @@ test_expect_success 'LHS of refspec follows ref disambiguation rules' '\n \t)\n '\n \n+test_expect_failure 'fetch with \"./\"-prefixed branch.<name>.merge does not mark any ref for merge' '\n+\tmkdir dotslash-merge-default-refspec &&\n+\t(\n+\t\tcd dotslash-merge-default-refspec &&\n+\t\tgit init -b main server &&\n+\t\ttest_commit -C server one &&\n+\t\tgit -C server branch other &&\n+\t\ttest_commit -C server two &&\n+\n+\t\t# The bogus ./refs/heads/main should not match the remote refs/heads/main.\n+\t\tgit clone server client &&\n+\t\tgit -C client config branch.main.merge ./refs/heads/main &&\n+\t\tgit -C client fetch &&\n+\t\t{\n+\t\t\techo \"$(git -C server rev-parse main)\tnot-for-merge\" &&\n+\t\t\techo \"$(git -C server rev-parse other)\tnot-for-merge\"\n+\t\t} >expect &&\n+\t\tcut -f -2 client/.git/FETCH_HEAD >actual &&\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_failure 'fetch protocol v0 with \"./\"-prefixed branch.<name>.merge does not match any remote ref' '\n+\tmkdir dotslash-merge-fetch-protocol-v0 &&\n+\t(\n+\t\tcd dotslash-merge-fetch-protocol-v0 &&\n+\t\tgit init -b main server &&\n+\t\ttest_commit -C server one &&\n+\t\tgit -C server branch other &&\n+\t\ttest_commit -C server two &&\n+\n+\t\t# Omit refs/heads/main from the fetch refspec so that the merge\n+\t\t# source is instead looked up among the refs the remote advertised.\n+\t\tgit clone server client-v0 &&\n+\t\tgit -C client-v0 config remote.origin.fetch \\\n+\t\t\t+refs/heads/other:refs/remotes/origin/other &&\n+\t\tgit -C client-v0 config branch.main.merge ./refs/heads/main &&\n+\t\tgit -C client-v0 -c protocol.version=0 fetch &&\n+\t\techo \"$(git -C server rev-parse other)\tnot-for-merge\" >expect &&\n+\t\tcut -f -2 client-v0/.git/FETCH_HEAD >actual &&\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n test_expect_success 'fetch.writeCommitGraph' '\n \tgit clone three write &&\n \t(\n-- \n2.55.0\n\n\n"},{"id":"554641","messageId":"20261009192953.81794-5-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 04/15] t/perf: add explicit delete refspec matching test","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:42Z","receivedAt":"2026-10-09T19:29:42Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"Add p5516 to measure the client-side cost of matching explicit\nrefspecs on 'git push' against a server that advertises many refs.\n\nThe server gets 100k branches, and two clients push 1, 10, 100 delete\nrefspecs each with --dry-run: an empty client (no local refs), which\nisolates matching against the advertised refs, and a mirror client\n(full local copy of server refs), to exercise matching against the\nsame number of local refs.  Delete refspecs and --dry-run are used\nto avoid any object transfer or ref update activity in the measurement\nand leave the scratch repos unmodified, such that each repetition does\nthe same amount of matching work.\n\nThe test is used to demonstrate speedups in subsequent commits.\nBaseline numbers on my machine:\n\n  Test                           this tree\n  ----------------------------------------------\n  5516.3: empty:refspecs:1       0.14(0.08+0.10)\n  5516.5: empty:refspecs:10      0.38(0.32+0.10)\n  5516.7: empty:refspecs:100     2.50(2.44+0.10)\n  5516.9: mirror:refspecs:1      0.21(0.14+0.11)\n  5516.11: mirror:refspecs:10    0.80(0.73+0.11)\n  5516.13: mirror:refspecs:100   6.85(6.78+0.12)\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n t/perf/p5516-push-delete-refspec.sh | 42 +++++++++++++++++++++++++++++\n 1 file changed, 42 insertions(+)\n create mode 100755 t/perf/p5516-push-delete-refspec.sh\n\ndiff --git a/t/perf/p5516-push-delete-refspec.sh b/t/perf/p5516-push-delete-refspec.sh\nnew file mode 100755\nindex 0000000000..0e425193e5\n--- /dev/null\n+++ b/t/perf/p5516-push-delete-refspec.sh\n@@ -0,0 +1,42 @@\n+#!/bin/sh\n+\n+test_description='explicit delete refspec matching on push\n+\n+Measure client-side matching of explicit delete refspecs with \"git push\n+--dry-run\" against a server that advertises lots of refs.  An empty client\n+(no local refs) and a mirror client (full local copy of the server refs)\n+are tested pushing 1, 10, and 100 refspecs each.\n+'\n+. ./perf-lib.sh\n+\n+test_perf_fresh_repo\n+\n+ref_count=100000\n+\n+test_expect_success 'create server with many refs and two clients' '\n+\ttest_commit base &&\n+\tgit clone --bare --ref-format=reftable . server &&\n+\ttest_seq -f \"create refs/heads/b%d HEAD\" $ref_count |\n+\tgit -C server update-ref --stdin &&\n+\tgit init --bare client_empty &&\n+\tgit -C client_empty remote add origin \"$PWD/server\" &&\n+\tgit clone --mirror --ref-format=reftable \"$PWD/server\" client_mirror &&\n+\tgit -C client_mirror config --unset remote.origin.mirror\n+'\n+\n+for mode in empty mirror\n+do\n+\tclient=client_$mode\n+\tfor nr_refspecs in 1 10 100\n+\tdo\n+\t\ttest_expect_success \"create $mode refspecs: $nr_refspecs\" '\n+\t\t\ttest_seq -f \":refs/heads/b%d\" $nr_refspecs >refspecs\n+\t\t'\n+\n+\t\ttest_perf \"$mode:refspecs:$nr_refspecs\" '\n+\t\t\tgit -C '\"$client\"' push --dry-run origin $(cat refspecs)\n+\t\t'\n+\tdone\n+done\n+\n+test_done\n-- \n2.55.0\n\n\n"},{"id":"554642","messageId":"20261009192953.81794-6-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 05/15] refs: stop using mkpath() in refname_match()","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:43Z","receivedAt":"2026-10-09T19:29:43Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"refname_match() formats every ref_rev_parse_rules entry through mkpath()\nto then strcmp() with its given full_name.  It turns out this formatting\nfunction dominates the client-side cost of matching explicit refspecs\nagainst a remote that advertises lots of refs.\n\nReuse match_parse_rule() instead to efficiently compare each rule\nagainst its given full_name.  With this change we avoid mkpath()'s\nstrbuf_vaddf(), and each match is now a direct prefix + suffix\ncomparison, length check, and memcmp of the refname at hand.\n\nTimings from the test added in the previous commit:\n\n  Test                           HEAD~1            HEAD\n  -----------------------------------------------------------------------\n  5516.3: empty:refspecs:1       0.15(0.09+0.11)   0.14(0.07+0.11) -6.7%\n  5516.5: empty:refspecs:10      0.38(0.32+0.11)   0.17(0.11+0.11) -55.3%\n  5516.7: empty:refspecs:100     2.50(2.43+0.11)   0.49(0.42+0.11) -80.4%\n  5516.9: mirror:refspecs:1      0.21(0.15+0.11)   0.16(0.09+0.11) -23.8%\n  5516.11: mirror:refspecs:10    0.80(0.73+0.11)   0.26(0.19+0.11) -67.5%\n  5516.13: mirror:refspecs:100   6.68(6.59+0.13)   1.18(1.11+0.11) -82.3%\n\nNotes on a change in behavior:\n\n - mkpath() runs cleanup_path(), which strips leading \"./\".  So,\n   \"./refs/heads/foo\" previously matched \"refs/heads/foo\" through the\n   \"%.*s\" rule.  As of this commit, this is no longer true.\n\n - It's considered a bug that \"./\"-prefixed strings previously could\n   have been matched with refnames in this way:\n\n   - cleanup_path() dates back to 26c8a533af (Add \"mkpath()\" helper\n     function, 2005-07-08), and is intended to be used for filepaths,\n     not refnames.  The leading-\"./\" strip itself comes from f17a1b1bec\n     (Fix up path-cleanup in git_path() properly, 2005-07-05).\n\n   - refname_match() has used mkpath() in its matching loop since its\n     inception with 79803322c1 (add refname_match(), 2007-11-11).\n\n   - 6cd4a8982d (avoid using mksnpath for refs, 2017-03-28) previously\n     removed other cleanup_path() spots reachable from mksnpath() and\n     notes they were \"questionable when dealing with refnames, as we\n     could silently canonicalize a syntactically bogus refname into a\n     valid one.\"\n\nTests in t5510 and t5516 that cover the aliasing behavior are toggled\nto test_expect_success.\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n refs.c                | 88 +++++++++++++++++++++++--------------------\n t/t5510-fetch.sh      |  4 +-\n t/t5516-fetch-push.sh |  2 +-\n 3 files changed, 50 insertions(+), 44 deletions(-)\n\ndiff --git a/refs.c b/refs.c\nindex 951db56113..9bf3bc8153 100644\n--- a/refs.c\n+++ b/refs.c\n@@ -652,6 +652,44 @@ static const char *ref_rev_parse_rules[] = {\n \n #define NUM_REV_PARSE_RULES (ARRAY_SIZE(ref_rev_parse_rules) - 1)\n \n+/*\n+ * Check that the string refname matches a rule of the form\n+ * \"{prefix}%.*s{suffix}\". So \"foo/bar/baz\" would match the rule\n+ * \"foo/%.*s/baz\", and return the string \"bar\".\n+ */\n+static const char *match_parse_rule(const char *refname, const char *rule,\n+\t\t\t\t    size_t *len)\n+{\n+\t/*\n+\t * Check that rule matches refname up to the first percent in the rule.\n+\t * We can bail immediately if not, but otherwise we leave \"rule\" at the\n+\t * %-placeholder, and \"refname\" at the start of the potential matched\n+\t * name.\n+\t */\n+\twhile (*rule != '%') {\n+\t\tif (!*rule)\n+\t\t\tBUG(\"rev-parse rule did not have percent\");\n+\t\tif (*refname++ != *rule++)\n+\t\t\treturn NULL;\n+\t}\n+\n+\t/*\n+\t * Check that our \"%\" is the expected placeholder. This assumes there\n+\t * are no other percents (placeholder or quoted) in the string, but\n+\t * that is sufficient for our rev-parse rules.\n+\t */\n+\tif (!skip_prefix(rule, \"%.*s\", &rule))\n+\t\treturn NULL;\n+\n+\t/*\n+\t * And now check that our suffix (if any) matches.\n+\t */\n+\tif (!strip_suffix(refname, rule, len))\n+\t\treturn NULL;\n+\n+\treturn refname; /* len set by strip_suffix() */\n+}\n+\n /*\n  * Is it possible that the caller meant full_name with abbrev_name?\n  * If so return a non-zero value to signal \"yes\"; the magnitude of\n@@ -662,12 +700,18 @@ static const char *ref_rev_parse_rules[] = {\n int refname_match(const char *abbrev_name, const char *full_name)\n {\n \tconst char **p;\n-\tconst int abbrev_name_len = strlen(abbrev_name);\n+\tconst size_t abbrev_name_len = strlen(abbrev_name);\n \tconst int num_rules = NUM_REV_PARSE_RULES;\n \n-\tfor (p = ref_rev_parse_rules; *p; p++)\n-\t\tif (!strcmp(full_name, mkpath(*p, abbrev_name_len, abbrev_name)))\n+\tfor (p = ref_rev_parse_rules; *p; p++) {\n+\t\tsize_t short_name_len;\n+\t\tconst char *short_name = match_parse_rule(full_name, *p,\n+\t\t\t\t\t\t\t  &short_name_len);\n+\n+\t\tif (short_name && short_name_len == abbrev_name_len &&\n+\t\t    !memcmp(short_name, abbrev_name, abbrev_name_len))\n \t\t\treturn &ref_rev_parse_rules[num_rules] - p;\n+\t}\n \n \treturn 0;\n }\n@@ -1615,44 +1659,6 @@ int refs_update_ref(struct ref_store *refs, const char *msg,\n \treturn 0;\n }\n \n-/*\n- * Check that the string refname matches a rule of the form\n- * \"{prefix}%.*s{suffix}\". So \"foo/bar/baz\" would match the rule\n- * \"foo/%.*s/baz\", and return the string \"bar\".\n- */\n-static const char *match_parse_rule(const char *refname, const char *rule,\n-\t\t\t\t    size_t *len)\n-{\n-\t/*\n-\t * Check that rule matches refname up to the first percent in the rule.\n-\t * We can bail immediately if not, but otherwise we leave \"rule\" at the\n-\t * %-placeholder, and \"refname\" at the start of the potential matched\n-\t * name.\n-\t */\n-\twhile (*rule != '%') {\n-\t\tif (!*rule)\n-\t\t\tBUG(\"rev-parse rule did not have percent\");\n-\t\tif (*refname++ != *rule++)\n-\t\t\treturn NULL;\n-\t}\n-\n-\t/*\n-\t * Check that our \"%\" is the expected placeholder. This assumes there\n-\t * are no other percents (placeholder or quoted) in the string, but\n-\t * that is sufficient for our rev-parse rules.\n-\t */\n-\tif (!skip_prefix(rule, \"%.*s\", &rule))\n-\t\treturn NULL;\n-\n-\t/*\n-\t * And now check that our suffix (if any) matches.\n-\t */\n-\tif (!strip_suffix(refname, rule, len))\n-\t\treturn NULL;\n-\n-\treturn refname; /* len set by strip_suffix() */\n-}\n-\n char *refs_shorten_unambiguous_ref(struct ref_store *refs,\n \t\t\t\t   const char *refname, int strict)\n {\ndiff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh\nindex 0303784b1f..941213f36a 100755\n--- a/t/t5510-fetch.sh\n+++ b/t/t5510-fetch.sh\n@@ -1074,7 +1074,7 @@ test_expect_success 'LHS of refspec follows ref disambiguation rules' '\n \t)\n '\n \n-test_expect_failure 'fetch with \"./\"-prefixed branch.<name>.merge does not mark any ref for merge' '\n+test_expect_success 'fetch with \"./\"-prefixed branch.<name>.merge does not mark any ref for merge' '\n \tmkdir dotslash-merge-default-refspec &&\n \t(\n \t\tcd dotslash-merge-default-refspec &&\n@@ -1096,7 +1096,7 @@ test_expect_failure 'fetch with \"./\"-prefixed branch.<name>.merge does not mark\n \t)\n '\n \n-test_expect_failure 'fetch protocol v0 with \"./\"-prefixed branch.<name>.merge does not match any remote ref' '\n+test_expect_success 'fetch protocol v0 with \"./\"-prefixed branch.<name>.merge does not match any remote ref' '\n \tmkdir dotslash-merge-fetch-protocol-v0 &&\n \t(\n \t\tcd dotslash-merge-fetch-protocol-v0 &&\ndiff --git a/t/t5516-fetch-push.sh b/t/t5516-fetch-push.sh\nindex aaeb251e2f..81ad6cd52f 100755\n--- a/t/t5516-fetch-push.sh\n+++ b/t/t5516-fetch-push.sh\n@@ -433,7 +433,7 @@ test_expect_success 'push with onelevel ref' '\n \ttest_must_fail git push testrepo HEAD:refs/onelevel\n '\n \n-test_expect_failure 'push with \"./\"-prefixed src does not match any ref' '\n+test_expect_success 'push with \"./\"-prefixed src does not match any ref' '\n \tmk_test testrepo heads/main &&\n \ttest_must_fail git push testrepo ./refs/heads/main:refs/heads/frotz 2>err &&\n \ttest_grep \"src refspec ./refs/heads/main does not match any\" err\n-- \n2.55.0\n\n\n"},{"id":"554643","messageId":"20261009192953.81794-7-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 06/15] remote: use strmap for check_push_refs()","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:44Z","receivedAt":"2026-10-09T19:29:44Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"Optimize check_push_refs() by replacing a linear traversal of all\nlocal refs with strmap lookups.\n\nBefore this change, matching R explicit refspecs against N local refs\nin check_push_refs() entails O(R * N) calls to refname_match().\n\nAfter this change, we build a strmap of local refs O(N) and use it\nfor O(R * rules) lookups of the refspecs.\n\nThe new count_refspec_match_in_map() is equivalent to the previous\ncount_refspec_match():\n\n - count_refspec_match() for 'pattern' iterates every local ref,\n   adding a match for each 'refname_match(pattern, refname)',\n   which searches against the six ref_rev_parse_rules.\n\n - count_refspec_match_in_map() for 'pattern' generates the six\n   possible matches with expand_ref_prefix(), and then issues\n   one strmap lookup for each one.\n\nmatch_explicit() still works on the ref list, so temporarily introduce\nmatch_explicit_lhs_map() alongside match_explicit_lhs().  These two\nfunctions are recombined in a subsequent commit that converts\nmatch_explicit().\n\nTimings show benefit for the case where the client has many local refs\nand specifies multiple refspecs:\n\n  Test                           HEAD~1            HEAD\n  -----------------------------------------------------------------------\n  5516.3: empty:refspecs:1       0.14(0.07+0.11)   0.13(0.07+0.10) -7.1%\n  5516.5: empty:refspecs:10      0.16(0.10+0.10)   0.16(0.10+0.10) +0.0%\n  5516.7: empty:refspecs:100     0.47(0.41+0.10)   0.47(0.41+0.10) +0.0%\n  5516.9: mirror:refspecs:1      0.16(0.09+0.11)   0.16(0.10+0.11) +0.0%\n  5516.11: mirror:refspecs:10    0.26(0.19+0.11)   0.22(0.16+0.11) -15.4%\n  5516.13: mirror:refspecs:100   1.19(1.13+0.10)   0.82(0.75+0.11) -31.1%\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n remote.c | 186 ++++++++++++++++++++++++++++++++++++++-----------------\n 1 file changed, 129 insertions(+), 57 deletions(-)\n\ndiff --git a/remote.c b/remote.c\nindex 114d4d983c..91d35b37fe 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -21,6 +21,7 @@\n #include \"dir.h\"\n #include \"setup.h\"\n #include \"string-list.h\"\n+#include \"strmap.h\"\n #include \"strvec.h\"\n #include \"commit-reach.h\"\n #include \"advice.h\"\n@@ -1056,60 +1057,95 @@ void free_refs(struct ref *ref)\n \t}\n }\n \n+struct refspec_match {\n+\tstruct ref *matched_weak;\n+\tstruct ref *matched;\n+\tint weak_match;\n+\tint match;\n+};\n+\n+static void add_refspec_match(struct refspec_match *m, const char *pattern,\n+\t\t\t      struct ref *ref)\n+{\n+\tsize_t patlen = strlen(pattern);\n+\tsize_t namelen = strlen(ref->name);\n+\n+\t/* A match is \"weak\" if it is with refs outside\n+\t * heads or tags, and did not specify the pattern\n+\t * in full (e.g. \"refs/remotes/origin/master\") or at\n+\t * least from the toplevel (e.g. \"remotes/origin/master\");\n+\t * otherwise \"git push $URL master\" would result in\n+\t * ambiguity between remotes/origin/master and heads/master\n+\t * at the remote site.\n+\t */\n+\tif (namelen != patlen &&\n+\t    patlen != namelen - 5 &&\n+\t    !starts_with(ref->name, \"refs/heads/\") &&\n+\t    !starts_with(ref->name, \"refs/tags/\")) {\n+\t\t/* We want to catch the case where only weak\n+\t\t * matches are found and there are multiple\n+\t\t * matches, and where more than one strong\n+\t\t * matches are found, as ambiguous.  One\n+\t\t * strong match with zero or more weak matches\n+\t\t * are acceptable as a unique match.\n+\t\t */\n+\t\tm->matched_weak = ref;\n+\t\tm->weak_match++;\n+\t} else {\n+\t\tm->matched = ref;\n+\t\tm->match++;\n+\t}\n+}\n+\n+static int finish_refspec_match(const struct refspec_match *m,\n+\t\t\t\tstruct ref **matched_ref)\n+{\n+\tif (!m->matched) {\n+\t\tif (matched_ref)\n+\t\t\t*matched_ref = m->matched_weak;\n+\t\treturn m->weak_match;\n+\t}\n+\tif (matched_ref)\n+\t\t*matched_ref = m->matched;\n+\treturn m->match;\n+}\n+\n int count_refspec_match(const char *pattern,\n \t\t\tstruct ref *refs,\n \t\t\tstruct ref **matched_ref)\n {\n-\tint patlen = strlen(pattern);\n-\tstruct ref *matched_weak = NULL;\n-\tstruct ref *matched = NULL;\n-\tint weak_match = 0;\n-\tint match = 0;\n+\tstruct refspec_match m = { 0 };\n \n-\tfor (weak_match = match = 0; refs; refs = refs->next) {\n-\t\tchar *name = refs->name;\n-\t\tint namelen = strlen(name);\n+\tfor (; refs; refs = refs->next) {\n+\t\tif (refname_match(pattern, refs->name))\n+\t\t\tadd_refspec_match(&m, pattern, refs);\n+\t}\n+\treturn finish_refspec_match(&m, matched_ref);\n+}\n \n-\t\tif (!refname_match(pattern, name))\n-\t\t\tcontinue;\n+static void ref_map_init(struct strmap *map, struct ref *refs)\n+{\n+\tstrmap_init_with_options(map, NULL, 0);\n+\tfor (; refs; refs = refs->next)\n+\t\tstrmap_put(map, refs->name, refs);\n+}\n \n-\t\t/* A match is \"weak\" if it is with refs outside\n-\t\t * heads or tags, and did not specify the pattern\n-\t\t * in full (e.g. \"refs/remotes/origin/master\") or at\n-\t\t * least from the toplevel (e.g. \"remotes/origin/master\");\n-\t\t * otherwise \"git push $URL master\" would result in\n-\t\t * ambiguity between remotes/origin/master and heads/master\n-\t\t * at the remote site.\n-\t\t */\n-\t\tif (namelen != patlen &&\n-\t\t    patlen != namelen - 5 &&\n-\t\t    !starts_with(name, \"refs/heads/\") &&\n-\t\t    !starts_with(name, \"refs/tags/\")) {\n-\t\t\t/* We want to catch the case where only weak\n-\t\t\t * matches are found and there are multiple\n-\t\t\t * matches, and where more than one strong\n-\t\t\t * matches are found, as ambiguous.  One\n-\t\t\t * strong match with zero or more weak matches\n-\t\t\t * are acceptable as a unique match.\n-\t\t\t */\n-\t\t\tmatched_weak = refs;\n-\t\t\tweak_match++;\n-\t\t}\n-\t\telse {\n-\t\t\tmatched = refs;\n-\t\t\tmatch++;\n-\t\t}\n-\t}\n-\tif (!matched) {\n-\t\tif (matched_ref)\n-\t\t\t*matched_ref = matched_weak;\n-\t\treturn weak_match;\n-\t}\n-\telse {\n-\t\tif (matched_ref)\n-\t\t\t*matched_ref = matched;\n-\t\treturn match;\n+static int count_refspec_match_in_map(const char *pattern,\n+\t\t\t\t      struct strmap *refs,\n+\t\t\t\t      struct ref **matched_ref)\n+{\n+\tstruct refspec_match m = { 0 };\n+\tstruct strvec names = STRVEC_INIT;\n+\tsize_t i;\n+\n+\texpand_ref_prefix(&names, pattern);\n+\tfor (i = 0; i < names.nr; i++) {\n+\t\tstruct ref *ref = strmap_get(refs, names.v[i]);\n+\t\tif (ref)\n+\t\t\tadd_refspec_match(&m, pattern, ref);\n \t}\n+\tstrvec_clear(&names);\n+\treturn finish_refspec_match(&m, matched_ref);\n }\n \n void tail_link_ref(struct ref *ref, struct ref ***tail)\n@@ -1178,12 +1214,12 @@ static char *guess_ref(const char *name, struct ref *peer)\n \treturn strbuf_detach(&buf, NULL);\n }\n \n-static int match_explicit_lhs(struct ref *src,\n-\t\t\t      struct refspec_item *rs,\n-\t\t\t      struct ref **match,\n-\t\t\t      int *allocated_match)\n+static int match_explicit_lhs_count(const int count,\n+\t\t\t\t    struct refspec_item *rs,\n+\t\t\t\t    struct ref **match,\n+\t\t\t\t    int *allocated_match)\n {\n-\tswitch (count_refspec_match(rs->src, src, match)) {\n+\tswitch (count) {\n \tcase 1:\n \t\tif (allocated_match)\n \t\t\t*allocated_match = 0;\n@@ -1203,6 +1239,24 @@ static int match_explicit_lhs(struct ref *src,\n \t}\n }\n \n+static int match_explicit_lhs(struct ref *src,\n+\t\t\t      struct refspec_item *rs,\n+\t\t\t      struct ref **match,\n+\t\t\t      int *allocated_match)\n+{\n+\treturn match_explicit_lhs_count(count_refspec_match(rs->src, src, match),\n+\t\t\t\t\trs, match, allocated_match);\n+}\n+\n+static int match_explicit_lhs_map(struct strmap *src,\n+\t\t\t\t  struct refspec_item *rs,\n+\t\t\t\t  struct ref **match,\n+\t\t\t\t  int *allocated_match)\n+{\n+\treturn match_explicit_lhs_count(count_refspec_match_in_map(rs->src, src, match),\n+\t\t\t\t\trs, match, allocated_match);\n+}\n+\n static void show_push_unqualified_ref_name_error(const char *dst_value,\n \t\t\t\t\t\t const char *matched_src_name)\n {\n@@ -1265,6 +1319,20 @@ static void show_push_unqualified_ref_name_error(const char *dst_value,\n \t}\n }\n \n+static bool refspec_item_is_explicit(const struct refspec_item *item)\n+{\n+\treturn !item->pattern && !item->matching && !item->negative;\n+}\n+\n+static bool any_refspec_item_is_explicit(const struct refspec *rs)\n+{\n+\tfor (int i = 0; i < rs->nr; i++) {\n+\t\tif (refspec_item_is_explicit(&rs->items[i]))\n+\t\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n static int match_explicit(struct ref *src, struct ref *dst,\n \t\t\t  struct ref ***dst_tail,\n \t\t\t  struct refspec_item *rs)\n@@ -1275,7 +1343,7 @@ static int match_explicit(struct ref *src, struct ref *dst,\n \tconst char *dst_value = rs->dst;\n \tchar *dst_guess;\n \n-\tif (rs->pattern || rs->matching || rs->negative) {\n+\tif (!refspec_item_is_explicit(rs)) {\n \t\tret = 0;\n \t\tgoto out;\n \t}\n@@ -1564,17 +1632,21 @@ static void prepare_ref_index(struct string_list *ref_index, struct ref *ref)\n  */\n int check_push_refs(struct ref *src, struct refspec *rs)\n {\n+\tstruct strmap src_map;\n \tint ret = 0;\n-\tint i;\n \n-\tfor (i = 0; i < rs->nr; i++) {\n-\t\tstruct refspec_item *item = &rs->items[i];\n+\tif (!any_refspec_item_is_explicit(rs))\n+\t\treturn 0;\n \n-\t\tif (item->pattern || item->matching || item->negative)\n+\tref_map_init(&src_map, src);\n+\tfor (int i = 0; i < rs->nr; i++) {\n+\t\tstruct refspec_item *item = &rs->items[i];\n+\t\tif (!refspec_item_is_explicit(item))\n \t\t\tcontinue;\n \n-\t\tret |= match_explicit_lhs(src, item, NULL, NULL);\n+\t\tret |= match_explicit_lhs_map(&src_map, item, NULL, NULL);\n \t}\n+\tstrmap_clear(&src_map, 0);\n \n \treturn ret;\n }\n-- \n2.55.0\n\n\n"},{"id":"554644","messageId":"20261009192953.81794-8-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 07/15] t5516: test pushing two refspecs creating the same new branch","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:45Z","receivedAt":"2026-10-09T19:29:45Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"Pushing two refspecs that create the same new branch fails client-side\nas desired with \"dst ref <dst> receives from more than one src\", as long\nas the remote advertises at least one ref.\n\nBut pushing the same to an empty remote does not fail client-side as\nexpected: a --dry-run push prints \"[new branch]\" twice, and an actual\npush fails on the server side with \"multiple updates for ref '<dst>'\nnot allowed\".\n\nAdd tests for both remotes, with and without --dry-run.  The empty repo\npasses are marked as test_expect_failure, in preparation for a commit\nthat includes a fix and toggles them to test_expect_success.\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n t/t5516-fetch-push.sh | 36 ++++++++++++++++++++++++++++++++++++\n 1 file changed, 36 insertions(+)\n\ndiff --git a/t/t5516-fetch-push.sh b/t/t5516-fetch-push.sh\nindex 81ad6cd52f..e150246577 100755\n--- a/t/t5516-fetch-push.sh\n+++ b/t/t5516-fetch-push.sh\n@@ -400,6 +400,42 @@ test_expect_success 'push with no ambiguity (2)' '\n \tcheck_push_result testrepo $the_commit remotes/origin/main\n '\n \n+test_expect_success 'push --dry-run two refspecs targeting the same ref fails' '\n+\tmk_test testrepo heads/main &&\n+\ttest_must_fail git push --dry-run testrepo main:frotz main:frotz 2>err &&\n+\ttest_grep \"dst ref refs/heads/frotz receives from more than one src\" err\n+'\n+\n+test_expect_success 'push two refspecs targeting the same ref fails' '\n+\tmk_test testrepo heads/main &&\n+\ttest_must_fail git push testrepo main:frotz main:frotz 2>err &&\n+\ttest_grep \"dst ref refs/heads/frotz receives from more than one src\" err\n+'\n+\n+test_expect_failure 'push --dry-run two refspecs creating the same ref fails on empty repo' '\n+\tmk_empty testrepo &&\n+\ttest_must_fail git push --dry-run testrepo main:frotz main:frotz 2>err &&\n+\ttest_grep \"dst ref refs/heads/frotz receives from more than one src\" err\n+'\n+\n+test_expect_failure 'push two refspecs creating the same ref fails on empty repo' '\n+\tmk_empty testrepo &&\n+\ttest_must_fail git push testrepo main:frotz main:frotz 2>err &&\n+\ttest_grep \"dst ref refs/heads/frotz receives from more than one src\" err\n+'\n+\n+test_expect_failure 'push --dry-run abbreviated then full refspec creating the same ref fails on empty repo' '\n+\tmk_empty testrepo &&\n+\ttest_must_fail git push --dry-run testrepo main:frotz main:refs/heads/frotz 2>err &&\n+\ttest_grep \"dst ref refs/heads/frotz receives from more than one src\" err\n+'\n+\n+test_expect_failure 'push abbreviated then full refspec creating the same ref fails on empty repo' '\n+\tmk_empty testrepo &&\n+\ttest_must_fail git push testrepo main:frotz main:refs/heads/frotz 2>err &&\n+\ttest_grep \"dst ref refs/heads/frotz receives from more than one src\" err\n+'\n+\n test_expect_success 'push with colon-less refspec, no ambiguity' '\n \tmk_test testrepo heads/main heads/t/main &&\n \tgit branch -f t/main main &&\n-- \n2.55.0\n\n\n"},{"id":"554645","messageId":"20261009192953.81794-9-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 08/15] t5408, t5410: test duplicate updates without relying on the client","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:46Z","receivedAt":"2026-10-09T19:29:46Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"The existing t5408 tests that use send-pack to transmit duplicate\nupdates for the same destination happen to rely on the client not\nrecognizing the duplicates when the remote is empty.\n\nAs a result, they are the only push tests that exercise each of:\n\n - receive-pack rejection of multiple updates for the same ref, from\n   9d2962a7c4 (receive-pack: use batched reference updates, 2025-05-19)\n\n - send-pack's receive_status() handling of a ref reported twice by the\n   remote, from 77188b5bba (send-pack: fix memory leak around duplicate\n   refs, 2025-05-19)\n\nAdd explicit tests for each of those paths, in preparation of an\nupcoming commit that fixes the client to reject such pushes before\nsending any update.\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n t/t5408-send-pack-stdin.sh | 12 ++++++++++++\n t/t5410-receive-pack.sh    | 35 +++++++++++++++++++++++++++++++++++\n 2 files changed, 47 insertions(+)\n\ndiff --git a/t/t5408-send-pack-stdin.sh b/t/t5408-send-pack-stdin.sh\nindex ec339761c2..3c47be1af8 100755\n--- a/t/t5408-send-pack-stdin.sh\n+++ b/t/t5408-send-pack-stdin.sh\n@@ -89,6 +89,18 @@ test_expect_success '--stdin refs come after cmdline' '\n \ttest_must_fail git --git-dir=remote.git rev-parse foo\n '\n \n+test_expect_success 'send-pack handles repeated status for the same ref' '\n+\tclear_remote &&\n+\ttest_hook -C remote.git receive-report <<-\\EOF &&\n+\tcat >/dev/null &&\n+\tprintf \"%s\\n\" \"unpack ok\" \"ng refs/heads/foo first\" \\\n+\t\t\"ng refs/heads/foo second\" 0000 |\n+\ttest-tool pkt-line pack\n+\tEOF\n+\ttest_must_fail git send-pack remote.git A:foo 2>err &&\n+\ttest_grep \"remote rejected.*A -> foo (second)\" err\n+'\n+\n test_expect_success 'refspecs and --mirror do not mix (cmdline)' '\n \tclear_remote &&\n \ttest_must_fail git send-pack remote.git --mirror $(cat refs)\ndiff --git a/t/t5410-receive-pack.sh b/t/t5410-receive-pack.sh\nindex 09d6bfd2a1..8fbc0c6bc9 100755\n--- a/t/t5410-receive-pack.sh\n+++ b/t/t5410-receive-pack.sh\n@@ -97,4 +97,39 @@ test_expect_success TEE_DOES_NOT_HANG \\\n \ttest_must_fail git -C remote.git rev-list $(git -C repo rev-parse HEAD)\n '\n \n+test_expect_success 'receive-pack rejects multiple updates for the same ref' '\n+\ttest_when_finished \"rm -rf repo remote.git\" &&\n+\n+\tgit init repo &&\n+\tgit -C repo commit --allow-empty -m A &&\n+\tgit -C repo branch A &&\n+\tgit -C repo commit --allow-empty -m B &&\n+\tgit -C repo branch B &&\n+\tgit init --bare remote.git &&\n+\tgit -C repo send-pack ../remote.git A B &&\n+\tA=$(git -C repo rev-parse A) &&\n+\tB=$(git -C repo rev-parse B) &&\n+\t{\n+\t\tprintf \"%s %s refs/heads/foo\\0report-status object-format=%s\" \\\n+\t\t\t$ZERO_OID $A \"$(test_oid algo)\" |\n+\t\ttest-tool pkt-line pack-raw-stdin &&\n+\t\tprintf \"%s %s refs/heads/foo\" $ZERO_OID $B |\n+\t\ttest-tool pkt-line pack-raw-stdin &&\n+\t\tprintf 0000 &&\n+\t\tgit pack-objects --stdout </dev/null\n+\t} >request &&\n+\tgit receive-pack remote.git <request >response 2>err &&\n+\ttest_grep \"multiple updates for ref ${SQ}refs/heads/foo${SQ} not allowed\" err &&\n+\ttest-tool pkt-line unpack <response >report &&\n+\tsed -n \"/^unpack /,\\$p\" report >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tunpack ok\n+\tng refs/heads/foo failed to update refs\n+\tng refs/heads/foo failed to update refs\n+\t0000\n+\tEOF\n+\ttest_cmp expect actual &&\n+\ttest_must_fail git --git-dir=remote.git rev-parse --verify refs/heads/foo\n+'\n+\n test_done\n-- \n2.55.0\n\n\n"},{"id":"554646","messageId":"20261009192953.81794-10-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 09/15] t5408: check refspec order with distinct destinations","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:47Z","receivedAt":"2026-10-09T19:29:47Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"26be19ba8d (send-pack: take refspecs over stdin, 2014-08-21) added tests\nto assert that send-pack sends command-line refspecs in the order given,\nand those read with --stdin after them, by pushing to the same\ndestination twice, and seeing which update succeeded.\n\nBut since 9d2962a7c4 (receive-pack: use batched reference updates,\n2025-05-19) such pushes to the same destination now fail completely on\nthe server-side with \"multiple updates for ref '<dst>' not allowed\",\nand so the ordering is no longer being asserted.\n\nAdd two explicit tests to reinstate the ordering assertions.\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n t/t5408-send-pack-stdin.sh | 30 ++++++++++++++++++++++++++++++\n 1 file changed, 30 insertions(+)\n\ndiff --git a/t/t5408-send-pack-stdin.sh b/t/t5408-send-pack-stdin.sh\nindex 3c47be1af8..7af350f01c 100755\n--- a/t/t5408-send-pack-stdin.sh\n+++ b/t/t5408-send-pack-stdin.sh\n@@ -67,6 +67,36 @@ test_expect_success 'stdin mixed with cmdline' '\n \tverify_push B\n '\n \n+test_expect_success 'cmdline refs are sent in order' '\n+\tclear_remote &&\n+\ttest_hook -C remote.git pre-receive <<-\\EOF &&\n+\tcut -d\" \" -f3 >pushed-refs\n+\tEOF\n+\tgit send-pack remote.git A:foo B:bar C:baz &&\n+\tcat >expect <<-\\EOF &&\n+\trefs/heads/foo\n+\trefs/heads/bar\n+\trefs/heads/baz\n+\tEOF\n+\ttest_cmp expect remote.git/pushed-refs\n+'\n+\n+test_expect_success '--stdin refs are sent after cmdline refs' '\n+\tclear_remote &&\n+\ttest_hook -C remote.git pre-receive <<-\\EOF &&\n+\tcut -d\" \" -f3 >pushed-refs\n+\tEOF\n+\techo A:bar >input &&\n+\tgit send-pack remote.git --stdin B:foo <input &&\n+\tcat >expect <<-\\EOF &&\n+\trefs/heads/foo\n+\trefs/heads/bar\n+\tEOF\n+\ttest_cmp expect remote.git/pushed-refs &&\n+\tverify_push B foo &&\n+\tverify_push A bar\n+'\n+\n test_expect_success 'cmdline refs written in order' '\n \tclear_remote &&\n \ttest_must_fail git send-pack remote.git A:foo B:foo 2>err &&\n-- \n2.55.0\n\n\n"},{"id":"554647","messageId":"20261009192953.81794-11-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 10/15] t5408: expect client-side error for duplicate destinations","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:48Z","receivedAt":"2026-10-09T19:29:48Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"Update the error message expectation for the existing t5408 tests that\nuse send-pack to transmit duplicate updates to an empty remote, and\ntoggle them test_expect_failure.\n\nThe tests previously asserted the server-side rejection \"multiple\nupdates for ref '<dst>' not allowed\".\n\nThe next commit rejects these pushes on the client-side such that\nsend-pack fails earlier with \"dst ref <dst> receives from more than\none src\", and never reaches the server.  The tests are toggled to\ntest_expect_success then.\n\nWhile here, change the test descriptions to note that they are no\nlonger asserting anything about the order of partial ref updates.\n\nAlso included is a fix for a missing \"2>err\" in the '--stdin refs\ncome after cmdline' test.\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n t/t5408-send-pack-stdin.sh | 14 +++++++-------\n 1 file changed, 7 insertions(+), 7 deletions(-)\n\ndiff --git a/t/t5408-send-pack-stdin.sh b/t/t5408-send-pack-stdin.sh\nindex 7af350f01c..0321519f21 100755\n--- a/t/t5408-send-pack-stdin.sh\n+++ b/t/t5408-send-pack-stdin.sh\n@@ -97,25 +97,25 @@ test_expect_success '--stdin refs are sent after cmdline refs' '\n \tverify_push A bar\n '\n \n-test_expect_success 'cmdline refs written in order' '\n+test_expect_failure 'two cmdline refs for the same destination are rejected' '\n \tclear_remote &&\n \ttest_must_fail git send-pack remote.git A:foo B:foo 2>err &&\n-\ttest_grep \"multiple updates for ref ${SQ}refs/heads/foo${SQ} not allowed\" err &&\n+\ttest_grep \"dst ref refs/heads/foo receives from more than one src\" err &&\n \ttest_must_fail git --git-dir=remote.git rev-parse foo\n '\n \n-test_expect_success 'cmdline refs with multiple duplicates' '\n+test_expect_failure 'three cmdline refs for the same destination are rejected' '\n \tclear_remote &&\n \ttest_must_fail git send-pack remote.git A:foo B:foo C:foo 2>err &&\n-\ttest_grep \"multiple updates for ref ${SQ}refs/heads/foo${SQ} not allowed\" err &&\n+\ttest_grep \"dst ref refs/heads/foo receives from more than one src\" err &&\n \ttest_must_fail git --git-dir=remote.git rev-parse foo\n '\n \n-test_expect_success '--stdin refs come after cmdline' '\n+test_expect_failure 'cmdline and --stdin refs for the same destination are rejected' '\n \tclear_remote &&\n \techo A:foo >input &&\n-\ttest_must_fail git send-pack remote.git --stdin B:foo <input &&\n-\ttest_grep \"multiple updates for ref ${SQ}refs/heads/foo${SQ} not allowed\" err &&\n+\ttest_must_fail git send-pack remote.git --stdin B:foo <input 2>err &&\n+\ttest_grep \"dst ref refs/heads/foo receives from more than one src\" err &&\n \ttest_must_fail git --git-dir=remote.git rev-parse foo\n '\n \n-- \n2.55.0\n\n\n"},{"id":"554648","messageId":"20261009192953.81794-12-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 11/15] remote: reject duplicate destinations on an empty remote","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:49Z","receivedAt":"2026-10-09T19:29:49Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"match_explicit_refs() passes the head of the remote ref list, 'dst',\nto match_explicit() for every refspec.  match_explicit() searches that\nlist via count_refspec_match() and, when nothing matches, appends a\nnew destination through 'dst_tail'.\n\nThis works when the remote already has refs: 'dst' is non-NULL, so each\nloop iteration walks from the same head and finds destinations linked\nat the tail.\n\nThis fails when the remote is empty and 'dst' is NULL.  match_push_refs()\ncomputes 'dst_tail' with tail_ref(), which for an empty list is the head\nslot itself: so the first link writes through '*dst'.  But each loop\niteration still provides the original NULL to match_explicit(), with\nthe result that newly-linked destinations are not seen.\n\nThe behavior dates back to f88395ac23 (Renaming push., 2005-08-03),\nwhere match_explicit_refs() searches the destination list from the\n'dst' it was given while appending new destinations through 'dst_tail'.\n\nPass the remote refs head by pointer in match_explicit_refs() so that\neach loop iteration re-reads '*dst' and detects a destination created\nby an earlier refspec.\n\nWith this, failing tests in t5408, t5516 now pass and are toggled to\ntest_expect_success.\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n remote.c                   | 6 +++---\n t/t5408-send-pack-stdin.sh | 6 +++---\n t/t5516-fetch-push.sh      | 8 ++++----\n 3 files changed, 10 insertions(+), 10 deletions(-)\n\ndiff --git a/remote.c b/remote.c\nindex 91d35b37fe..f4cf63b756 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -1416,12 +1416,12 @@ static int match_explicit(struct ref *src, struct ref *dst,\n \treturn ret;\n }\n \n-static int match_explicit_refs(struct ref *src, struct ref *dst,\n+static int match_explicit_refs(struct ref *src, struct ref **dst,\n \t\t\t       struct ref ***dst_tail, struct refspec *rs)\n {\n \tint i, errs;\n \tfor (i = errs = 0; i < rs->nr; i++)\n-\t\terrs += match_explicit(src, dst, dst_tail, &rs->items[i]);\n+\t\terrs += match_explicit(src, *dst, dst_tail, &rs->items[i]);\n \treturn errs;\n }\n \n@@ -1673,7 +1673,7 @@ int match_push_refs(struct ref *src, struct ref **dst,\n \tif (!rs->nr)\n \t\trefspec_append(rs, \":\");\n \n-\terrs = match_explicit_refs(src, *dst, &dst_tail, rs);\n+\terrs = match_explicit_refs(src, dst, &dst_tail, rs);\n \n \t/* pick the remainder */\n \tfor (ref = src; ref; ref = ref->next) {\ndiff --git a/t/t5408-send-pack-stdin.sh b/t/t5408-send-pack-stdin.sh\nindex 0321519f21..1e34880323 100755\n--- a/t/t5408-send-pack-stdin.sh\n+++ b/t/t5408-send-pack-stdin.sh\n@@ -97,21 +97,21 @@ test_expect_success '--stdin refs are sent after cmdline refs' '\n \tverify_push A bar\n '\n \n-test_expect_failure 'two cmdline refs for the same destination are rejected' '\n+test_expect_success 'two cmdline refs for the same destination are rejected' '\n \tclear_remote &&\n \ttest_must_fail git send-pack remote.git A:foo B:foo 2>err &&\n \ttest_grep \"dst ref refs/heads/foo receives from more than one src\" err &&\n \ttest_must_fail git --git-dir=remote.git rev-parse foo\n '\n \n-test_expect_failure 'three cmdline refs for the same destination are rejected' '\n+test_expect_success 'three cmdline refs for the same destination are rejected' '\n \tclear_remote &&\n \ttest_must_fail git send-pack remote.git A:foo B:foo C:foo 2>err &&\n \ttest_grep \"dst ref refs/heads/foo receives from more than one src\" err &&\n \ttest_must_fail git --git-dir=remote.git rev-parse foo\n '\n \n-test_expect_failure 'cmdline and --stdin refs for the same destination are rejected' '\n+test_expect_success 'cmdline and --stdin refs for the same destination are rejected' '\n \tclear_remote &&\n \techo A:foo >input &&\n \ttest_must_fail git send-pack remote.git --stdin B:foo <input 2>err &&\ndiff --git a/t/t5516-fetch-push.sh b/t/t5516-fetch-push.sh\nindex e150246577..2f46ddc9ba 100755\n--- a/t/t5516-fetch-push.sh\n+++ b/t/t5516-fetch-push.sh\n@@ -412,25 +412,25 @@ test_expect_success 'push two refspecs targeting the same ref fails' '\n \ttest_grep \"dst ref refs/heads/frotz receives from more than one src\" err\n '\n \n-test_expect_failure 'push --dry-run two refspecs creating the same ref fails on empty repo' '\n+test_expect_success 'push --dry-run two refspecs creating the same ref fails on empty repo' '\n \tmk_empty testrepo &&\n \ttest_must_fail git push --dry-run testrepo main:frotz main:frotz 2>err &&\n \ttest_grep \"dst ref refs/heads/frotz receives from more than one src\" err\n '\n \n-test_expect_failure 'push two refspecs creating the same ref fails on empty repo' '\n+test_expect_success 'push two refspecs creating the same ref fails on empty repo' '\n \tmk_empty testrepo &&\n \ttest_must_fail git push testrepo main:frotz main:frotz 2>err &&\n \ttest_grep \"dst ref refs/heads/frotz receives from more than one src\" err\n '\n \n-test_expect_failure 'push --dry-run abbreviated then full refspec creating the same ref fails on empty repo' '\n+test_expect_success 'push --dry-run abbreviated then full refspec creating the same ref fails on empty repo' '\n \tmk_empty testrepo &&\n \ttest_must_fail git push --dry-run testrepo main:frotz main:refs/heads/frotz 2>err &&\n \ttest_grep \"dst ref refs/heads/frotz receives from more than one src\" err\n '\n \n-test_expect_failure 'push abbreviated then full refspec creating the same ref fails on empty repo' '\n+test_expect_success 'push abbreviated then full refspec creating the same ref fails on empty repo' '\n \tmk_empty testrepo &&\n \ttest_must_fail git push testrepo main:frotz main:refs/heads/frotz 2>err &&\n \ttest_grep \"dst ref refs/heads/frotz receives from more than one src\" err\n-- \n2.55.0\n\n\n"},{"id":"554649","messageId":"20261009192953.81794-13-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 12/15] remote: use strmap for match_explicit_refs()","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:50Z","receivedAt":"2026-10-09T19:29:50Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"Optimize match_explicit_refs() by replacing two linear refs traversals\nwith strmap lookups.\n\nBefore this change, matching R explicit refspecs against N local refs\nand M remote refs in match_explicit() entails O(R * N) and O(R * M)\ncalls to refname_match() for sources and destinations respectively.\n\nAfter this change, we build a strmap for local refs O(N), a strmap for\nremote refs O(M), and use them for O(R * rules) lookups of the refspecs.\n\nRefspecs are resolved using count_refspec_match_in_map() introduced\nearlier in the series, which issues one strmap lookup for each candidate\nrules pattern generated with expand_ref_prefix().\n\nThe match_explicit_lhs_map() function introduced in a previous commit\nhas now been recombined into match_explicit_lhs(): all call sites are\nnow using the new strmap variation.\n\nTimings show benefit where one or both of the client and remote have\nmany refs, and multiple refspecs are specified:\n\n  Test                           HEAD~1            HEAD\n  -----------------------------------------------------------------------\n  5516.3: empty:refspecs:1       0.13(0.07+0.10)   0.14(0.07+0.10) +7.7%\n  5516.5: empty:refspecs:10      0.17(0.10+0.11)   0.14(0.07+0.11) -17.6%\n  5516.7: empty:refspecs:100     0.47(0.41+0.10)   0.15(0.08+0.10) -68.1%\n  5516.9: mirror:refspecs:1      0.17(0.10+0.11)   0.17(0.10+0.11) +0.0%\n  5516.11: mirror:refspecs:10    0.23(0.16+0.11)   0.17(0.10+0.11) -26.1%\n  5516.13: mirror:refspecs:100   0.87(0.80+0.11)   0.18(0.11+0.11) -79.3%\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n remote.c | 54 +++++++++++++++++++++++++-----------------------------\n 1 file changed, 25 insertions(+), 29 deletions(-)\n\ndiff --git a/remote.c b/remote.c\nindex f4cf63b756..8074750601 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -1214,12 +1214,12 @@ static char *guess_ref(const char *name, struct ref *peer)\n \treturn strbuf_detach(&buf, NULL);\n }\n \n-static int match_explicit_lhs_count(const int count,\n-\t\t\t\t    struct refspec_item *rs,\n-\t\t\t\t    struct ref **match,\n-\t\t\t\t    int *allocated_match)\n+static int match_explicit_lhs(struct strmap *src,\n+\t\t\t      struct refspec_item *rs,\n+\t\t\t      struct ref **match,\n+\t\t\t      int *allocated_match)\n {\n-\tswitch (count) {\n+\tswitch (count_refspec_match_in_map(rs->src, src, match)) {\n \tcase 1:\n \t\tif (allocated_match)\n \t\t\t*allocated_match = 0;\n@@ -1239,24 +1239,6 @@ static int match_explicit_lhs_count(const int count,\n \t}\n }\n \n-static int match_explicit_lhs(struct ref *src,\n-\t\t\t      struct refspec_item *rs,\n-\t\t\t      struct ref **match,\n-\t\t\t      int *allocated_match)\n-{\n-\treturn match_explicit_lhs_count(count_refspec_match(rs->src, src, match),\n-\t\t\t\t\trs, match, allocated_match);\n-}\n-\n-static int match_explicit_lhs_map(struct strmap *src,\n-\t\t\t\t  struct refspec_item *rs,\n-\t\t\t\t  struct ref **match,\n-\t\t\t\t  int *allocated_match)\n-{\n-\treturn match_explicit_lhs_count(count_refspec_match_in_map(rs->src, src, match),\n-\t\t\t\t\trs, match, allocated_match);\n-}\n-\n static void show_push_unqualified_ref_name_error(const char *dst_value,\n \t\t\t\t\t\t const char *matched_src_name)\n {\n@@ -1333,7 +1315,7 @@ static bool any_refspec_item_is_explicit(const struct refspec *rs)\n \treturn false;\n }\n \n-static int match_explicit(struct ref *src, struct ref *dst,\n+static int match_explicit(struct strmap *src, struct strmap *dst,\n \t\t\t  struct ref ***dst_tail,\n \t\t\t  struct refspec_item *rs)\n {\n@@ -1367,7 +1349,7 @@ static int match_explicit(struct ref *src, struct ref *dst,\n \t\t\t    matched_src->name);\n \t}\n \n-\tswitch (count_refspec_match(dst_value, dst, &matched_dst)) {\n+\tswitch (count_refspec_match_in_map(dst_value, dst, &matched_dst)) {\n \tcase 1:\n \t\tbreak;\n \tcase 0:\n@@ -1383,6 +1365,9 @@ static int match_explicit(struct ref *src, struct ref *dst,\n \t\t\tshow_push_unqualified_ref_name_error(dst_value,\n \t\t\t\t\t\t\t     matched_src->name);\n \t\t}\n+\t\t/* later refspecs must see the ref we just added to dst */\n+\t\tif (matched_dst)\n+\t\t\tstrmap_put(dst, matched_dst->name, matched_dst);\n \t\tbreak;\n \tdefault:\n \t\tmatched_dst = NULL;\n@@ -1416,12 +1401,23 @@ static int match_explicit(struct ref *src, struct ref *dst,\n \treturn ret;\n }\n \n-static int match_explicit_refs(struct ref *src, struct ref **dst,\n+static int match_explicit_refs(struct ref *src, struct ref *dst,\n \t\t\t       struct ref ***dst_tail, struct refspec *rs)\n {\n \tint i, errs;\n+\tstruct strmap src_map, dst_map;\n+\n+\tif (!any_refspec_item_is_explicit(rs))\n+\t\treturn 0;\n+\n+\tref_map_init(&src_map, src);\n+\tref_map_init(&dst_map, dst);\n \tfor (i = errs = 0; i < rs->nr; i++)\n-\t\terrs += match_explicit(src, *dst, dst_tail, &rs->items[i]);\n+\t\terrs += match_explicit(&src_map, &dst_map, dst_tail,\n+\t\t\t\t       &rs->items[i]);\n+\tstrmap_clear(&dst_map, 0);\n+\tstrmap_clear(&src_map, 0);\n+\n \treturn errs;\n }\n \n@@ -1644,7 +1640,7 @@ int check_push_refs(struct ref *src, struct refspec *rs)\n \t\tif (!refspec_item_is_explicit(item))\n \t\t\tcontinue;\n \n-\t\tret |= match_explicit_lhs_map(&src_map, item, NULL, NULL);\n+\t\tret |= match_explicit_lhs(&src_map, item, NULL, NULL);\n \t}\n \tstrmap_clear(&src_map, 0);\n \n@@ -1673,7 +1669,7 @@ int match_push_refs(struct ref *src, struct ref **dst,\n \tif (!rs->nr)\n \t\trefspec_append(rs, \":\");\n \n-\terrs = match_explicit_refs(src, dst, &dst_tail, rs);\n+\terrs = match_explicit_refs(src, *dst, &dst_tail, rs);\n \n \t/* pick the remainder */\n \tfor (ref = src; ref; ref = ref->next) {\n-- \n2.55.0\n\n\n"},{"id":"554650","messageId":"20261009192953.81794-14-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 13/15] t/perf: measure --force-with-lease in p5516","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:51Z","receivedAt":"2026-10-09T19:29:51Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"Extend p5516 with rows that push the same delete refspecs plus one\nexplicit --force-with-lease=<refname>:<expect> per refspec.\n\nThe new rows are used to demonstrate a speedup in a subsequent commit.\n\nNumbers as of this commit\n(`./p5516-push-delete-refspec.sh -r create,lease`):\n\n  Test                        this tree\n  -------------------------------------------\n  5516.16: empty:lease:1      0.12(0.06+0.10)\n  5516.19: empty:lease:10     0.17(0.10+0.10)\n  5516.22: empty:lease:100    0.45(0.38+0.10)\n  5516.25: mirror:lease:1     0.15(0.09+0.10)\n  5516.28: mirror:lease:10    0.18(0.12+0.10)\n  5516.31: mirror:lease:100   0.52(0.42+0.10)\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n t/perf/p5516-push-delete-refspec.sh | 25 +++++++++++++++++++++++++\n 1 file changed, 25 insertions(+)\n\ndiff --git a/t/perf/p5516-push-delete-refspec.sh b/t/perf/p5516-push-delete-refspec.sh\nindex 0e425193e5..a1e3f74bf7 100755\n--- a/t/perf/p5516-push-delete-refspec.sh\n+++ b/t/perf/p5516-push-delete-refspec.sh\n@@ -6,6 +6,9 @@ Measure client-side matching of explicit delete refspecs with \"git push\n --dry-run\" against a server that advertises lots of refs.  An empty client\n (no local refs) and a mirror client (full local copy of the server refs)\n are tested pushing 1, 10, and 100 refspecs each.\n+\n+A second set of rows measures one --force-with-lease per refspec for\n+timing those paths.\n '\n . ./perf-lib.sh\n \n@@ -24,6 +27,8 @@ test_expect_success 'create server with many refs and two clients' '\n \tgit -C client_mirror config --unset remote.origin.mirror\n '\n \n+oid=$(git -C server rev-parse HEAD)\n+\n for mode in empty mirror\n do\n \tclient=client_$mode\n@@ -39,4 +44,24 @@ do\n \tdone\n done\n \n+for mode in empty mirror\n+do\n+\tclient=client_$mode\n+\tfor nr_refspecs in 1 10 100\n+\tdo\n+\t\ttest_expect_success \"create $mode lease refspecs: $nr_refspecs\" '\n+\t\t\ttest_seq -f \":refs/heads/b%d\" $nr_refspecs >refspecs\n+\t\t'\n+\n+\t\ttest_expect_success \"create $mode leases: $nr_refspecs\" '\n+\t\t\ttest_seq -f \"refs/heads/b%d:'\"$oid\"'\" $nr_refspecs |\n+\t\t\tsed \"s/^/--force-with-lease=/\" >leases\n+\t\t'\n+\n+\t\ttest_perf \"$mode:lease:$nr_refspecs\" '\n+\t\t\tgit -C '\"$client\"' push --dry-run origin $(cat refspecs) $(cat leases)\n+\t\t'\n+\tdone\n+done\n+\n test_done\n-- \n2.55.0\n\n\n"},{"id":"554651","messageId":"20261009192953.81794-15-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 14/15] remote: restructure apply_push_cas() loops","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:52Z","receivedAt":"2026-10-09T19:29:52Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"Restructure the apply_push_cas() loops to prepare for replacing\na linear remote_refs traversal with a strmap lookup.\n\nBefore this change, apply_push_cas() loops:\n\n    for each advertised remote ref,\n      apply_cas(ref):\n        for each explicit lease entry until first match,\n          refname_match(entry, ref)\n        else if use_tracking_for_rest,\n          stamp from tracking\n\nAfter this change the loops are inverted and apply_cas() is split\ninto apply_one_cas() (one explicit entry, stamp every matching\nstill-free ref) and apply_cas_tracking() (shared by the explicit\nuse_tracking path and use_tracking_for_rest):\n\n    for each explicit lease entry,\n      apply_one_cas(entry):\n        for each advertised remote ref not yet stamped,\n          refname_match(entry, ref)\n\n    if use_tracking_for_rest,\n      apply_cas_tracking() on remaining refs\n\nIn the next commit, apply_one_cas() is updated to use a strmap.\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n remote.c | 66 ++++++++++++++++++++++++++++++++------------------------\n 1 file changed, 38 insertions(+), 28 deletions(-)\n\ndiff --git a/remote.c b/remote.c\nindex 8074750601..8dd163038d 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2978,33 +2978,10 @@ static void check_if_includes_upstream(struct ref *remote)\n \tfree_one_ref(local);\n }\n \n-static void apply_cas(struct push_cas_option *cas,\n-\t\t      struct remote *remote,\n-\t\t      struct ref *ref)\n+static void apply_cas_tracking(struct push_cas_option *cas,\n+\t\t\t       struct remote *remote,\n+\t\t\t       struct ref *ref)\n {\n-\tint i;\n-\n-\t/* Find an explicit --<option>=<name>[:<value>] entry */\n-\tfor (i = 0; i < cas->nr; i++) {\n-\t\tstruct push_cas *entry = &cas->entry[i];\n-\t\tif (!refname_match(entry->refname, ref->name))\n-\t\t\tcontinue;\n-\t\tref->expect_old_sha1 = 1;\n-\t\tif (!entry->use_tracking)\n-\t\t\toidcpy(&ref->old_oid_expect, &entry->expect);\n-\t\telse if (remote_tracking(remote, ref->name,\n-\t\t\t\t\t &ref->old_oid_expect,\n-\t\t\t\t\t &ref->tracking_ref))\n-\t\t\toidclr(&ref->old_oid_expect, the_repository->hash_algo);\n-\t\telse\n-\t\t\tref->check_reachable = cas->use_force_if_includes;\n-\t\treturn;\n-\t}\n-\n-\t/* Are we using \"--<option>\" to cover all? */\n-\tif (!cas->use_tracking_for_rest)\n-\t\treturn;\n-\n \tref->expect_old_sha1 = 1;\n \tif (remote_tracking(remote, ref->name,\n \t\t\t    &ref->old_oid_expect,\n@@ -3014,14 +2991,47 @@ static void apply_cas(struct push_cas_option *cas,\n \t\tref->check_reachable = cas->use_force_if_includes;\n }\n \n+static void apply_one_cas(struct ref *remote_refs,\n+\t\t\t  struct remote *remote,\n+\t\t\t  struct push_cas_option *cas,\n+\t\t\t  struct push_cas *entry)\n+{\n+\tstruct ref *ref;\n+\n+\tfor (ref = remote_refs; ref; ref = ref->next) {\n+\t\tif (ref->expect_old_sha1)\n+\t\t\tcontinue;\n+\t\tif (!refname_match(entry->refname, ref->name))\n+\t\t\tcontinue;\n+\t\tif (entry->use_tracking) {\n+\t\t\tapply_cas_tracking(cas, remote, ref);\n+\t\t} else {\n+\t\t\tref->expect_old_sha1 = 1;\n+\t\t\toidcpy(&ref->old_oid_expect, &entry->expect);\n+\t\t}\n+\t}\n+}\n+\n void apply_push_cas(struct push_cas_option *cas,\n \t\t    struct remote *remote,\n \t\t    struct ref *remote_refs)\n {\n \tstruct ref *ref;\n-\tfor (ref = remote_refs; ref; ref = ref->next) {\n-\t\tapply_cas(cas, remote, ref);\n \n+\t/* Apply each explicit --<option>=<name>[:<value>] entry */\n+\tfor (size_t i = 0; i < cas->nr; i++)\n+\t\tapply_one_cas(remote_refs, remote, cas, &cas->entry[i]);\n+\n+\t/* Are we using \"--<option>\" to cover all? */\n+\tif (cas->use_tracking_for_rest) {\n+\t\tfor (ref = remote_refs; ref; ref = ref->next) {\n+\t\t\tif (ref->expect_old_sha1)\n+\t\t\t\tcontinue;\n+\t\t\tapply_cas_tracking(cas, remote, ref);\n+\t\t}\n+\t}\n+\n+\tfor (ref = remote_refs; ref; ref = ref->next) {\n \t\t/*\n \t\t * If \"compare-and-swap\" is in \"use_tracking[_for_rest]\"\n \t\t * mode, and if \"--force-if-includes\" was specified, run\n-- \n2.55.0\n\n\n"},{"id":"554652","messageId":"20261009192953.81794-16-jon@jonsimons.org","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"[PATCH 15/15] remote: use strmap for apply_push_cas()","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-09T19:29:53Z","receivedAt":"2026-10-09T19:29:53Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"Optimize apply_push_cas() by replacing a linear traversal of all\nadvertised remote refs with strmap lookups.\n\nBefore this change, applying N explicit --force-with-lease entries\nto M remote advertised refs entails O(N * M) calls to refname_match()\nin apply_one_cas().\n\nAfter this change, we build a strmap of remote refs O(M), and use it\nfor O(N * rules) lookups of the lease entry refnames.\n\nThe refnames are resolved by issuing one strmap lookup for each\ncandidate rules pattern generated with expand_ref_prefix(), as\nwith earlier strmap conversions.\n\np5516 timings show the speedup:\n\n  Test                        HEAD~1            HEAD\n  --------------------------------------------------------------------\n  5516.16: empty:lease:1      0.13(0.07+0.10)   0.14(0.08+0.11) +7.7%\n  5516.19: empty:lease:10     0.16(0.10+0.10)   0.14(0.08+0.11) -12.5%\n  5516.22: empty:lease:100    0.45(0.39+0.10)   0.16(0.09+0.11) -64.4%\n  5516.25: mirror:lease:1     0.16(0.09+0.11)   0.17(0.10+0.12) +6.3%\n  5516.28: mirror:lease:10    0.19(0.12+0.10)   0.17(0.10+0.11) -10.5%\n  5516.31: mirror:lease:100   0.49(0.42+0.12)   0.19(0.11+0.12) -61.2%\n\nSigned-off-by: Jon Simons <jon@jonsimons.org>\n---\n remote.c | 23 +++++++++++++++--------\n 1 file changed, 15 insertions(+), 8 deletions(-)\n\ndiff --git a/remote.c b/remote.c\nindex 8dd163038d..c16f1a2b82 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -2991,17 +2991,17 @@ static void apply_cas_tracking(struct push_cas_option *cas,\n \t\tref->check_reachable = cas->use_force_if_includes;\n }\n \n-static void apply_one_cas(struct ref *remote_refs,\n+static void apply_one_cas(struct strmap *ref_map,\n \t\t\t  struct remote *remote,\n \t\t\t  struct push_cas_option *cas,\n \t\t\t  struct push_cas *entry)\n {\n-\tstruct ref *ref;\n+\tstruct strvec names = STRVEC_INIT;\n \n-\tfor (ref = remote_refs; ref; ref = ref->next) {\n-\t\tif (ref->expect_old_sha1)\n-\t\t\tcontinue;\n-\t\tif (!refname_match(entry->refname, ref->name))\n+\texpand_ref_prefix(&names, entry->refname);\n+\tfor (size_t i = 0; i < names.nr; i++) {\n+\t\tstruct ref *ref = strmap_get(ref_map, names.v[i]);\n+\t\tif (!ref || ref->expect_old_sha1)\n \t\t\tcontinue;\n \t\tif (entry->use_tracking) {\n \t\t\tapply_cas_tracking(cas, remote, ref);\n@@ -3010,6 +3010,7 @@ static void apply_one_cas(struct ref *remote_refs,\n \t\t\toidcpy(&ref->old_oid_expect, &entry->expect);\n \t\t}\n \t}\n+\tstrvec_clear(&names);\n }\n \n void apply_push_cas(struct push_cas_option *cas,\n@@ -3019,8 +3020,14 @@ void apply_push_cas(struct push_cas_option *cas,\n \tstruct ref *ref;\n \n \t/* Apply each explicit --<option>=<name>[:<value>] entry */\n-\tfor (size_t i = 0; i < cas->nr; i++)\n-\t\tapply_one_cas(remote_refs, remote, cas, &cas->entry[i]);\n+\tif (cas->nr) {\n+\t\tstruct strmap ref_map;\n+\n+\t\tref_map_init(&ref_map, remote_refs);\n+\t\tfor (size_t i = 0; i < cas->nr; i++)\n+\t\t\tapply_one_cas(&ref_map, remote, cas, &cas->entry[i]);\n+\t\tstrmap_clear(&ref_map, 0);\n+\t}\n \n \t/* Are we using \"--<option>\" to cover all? */\n \tif (cas->use_tracking_for_rest) {\n-- \n2.55.0\n\n\n"},{"id":"554656","messageId":"4fcdf05d-b558-4471-b696-bd7c285f49e2@app.fastmail.com","threadId":"66497","inReplyTo":"20261009192953.81794-1-jon@jonsimons.org","subject":"Re: [PATCH 00/15] push: speed up client-side refspec matching","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2026-10-09T20:18:05Z","receivedAt":"2026-10-09T20:18:05Z","isPatch":true,"sender":{"key":"kristofferhaugsbakk@fastmail.com","avatar":null},"body":"On Fri, Oct 9, 2026, at 21:29, Jon Simons wrote:\n> Guide to changes:\n>\n>  - Commits are ordered to introduce failing tests for behavioral\n>    changes and bugfixes, before the code change that fixes them\n\nThis project prefers introducing the \nregression test and fix in the same commit.\n\n-- \nSent from mobile\n\n"},{"id":"554706","messageId":"f949a5fa-5485-4ae4-a7dc-75afaef798cc@jonsimons.org","threadId":"66497","inReplyTo":"4fcdf05d-b558-4471-b696-bd7c285f49e2@app.fastmail.com","subject":"Re: [PATCH 00/15] push: speed up client-side refspec matching","fromName":"Jon Simons","fromEmail":"jon@jonsimons.org","sentAt":"2026-10-11T02:35:33Z","receivedAt":"2026-10-11T02:35:33Z","isPatch":true,"sender":{"key":"jon@jonsimons.org","avatar":"https://avatars.githubusercontent.com/u/118440?v=4"},"body":"On 10/9/26 4:18 PM, Kristoffer Haugsbakk wrote:\n> On Fri, Oct 9, 2026, at 21:29, Jon Simons wrote:\n>> Guide to changes:\n>>\n>>  - Commits are ordered to introduce failing tests for behavioral\n>>    changes and bugfixes, before the code change that fixes them\n> \n> This project prefers introducing the \n> regression test and fix in the same commit.\n> \n\nThanks, I did not know this, and in searching I now find that this is a\ncommon review feedback [1].  And the commit history also shows that it\nis uncommon for a series to be merged that introduces a failing test\nthen fix in a subsequent commit.\n\nI'll look at squashing the commits that introduce new test_expect_failure\ncases up into the commits that toggles them passing, for v2, after first\nletting the v1 soak for comments.\n\n\n-Jon\n\n[1] A couple examples of feedback and rationale to not to do this from\n    August 2025 here:\n\n    - https://lore.kernel.org/git/xmqqqzxpzo8b.fsf@gitster.g/\n    - https://lore.kernel.org/git/xmqqfrdk3aqy.fsf@gitster.g/\n\n\n"}]}