update-ref: add --batch-updates flag for stdin mode

When updating multiple references through stdin, Git's update-ref command normally aborts the entire transaction if any single update fails. This atomic behavior prevents partial updates. Introduce a new batch update system, where the updates the performed together similar but individual updates are allowed to fail. Add a new `--batch-updates` flag that allows the transaction to continue even when individual reference updates fail. This flag can only be used in `--stdin` mode and builds upon the batch update support added to the refs subsystem in the previous commits. When enabled, failed updates are reported in the following format: rejected SP (<old-oid> | <old-target>) SP (<new-oid> | <new-target>) SP <rejection-reason> LF Update the documentation to reflect this change and also tests to cover different scenarios where an update could be rejected. Signed-off-by: Karthik Nayak <karthik.188@gmail.com> Acked-by: Patrick Steinhardt <ps@pks.im> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Karthik Nayak committed Apr 8, 2025 at 10:51 UTC 221e8fcb7f543f056246a901bcb1269a13145fa9
3 files changed +306 -7
Documentation/git-update-ref.adoc
+12 -2
@@ -7,8 +7,10 @@ git-update-ref - Update the object name stored in a ref safely
7
8 SYNOPSIS
9 --------
10 -[verse]
11 -'git update-ref' [-m <reason>] [--no-deref] (-d <ref> [<old-oid>] | [--create-reflog] <ref> <new-oid> [<old-oid>] | --stdin [-z])
10 +[synopsis]
11 +git update-ref [-m <reason>] [--no-deref] -d <ref> [<old-oid>]
12 +git update-ref [-m <reason>] [--no-deref] [--create-reflog] <ref> <new-oid> [<old-oid>]
13 +git update-ref [-m <reason>] [--no-deref] --stdin [-z] [--batch-updates]
14
15 DESCRIPTION
16 -----------
@@ -57,6 +59,14 @@ performs all modifications together. Specify commands of the form:
59 With `--create-reflog`, update-ref will create a reflog for each ref
60 even if one would not ordinarily be created.
61
62 +With `--batch-updates`, update-ref executes the updates in a batch but allows
63 +individual updates to fail due to invalid or incorrect user input, applying only
64 +the successful updates. However, system-related errors—such as I/O failures or
65 +memory issues—will result in a full failure of all batched updates. Any failed
66 +updates will be reported in the following format:
67 +
68 + rejected SP (<old-oid> | <old-target>) SP (<new-oid> | <new-target>) SP <rejection-reason> LF
69 +
70 Quote fields containing whitespace as if they were strings in C source
71 code; i.e., surrounded by double-quotes and with backslash escapes.
72 Use 40 "0" characters or the empty string to specify a zero value. To
builtin/update-ref.c
+61 -5
@@ -5,6 +5,7 @@
5 #include "config.h"
6 #include "gettext.h"
7 #include "hash.h"
8 +#include "hex.h"
9 #include "refs.h"
10 #include "object-name.h"
11 #include "parse-options.h"
@@ -13,7 +14,7 @@
14 static const char * const git_update_ref_usage[] = {
15 N_("git update-ref [<options>] -d <refname> [<old-oid>]"),
16 N_("git update-ref [<options>] <refname> <new-oid> [<old-oid>]"),
16 - N_("git update-ref [<options>] --stdin [-z]"),
17 + N_("git update-ref [<options>] --stdin [-z] [--batch-updates]"),
18 NULL
19 };
20
@@ -565,6 +566,49 @@ static void parse_cmd_abort(struct ref_transaction *transaction,
566 report_ok("abort");
567 }
568
569 +static void print_rejected_refs(const char *refname,
570 + const struct object_id *old_oid,
571 + const struct object_id *new_oid,
572 + const char *old_target,
573 + const char *new_target,
574 + enum ref_transaction_error err,
575 + void *cb_data UNUSED)
576 +{
577 + struct strbuf sb = STRBUF_INIT;
578 + const char *reason = "";
579 +
580 + switch (err) {
581 + case REF_TRANSACTION_ERROR_NAME_CONFLICT:
582 + reason = "refname conflict";
583 + break;
584 + case REF_TRANSACTION_ERROR_CREATE_EXISTS:
585 + reason = "reference already exists";
586 + break;
587 + case REF_TRANSACTION_ERROR_NONEXISTENT_REF:
588 + reason = "reference does not exist";
589 + break;
590 + case REF_TRANSACTION_ERROR_INCORRECT_OLD_VALUE:
591 + reason = "incorrect old value provided";
592 + break;
593 + case REF_TRANSACTION_ERROR_INVALID_NEW_VALUE:
594 + reason = "invalid new value provided";
595 + break;
596 + case REF_TRANSACTION_ERROR_EXPECTED_SYMREF:
597 + reason = "expected symref but found regular ref";
598 + break;
599 + default:
600 + reason = "unkown failure";
601 + }
602 +
603 + strbuf_addf(&sb, "rejected %s %s %s %s\n", refname,
604 + new_oid ? oid_to_hex(new_oid) : new_target,
605 + old_oid ? oid_to_hex(old_oid) : old_target,
606 + reason);
607 +
608 + fwrite(sb.buf, sb.len, 1, stdout);
609 + strbuf_release(&sb);
610 +}
611 +
612 static void parse_cmd_commit(struct ref_transaction *transaction,
613 const char *next, const char *end UNUSED)
614 {
@@ -573,6 +617,10 @@ static void parse_cmd_commit(struct ref_transaction *transaction,
617 die("commit: extra input: %s", next);
618 if (ref_transaction_commit(transaction, &error))
619 die("commit: %s", error.buf);
620 +
621 + ref_transaction_for_each_rejected_update(transaction,
622 + print_rejected_refs, NULL);
623 +
624 report_ok("commit");
625 ref_transaction_free(transaction);
626 }
@@ -609,7 +657,7 @@ static const struct parse_cmd {
657 { "commit", parse_cmd_commit, 0, UPDATE_REFS_CLOSED },
658 };
659
612 -static void update_refs_stdin(void)
660 +static void update_refs_stdin(unsigned int flags)
661 {
662 struct strbuf input = STRBUF_INIT, err = STRBUF_INIT;
663 enum update_refs_state state = UPDATE_REFS_OPEN;
@@ -617,7 +665,7 @@ static void update_refs_stdin(void)
665 int i, j;
666
667 transaction = ref_store_transaction_begin(get_main_ref_store(the_repository),
620 - 0, &err);
668 + flags, &err);
669 if (!transaction)
670 die("%s", err.buf);
671
@@ -685,7 +733,7 @@ static void update_refs_stdin(void)
733 */
734 state = cmd->state;
735 transaction = ref_store_transaction_begin(get_main_ref_store(the_repository),
688 - 0, &err);
736 + flags, &err);
737 if (!transaction)
738 die("%s", err.buf);
739
@@ -701,6 +749,8 @@ static void update_refs_stdin(void)
749 /* Commit by default if no transaction was requested. */
750 if (ref_transaction_commit(transaction, &err))
751 die("%s", err.buf);
752 + ref_transaction_for_each_rejected_update(transaction,
753 + print_rejected_refs, NULL);
754 ref_transaction_free(transaction);
755 break;
756 case UPDATE_REFS_STARTED:
@@ -727,6 +777,8 @@ int cmd_update_ref(int argc,
777 struct object_id oid, oldoid;
778 int delete = 0, no_deref = 0, read_stdin = 0, end_null = 0;
779 int create_reflog = 0;
780 + unsigned int flags = 0;
781 +
782 struct option options[] = {
783 OPT_STRING( 'm', NULL, &msg, N_("reason"), N_("reason of the update")),
784 OPT_BOOL('d', NULL, &delete, N_("delete the reference")),
@@ -735,6 +787,8 @@ int cmd_update_ref(int argc,
787 OPT_BOOL('z', NULL, &end_null, N_("stdin has NUL-terminated arguments")),
788 OPT_BOOL( 0 , "stdin", &read_stdin, N_("read updates from stdin")),
789 OPT_BOOL( 0 , "create-reflog", &create_reflog, N_("create a reflog")),
790 + OPT_BIT('0', "batch-updates", &flags, N_("batch reference updates"),
791 + REF_TRANSACTION_ALLOW_FAILURE),
792 OPT_END(),
793 };
794
@@ -756,8 +810,10 @@ int cmd_update_ref(int argc,
810 usage_with_options(git_update_ref_usage, options);
811 if (end_null)
812 line_termination = '\0';
759 - update_refs_stdin();
813 + update_refs_stdin(flags);
814 return 0;
815 + } else if (flags & REF_TRANSACTION_ALLOW_FAILURE) {
816 + die("--batch-updates can only be used with --stdin");
817 }
818
819 if (end_null)
t/t1400-update-ref.sh
+233
@@ -2066,6 +2066,239 @@ do
2066 grep "$(git rev-parse $a) $(git rev-parse $a)" actual
2067 '
2068
2069 + test_expect_success "stdin $type batch-updates" '
2070 + git init repo &&
2071 + test_when_finished "rm -fr repo" &&
2072 + (
2073 + cd repo &&
2074 + test_commit commit &&
2075 + head=$(git rev-parse HEAD) &&
2076 +
2077 + format_command $type "update refs/heads/ref1" "$head" "$Z" >stdin &&
2078 + format_command $type "update refs/heads/ref2" "$head" "$Z" >>stdin &&
2079 + git update-ref $type --stdin --batch-updates <stdin &&
2080 + echo $head >expect &&
2081 + git rev-parse refs/heads/ref1 >actual &&
2082 + test_cmp expect actual &&
2083 + git rev-parse refs/heads/ref2 >actual &&
2084 + test_cmp expect actual
2085 + )
2086 + '
2087 +
2088 + test_expect_success "stdin $type batch-updates with invalid new_oid" '
2089 + git init repo &&
2090 + test_when_finished "rm -fr repo" &&
2091 + (
2092 + cd repo &&
2093 + test_commit one &&
2094 + old_head=$(git rev-parse HEAD) &&
2095 + test_commit two &&
2096 + head=$(git rev-parse HEAD) &&
2097 + git update-ref refs/heads/ref1 $head &&
2098 + git update-ref refs/heads/ref2 $head &&
2099 +
2100 + format_command $type "update refs/heads/ref1" "$old_head" "$head" >stdin &&
2101 + format_command $type "update refs/heads/ref2" "$(test_oid 001)" "$head" >>stdin &&
2102 + git update-ref $type --stdin --batch-updates <stdin >stdout &&
2103 + echo $old_head >expect &&
2104 + git rev-parse refs/heads/ref1 >actual &&
2105 + test_cmp expect actual &&
2106 + echo $head >expect &&
2107 + git rev-parse refs/heads/ref2 >actual &&
2108 + test_cmp expect actual &&
2109 + test_grep -q "invalid new value provided" stdout
2110 + )
2111 + '
2112 +
2113 + test_expect_success "stdin $type batch-updates with non-commit new_oid" '
2114 + git init repo &&
2115 + test_when_finished "rm -fr repo" &&
2116 + (
2117 + cd repo &&
2118 + test_commit one &&
2119 + old_head=$(git rev-parse HEAD) &&
2120 + test_commit two &&
2121 + head=$(git rev-parse HEAD) &&
2122 + head_tree=$(git rev-parse HEAD^{tree}) &&
2123 + git update-ref refs/heads/ref1 $head &&
2124 + git update-ref refs/heads/ref2 $head &&
2125 +
2126 + format_command $type "update refs/heads/ref1" "$old_head" "$head" >stdin &&
2127 + format_command $type "update refs/heads/ref2" "$head_tree" "$head" >>stdin &&
2128 + git update-ref $type --stdin --batch-updates <stdin >stdout &&
2129 + echo $old_head >expect &&
2130 + git rev-parse refs/heads/ref1 >actual &&
2131 + test_cmp expect actual &&
2132 + echo $head >expect &&
2133 + git rev-parse refs/heads/ref2 >actual &&
2134 + test_cmp expect actual &&
2135 + test_grep -q "invalid new value provided" stdout
2136 + )
2137 + '
2138 +
2139 + test_expect_success "stdin $type batch-updates with non-existent ref" '
2140 + git init repo &&
2141 + test_when_finished "rm -fr repo" &&
2142 + (
2143 + cd repo &&
2144 + test_commit one &&
2145 + old_head=$(git rev-parse HEAD) &&
2146 + test_commit two &&
2147 + head=$(git rev-parse HEAD) &&
2148 + git update-ref refs/heads/ref1 $head &&
2149 +
2150 + format_command $type "update refs/heads/ref1" "$old_head" "$head" >stdin &&
2151 + format_command $type "update refs/heads/ref2" "$old_head" "$head" >>stdin &&
2152 + git update-ref $type --stdin --batch-updates <stdin >stdout &&
2153 + echo $old_head >expect &&
2154 + git rev-parse refs/heads/ref1 >actual &&
2155 + test_cmp expect actual &&
2156 + test_must_fail git rev-parse refs/heads/ref2 &&
2157 + test_grep -q "reference does not exist" stdout
2158 + )
2159 + '
2160 +
2161 + test_expect_success "stdin $type batch-updates with dangling symref" '
2162 + git init repo &&
2163 + test_when_finished "rm -fr repo" &&
2164 + (
2165 + cd repo &&
2166 + test_commit one &&
2167 + old_head=$(git rev-parse HEAD) &&
2168 + test_commit two &&
2169 + head=$(git rev-parse HEAD) &&
2170 + git update-ref refs/heads/ref1 $head &&
2171 + git symbolic-ref refs/heads/ref2 refs/heads/nonexistent &&
2172 +
2173 + format_command $type "update refs/heads/ref1" "$old_head" "$head" >stdin &&
2174 + format_command $type "update refs/heads/ref2" "$old_head" "$head" >>stdin &&
2175 + git update-ref $type --no-deref --stdin --batch-updates <stdin >stdout &&
2176 + echo $old_head >expect &&
2177 + git rev-parse refs/heads/ref1 >actual &&
2178 + test_cmp expect actual &&
2179 + echo $head >expect &&
2180 + test_must_fail git rev-parse refs/heads/ref2 &&
2181 + test_grep -q "reference does not exist" stdout
2182 + )
2183 + '
2184 +
2185 + test_expect_success "stdin $type batch-updates with regular ref as symref" '
2186 + git init repo &&
2187 + test_when_finished "rm -fr repo" &&
2188 + (
2189 + cd repo &&
2190 + test_commit one &&
2191 + old_head=$(git rev-parse HEAD) &&
2192 + test_commit two &&
2193 + head=$(git rev-parse HEAD) &&
2194 + git update-ref refs/heads/ref1 $head &&
2195 + git update-ref refs/heads/ref2 $head &&
2196 +
2197 + format_command $type "update refs/heads/ref1" "$old_head" "$head" >stdin &&
2198 + format_command $type "symref-update refs/heads/ref2" "$old_head" "ref" "refs/heads/nonexistent" >>stdin &&
2199 + git update-ref $type --no-deref --stdin --batch-updates <stdin >stdout &&
2200 + echo $old_head >expect &&
2201 + git rev-parse refs/heads/ref1 >actual &&
2202 + test_cmp expect actual &&
2203 + echo $head >expect &&
2204 + echo $head >expect &&
2205 + git rev-parse refs/heads/ref2 >actual &&
2206 + test_cmp expect actual &&
2207 + test_grep -q "expected symref but found regular ref" stdout
2208 + )
2209 + '
2210 +
2211 + test_expect_success "stdin $type batch-updates with invalid old_oid" '
2212 + git init repo &&
2213 + test_when_finished "rm -fr repo" &&
2214 + (
2215 + cd repo &&
2216 + test_commit one &&
2217 + old_head=$(git rev-parse HEAD) &&
2218 + test_commit two &&
2219 + head=$(git rev-parse HEAD) &&
2220 + git update-ref refs/heads/ref1 $head &&
2221 + git update-ref refs/heads/ref2 $head &&
2222 +
2223 + format_command $type "update refs/heads/ref1" "$old_head" "$head" >stdin &&
2224 + format_command $type "update refs/heads/ref2" "$old_head" "$Z" >>stdin &&
2225 + git update-ref $type --stdin --batch-updates <stdin >stdout &&
2226 + echo $old_head >expect &&
2227 + git rev-parse refs/heads/ref1 >actual &&
2228 + test_cmp expect actual &&
2229 + echo $head >expect &&
2230 + git rev-parse refs/heads/ref2 >actual &&
2231 + test_cmp expect actual &&
2232 + test_grep -q "reference already exists" stdout
2233 + )
2234 + '
2235 +
2236 + test_expect_success "stdin $type batch-updates with incorrect old oid" '
2237 + git init repo &&
2238 + test_when_finished "rm -fr repo" &&
2239 + (
2240 + cd repo &&
2241 + test_commit one &&
2242 + old_head=$(git rev-parse HEAD) &&
2243 + test_commit two &&
2244 + head=$(git rev-parse HEAD) &&
2245 + git update-ref refs/heads/ref1 $head &&
2246 + git update-ref refs/heads/ref2 $head &&
2247 +
2248 + format_command $type "update refs/heads/ref1" "$old_head" "$head" >stdin &&
2249 + format_command $type "update refs/heads/ref2" "$head" "$old_head" >>stdin &&
2250 + git update-ref $type --stdin --batch-updates <stdin >stdout &&
2251 + echo $old_head >expect &&
2252 + git rev-parse refs/heads/ref1 >actual &&
2253 + test_cmp expect actual &&
2254 + echo $head >expect &&
2255 + git rev-parse refs/heads/ref2 >actual &&
2256 + test_cmp expect actual &&
2257 + test_grep -q "incorrect old value provided" stdout
2258 + )
2259 + '
2260 +
2261 + test_expect_success "stdin $type batch-updates refname conflict" '
2262 + git init repo &&
2263 + test_when_finished "rm -fr repo" &&
2264 + (
2265 + cd repo &&
2266 + test_commit one &&
2267 + old_head=$(git rev-parse HEAD) &&
2268 + test_commit two &&
2269 + head=$(git rev-parse HEAD) &&
2270 + git update-ref refs/heads/ref/foo $head &&
2271 +
2272 + format_command $type "update refs/heads/ref/foo" "$old_head" "$head" >stdin &&
2273 + format_command $type "update refs/heads/ref" "$old_head" "" >>stdin &&
2274 + git update-ref $type --stdin --batch-updates <stdin >stdout &&
2275 + echo $old_head >expect &&
2276 + git rev-parse refs/heads/ref/foo >actual &&
2277 + test_cmp expect actual &&
2278 + test_grep -q "refname conflict" stdout
2279 + )
2280 + '
2281 +
2282 + test_expect_success "stdin $type batch-updates refname conflict new ref" '
2283 + git init repo &&
2284 + test_when_finished "rm -fr repo" &&
2285 + (
2286 + cd repo &&
2287 + test_commit one &&
2288 + old_head=$(git rev-parse HEAD) &&
2289 + test_commit two &&
2290 + head=$(git rev-parse HEAD) &&
2291 + git update-ref refs/heads/ref/foo $head &&
2292 +
2293 + format_command $type "update refs/heads/foo" "$old_head" "" >stdin &&
2294 + format_command $type "update refs/heads/ref" "$old_head" "" >>stdin &&
2295 + git update-ref $type --stdin --batch-updates <stdin >stdout &&
2296 + echo $old_head >expect &&
2297 + git rev-parse refs/heads/foo >actual &&
2298 + test_cmp expect actual &&
2299 + test_grep -q "refname conflict" stdout
2300 + )
2301 + '
2302 done
2303
2304 test_expect_success 'update-ref should also create reflog for HEAD' '