From 7454b68b6a08abdc87b8fea6ebc2c54f334860af Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Mon, 6 Jul 2026 17:34:01 +0000 Subject: [PATCH 01/13] t0213: skip ancestry tests under user-mode emulation The tests added in 3c8c638df6 (t0213: add trace2 cmd_ancestry tests, 2026-02-13) expect the cmd_ancestry event to name "test-tool" and "git". On Linux those names come from the "comm" field of /proc//stat. Under user-mode emulation (e.g. qemu-user) /proc reports the emulator ("qemu-riscv64") instead, so the event is still emitted, the TRACE2_ANCESTRY probe enables the tests, and tests 2-5 fail even though they pass on native riscv64. Require the probe to see "test-tool" in the ancestry of a test-tool spawned from test-tool, so the tests skip when the names are unreliable. Cc: Matthew John Cheetham Signed-off-by: Jamie Magee Signed-off-by: Junio C Hamano --- t/t0213-trace2-ancestry.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/t/t0213-trace2-ancestry.sh b/t/t0213-trace2-ancestry.sh index a2b9536da83152..2eb86c195274c2 100755 --- a/t/t0213-trace2-ancestry.sh +++ b/t/t0213-trace2-ancestry.sh @@ -31,12 +31,15 @@ PATH="$TTDIR:$PATH" && export PATH # no cmd_ancestry event is emitted. We detect this at runtime and # skip the format-specific tests accordingly. -# Determine if cmd_ancestry is supported on this platform. +# Enable these tests only when cmd_ancestry reports real process names. +# The procinfo stub emits no event; under user-mode emulation (e.g. +# qemu-user) /proc reports the emulator, not the guest. Spawn test-tool +# from test-tool and require "test-tool" in the child's ancestry. test_expect_success 'detect cmd_ancestry support' ' test_when_finished "rm -f trace.detect" && GIT_TRACE2_BRIEF=1 GIT_TRACE2="$(pwd)/trace.detect" \ - test-tool trace2 001return 0 && - if grep -q "^cmd_ancestry" trace.detect + test-tool trace2 004child test-tool trace2 001return 0 && + if grep -q "^cmd_ancestry.*test-tool" trace.detect then test_set_prereq TRACE2_ANCESTRY fi From c71ed75a8b1d59793e033407cf38b03ddc1d0179 Mon Sep 17 00:00:00 2001 From: Ted Nyman Date: Sun, 26 Jul 2026 17:28:38 -0700 Subject: [PATCH 02/13] http-fetch: correct --index-pack-arg documentation The --packfile mode accepts one --index-pack-arg= option per argument passed to index-pack, but its documentation and option dependency errors still refer to the plural --index-pack-args form. Correct the spelling and describe the repeatable per-argument form. Signed-off-by: Ted Nyman Signed-off-by: Junio C Hamano --- Documentation/git-http-fetch.adoc | 9 +++++---- http-fetch.c | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Documentation/git-http-fetch.adoc b/Documentation/git-http-fetch.adoc index 2200f073c47120..12036e65e93651 100644 --- a/Documentation/git-http-fetch.adoc +++ b/Documentation/git-http-fetch.adoc @@ -50,11 +50,12 @@ commit-id:: URL and uses index-pack to generate corresponding .idx and .keep files. The hash is used to determine the name of the temporary file and is arbitrary. The output of index-pack is printed to stdout. Requires - --index-pack-args. + one or more --index-pack-arg options. ---index-pack-args=:: - For internal use only. The command to run on the contents of the - downloaded pack. Arguments are URL-encoded separated by spaces. +--index-pack-arg=:: + For internal use only. The first instance specifies the command run on + the contents of the downloaded pack. Subsequent instances specify its + arguments. --recover:: Verify that everything reachable from target is fetched. Used after diff --git a/http-fetch.c b/http-fetch.c index f9b6ecb0616fe0..601a77c3c10204 100644 --- a/http-fetch.c +++ b/http-fetch.c @@ -155,7 +155,7 @@ int cmd_main(int argc, const char **argv) if (packfile) { if (!index_pack_args.nr) - die(_("the option '%s' requires '%s'"), "--packfile", "--index-pack-args"); + die(_("the option '%s' requires '%s'"), "--packfile", "--index-pack-arg"); fetch_single_packfile(&packfile_hash, argv[arg], index_pack_args.v); @@ -164,7 +164,7 @@ int cmd_main(int argc, const char **argv) } if (index_pack_args.nr) - die(_("the option '%s' requires '%s'"), "--index-pack-args", "--packfile"); + die(_("the option '%s' requires '%s'"), "--index-pack-arg", "--packfile"); if (commits_on_stdin) { commits = walker_targets_stdin(&commit_id, &write_ref); From f0d866a2eafcd012fb8fda8edd6b32da68859d7b Mon Sep 17 00:00:00 2001 From: Ted Nyman Date: Sun, 26 Jul 2026 17:28:39 -0700 Subject: [PATCH 03/13] http: avoid closing index-pack input twice finish_http_pack_request() passes its staging-file descriptor to index-pack through child_process.in. start_command() takes ownership of a supplied descriptor and closes it, even when starting the child fails. Do not close the descriptor again after run_command() returns. Signed-off-by: Ted Nyman Signed-off-by: Junio C Hamano --- http.c | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/http.c b/http.c index b4e7b8d00b3cdc..930e0d227fda73 100644 --- a/http.c +++ b/http.c @@ -2704,13 +2704,8 @@ int finish_http_pack_request(struct http_pack_request *preq) else ip.no_stdout = 1; - if (run_command(&ip)) { + if (run_command(&ip)) ret = -1; - goto cleanup; - } - -cleanup: - close(tmpfile_fd); unlink(preq->tmpfile.buf); return ret; } From 85f4f04f820c9aa6cfdfb4594ceb9b41515a8245 Mon Sep 17 00:00:00 2001 From: Ted Nyman Date: Sun, 26 Jul 2026 17:28:40 -0700 Subject: [PATCH 04/13] http: accept HTTP 416 for complete partial packs A resumed pack request may already have all bytes of the remote pack. A server can respond to the resulting Range request with HTTP 416 instead of returning an empty response. Accept that response in each pack-download caller and let index-pack validate the completed staging file. This can happen without concurrent downloads when a previous attempt completed the transfer but failed before indexing it. Add a regression test that seeds a complete partial pack and checks that http-fetch indexes it after the server returns HTTP 416. Signed-off-by: Ted Nyman Signed-off-by: Junio C Hamano --- http-fetch.c | 3 ++- http-push.c | 3 ++- http-walker.c | 3 ++- t/t5550-http-fetch-dumb.sh | 19 +++++++++++++++++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/http-fetch.c b/http-fetch.c index 601a77c3c10204..05f68f306a5821 100644 --- a/http-fetch.c +++ b/http-fetch.c @@ -70,7 +70,8 @@ static void fetch_single_packfile(struct object_id *packfile_hash, if (start_active_slot(preq->slot)) { run_active_slot(preq->slot); - if (results.curl_result != CURLE_OK) { + if (results.curl_result != CURLE_OK && + results.http_code != 416) { struct url_info url; char *nurl = url_normalize(preq->url, &url); if (!nurl || !git_env_bool("GIT_TRACE_REDACT", 1)) { diff --git a/http-push.c b/http-push.c index 3c23cbba27a9ec..03dc8102a185fc 100644 --- a/http-push.c +++ b/http-push.c @@ -595,7 +595,8 @@ static void finish_request(struct transfer_request *request) } else if (request->state == RUN_FETCH_PACKED) { int fail = 1; - if (request->curl_result != CURLE_OK) { + if (request->curl_result != CURLE_OK && + request->http_code != 416) { fprintf(stderr, "Unable to get pack file %s\n%s", request->url, curl_errorstr); } else { diff --git a/http-walker.c b/http-walker.c index b58a3b2a92be38..abafca84d65441 100644 --- a/http-walker.c +++ b/http-walker.c @@ -451,7 +451,8 @@ static int http_fetch_pack(struct walker *walker, struct alt_base *repo, if (start_active_slot(preq->slot)) { run_active_slot(preq->slot); - if (results.curl_result != CURLE_OK) { + if (results.curl_result != CURLE_OK && + results.http_code != 416) { error("Unable to get pack file %s\n%s", preq->url, curl_errorstr); goto abort; diff --git a/t/t5550-http-fetch-dumb.sh b/t/t5550-http-fetch-dumb.sh index b0080bf2047899..621f2fbf7327c5 100755 --- a/t/t5550-http-fetch-dumb.sh +++ b/t/t5550-http-fetch-dumb.sh @@ -293,6 +293,25 @@ test_expect_success 'http-fetch --packfile' ' git -C packfileclient cat-file -e "$HASH" ' +test_expect_success 'http-fetch --packfile accepts an already complete partial' ' + git init packfileclient-complete && + p=$(cd "$HTTPD_DOCUMENT_ROOT_PATH"/repo_pack.git && + ls objects/pack/pack-*.pack) && + packhash=$(basename "$p" .pack) && + packhash=${packhash#pack-} && + tmpfile="packfileclient-complete/.git/objects/pack/pack-$packhash.pack.temp" && + cp "$HTTPD_DOCUMENT_ROOT_PATH/repo_pack.git/$p" "$tmpfile" && + chmod u+w "$tmpfile" && + GIT_TRACE_CURL="$TRASH_DIRECTORY/complete.trace" \ + git -C packfileclient-complete http-fetch --packfile="$packhash" \ + --index-pack-arg=index-pack \ + --index-pack-arg=--stdin --index-pack-arg=--keep \ + "$HTTPD_URL/dumb/repo_pack.git/$p" >out && + test_grep "416 Requested Range Not Satisfiable" complete.trace && + test_path_is_missing "$tmpfile" && + git -C packfileclient-complete cat-file -e "$HASH" +' + test_expect_success 'fetch notices corrupt pack' ' cp -R "$HTTPD_DOCUMENT_ROOT_PATH"/repo_pack.git "$HTTPD_DOCUMENT_ROOT_PATH"/repo_bad1.git && (cd "$HTTPD_DOCUMENT_ROOT_PATH"/repo_bad1.git && From 5e855d9b426dd01552ecbfb47062b90fe53b3df5 Mon Sep 17 00:00:00 2001 From: Ted Nyman Date: Sun, 26 Jul 2026 17:28:41 -0700 Subject: [PATCH 05/13] http: avoid concurrent appends to partial packs Pack requests stage downloads in a predictable partial-pack file so an interrupted transfer can be resumed. Both packfile URI and ordinary dumb HTTP requests use this staging path. Opening it in append mode forces each write to the current end of the file, so concurrent responses can append duplicate data and corrupt the pack. Open the partial pack read-write without O_APPEND and seek once to its current end. Each downloader then retains the offset matching the Range it requested. Because the staging key must uniquely identify immutable pack contents, overlapping responses write the same bytes at the same offsets instead of extending the file with duplicate data. Duplicate the staging descriptor for index-pack instead of reopening the path after closing the stream. Another downloader may unlink the staging path before indexing begins, but index-pack can still read the retained descriptor. Exercise resumed transfers and overlapping 200 and 206 responses, and clarify the staging-key documentation. Signed-off-by: Ted Nyman Signed-off-by: Junio C Hamano --- Documentation/git-http-fetch.adoc | 5 +- http.c | 34 ++++--- t/t5550-http-fetch-dumb.sh | 164 ++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 16 deletions(-) diff --git a/Documentation/git-http-fetch.adoc b/Documentation/git-http-fetch.adoc index 12036e65e93651..45e0d3d07c73cf 100644 --- a/Documentation/git-http-fetch.adoc +++ b/Documentation/git-http-fetch.adoc @@ -48,8 +48,9 @@ commit-id:: line (which is not expected in this case), 'git http-fetch' fetches the packfile directly at the given URL and uses index-pack to generate corresponding .idx and .keep files. - The hash is used to determine the name of the temporary file and is - arbitrary. The output of index-pack is printed to stdout. Requires + The hash is used to determine the name of the temporary file. It need + not be the pack hash, but it must uniquely identify the pack contents + for resumption. The output of index-pack is printed to stdout. Requires one or more --index-pack-arg options. --index-pack-arg=:: diff --git a/http.c b/http.c index 930e0d227fda73..4965e892489ebf 100644 --- a/http.c +++ b/http.c @@ -2688,10 +2688,13 @@ int finish_http_pack_request(struct http_pack_request *preq) int tmpfile_fd; int ret = 0; + /* Another downloader may unlink the staging path while we index it. */ + tmpfile_fd = xdup(fileno(preq->packfile)); fclose(preq->packfile); preq->packfile = NULL; - - tmpfile_fd = xopen(preq->tmpfile.buf, O_RDONLY); + if (lseek(tmpfile_fd, 0, SEEK_SET) < 0) + die_errno("unable to seek local file %s for pack", + preq->tmpfile.buf); ip.git_cmd = 1; ip.in = tmpfile_fd; @@ -2733,22 +2736,30 @@ struct http_pack_request *new_http_pack_request( struct http_pack_request *new_direct_http_pack_request( const unsigned char *packed_git_hash, char *url) { - off_t prev_posn = 0; + off_t prev_posn; struct http_pack_request *preq; + int fd; CALLOC_ARRAY(preq, 1); strbuf_init(&preq->tmpfile, 0); - preq->url = url; odb_pack_name(the_repository, &preq->tmpfile, packed_git_hash, "pack"); strbuf_addstr(&preq->tmpfile, ".temp"); - preq->packfile = fopen(preq->tmpfile.buf, "a"); - if (!preq->packfile) { - error("Unable to open local file %s for pack", - preq->tmpfile.buf); + fd = open(preq->tmpfile.buf, O_RDWR | O_CREAT, 0666); + if (fd < 0) { + error_errno("unable to open local file %s for pack", + preq->tmpfile.buf); + goto abort; + } + prev_posn = lseek(fd, 0, SEEK_END); + if (prev_posn < 0) { + error_errno("unable to seek local file %s for pack", + preq->tmpfile.buf); + close(fd); goto abort; } + preq->packfile = xfdopen(fd, "w"); preq->slot = get_active_slot(); preq->headers = object_request_headers(); @@ -2757,12 +2768,7 @@ struct http_pack_request *new_direct_http_pack_request( curl_easy_setopt(preq->slot->curl, CURLOPT_URL, preq->url); curl_easy_setopt(preq->slot->curl, CURLOPT_HTTPHEADER, preq->headers); - /* - * If there is data present from a previous transfer attempt, - * resume where it left off - */ - prev_posn = ftello(preq->packfile); - if (prev_posn>0) { + if (prev_posn > 0) { if (http_is_verbose) fprintf(stderr, "Resuming fetch of pack %s at byte %"PRIuMAX"\n", diff --git a/t/t5550-http-fetch-dumb.sh b/t/t5550-http-fetch-dumb.sh index 621f2fbf7327c5..76cfcb8006d7d3 100755 --- a/t/t5550-http-fetch-dumb.sh +++ b/t/t5550-http-fetch-dumb.sh @@ -312,6 +312,170 @@ test_expect_success 'http-fetch --packfile accepts an already complete partial' git -C packfileclient-complete cat-file -e "$HASH" ' +test_expect_success 'http-fetch --packfile resumes a partial download' ' + git init packfileclient-resume && + p=$(cd "$HTTPD_DOCUMENT_ROOT_PATH"/repo_pack.git && + ls objects/pack/pack-*.pack) && + tmpfile="packfileclient-resume/.git/objects/pack/pack-$ARBITRARY.pack.temp" && + test_copy_bytes 64 <"$HTTPD_DOCUMENT_ROOT_PATH/repo_pack.git/$p" >"$tmpfile" && + GIT_TRACE_CURL="$TRASH_DIRECTORY/resume.trace" \ + git -C packfileclient-resume http-fetch --packfile="$ARBITRARY" \ + --index-pack-arg=index-pack --index-pack-arg=--stdin \ + --index-pack-arg=--keep \ + "$HTTPD_URL/dumb/repo_pack.git/$p" >out && + test_grep "Range: bytes=64-" resume.trace && + test_path_is_missing "$tmpfile" && + git -C packfileclient-resume cat-file -e "$HASH" +' + +test_expect_success PERL,PIPE 'concurrent http-fetch --packfile cannot corrupt an overlapping download' ' + git init packfileclient-overlap && + blob=$(test-tool genrandom pack-overlap 2m | + git -C "$HTTPD_DOCUMENT_ROOT_PATH"/repo_pack.git \ + hash-object -w --stdin) && + packhash=$(printf "%s\n" "$blob" | + git -C "$HTTPD_DOCUMENT_ROOT_PATH"/repo_pack.git \ + pack-objects "$TRASH_DIRECTORY/overlap-pack") && + pack="$TRASH_DIRECTORY/overlap-pack-$packhash.pack" && + tmpfile="packfileclient-overlap/.git/objects/pack/pack-$packhash.pack.temp" && + mkfifo server-ready first-ready && + exec 7<>server-ready && + exec 8<>first-ready && + write_script slow-pack-server "$PERL_PATH" <<-\EOF && + use strict; + use warnings; + use IO::Socket::INET; + + my ($packfile, $server_ready, $first_ready) = @ARGV; + my $completed = 0; + END { + if (!$completed) { + signal_ready($server_ready, "failed"); + signal_ready($first_ready, "failed"); + } + } + + $SIG{ALRM} = sub { die "timed out serving concurrent pack requests\n" }; + alarm 60; + + open(my $in, "<:raw", $packfile) or die "open $packfile: $!"; + my $pack = do { local $/; <$in> }; + close($in) or die "close $packfile: $!"; + my $server = IO::Socket::INET->new(LocalAddr => "127.0.0.1", + LocalPort => 0, Proto => "tcp", Listen => 2, ReuseAddr => 1) + or die "listen: $!"; + + sub signal_ready { + my ($file, $value) = @_; + open(my $out, ">", $file) or die "open $file: $!"; + print $out "$value\n" or die "write $file: $!"; + close($out) or die "close $file: $!"; + } + + sub write_all { + my ($out, $data) = @_; + my $offset = 0; + while ($offset < length($data)) { + my $written = syswrite($out, $data, + length($data) - $offset, $offset); + defined($written) && $written or die "write response: $!"; + $offset += $written; + } + } + + sub start_response { + my $out = $server->accept() or die "accept: $!"; + <$out> or die "read request: $!"; + my $start = 0; + while (<$out>) { + last if /^\r?\n$/; + $start = $1 if /^Range: bytes=(\d+)-/i; + } + $start < length($pack) or die "invalid range $start"; + my $length = length($pack) - $start; + my $middle = int($length / 2); + my $status = $start ? "206 Partial Content" : "200 OK"; + my $headers = "HTTP/1.1 $status\r\n" . + "Content-Length: $length\r\n" . + ($start ? "Content-Range: bytes $start-" . + (length($pack) - 1) . "/" . length($pack) . "\r\n" : "") . + "Connection: close\r\n\r\n"; + write_all($out, $headers); + write_all($out, substr($pack, $start, $middle)); + return ($out, $start + $middle); + } + + signal_ready($server_ready, $server->sockport()); + my ($first, $first_pos) = start_response(); + signal_ready($first_ready, "ready"); + my ($second, $second_pos) = start_response(); + write_all($first, substr($pack, $first_pos)); + write_all($second, substr($pack, $second_pos)); + close($first) or die "close first response: $!"; + close($second) or die "close second response: $!"; + $completed = 1; + alarm 0; + EOF + { + "$TRASH_DIRECTORY/slow-pack-server" "$pack" \ + "$TRASH_DIRECTORY/server-ready" \ + "$TRASH_DIRECTORY/first-ready" >server.log 2>&1 & + server_pid=$! + } && + test_when_finished " + kill $server_pid 2>/dev/null || : + wait $server_pid 2>/dev/null || : + exec 7>&- + exec 8>&- + rm -f server-ready first-ready slow-pack-server + " && + read port <&7 && + url="http://127.0.0.1:$port/pack" && + { + ( + if ! GIT_TRACE_CURL="$TRASH_DIRECTORY/overlap-first.trace" \ + GIT_TRACE_CURL_NO_DATA=1 \ + git -C packfileclient-overlap http-fetch --packfile="$packhash" \ + --index-pack-arg=index-pack \ + --index-pack-arg=--stdin --index-pack-arg=--keep \ + "$url" >first.out + then + echo failed >"$TRASH_DIRECTORY/first-ready" && + exit 1 + fi + ) & + first_pid=$! + } && + test_when_finished " + kill $first_pid 2>/dev/null || : + wait $first_pid 2>/dev/null || : + " && + read ready <&8 && + test "$ready" = ready && + test_path_is_file "$tmpfile" && + { + GIT_TRACE_CURL="$TRASH_DIRECTORY/overlap-second.trace" \ + GIT_TRACE_CURL_NO_DATA=1 \ + git -C packfileclient-overlap http-fetch --packfile="$packhash" \ + --index-pack-arg=index-pack \ + --index-pack-arg=--stdin --index-pack-arg=--keep \ + "$url" >second.out & + second_pid=$! + } && + test_when_finished " + kill $second_pid 2>/dev/null || : + wait $second_pid 2>/dev/null || : + " && + wait "$second_pid" && + wait "$first_pid" && + wait "$server_pid" && + printf "keep\t%s\npack\t%s\n" "$packhash" "$packhash" | sort >expect && + sort first.out second.out >actual && + test_cmp expect actual && + test_path_is_missing "$tmpfile" && + git -C packfileclient-overlap cat-file -e "$blob" +' + test_expect_success 'fetch notices corrupt pack' ' cp -R "$HTTPD_DOCUMENT_ROOT_PATH"/repo_pack.git "$HTTPD_DOCUMENT_ROOT_PATH"/repo_bad1.git && (cd "$HTTPD_DOCUMENT_ROOT_PATH"/repo_bad1.git && From e92a518a741ead85634c43f871e7e095bcf66f1f Mon Sep 17 00:00:00 2001 From: Ted Nyman Date: Sun, 26 Jul 2026 17:28:42 -0700 Subject: [PATCH 06/13] http: permit unlinking partial packs on Windows On Windows, an open file must permit FILE_SHARE_DELETE before another process can unlink it. MinGW's non-append O_RDWR open enables that sharing mode only for an existing file; adding O_CREAT falls back to _wopen(), which cannot set it. First try opening the partial pack without O_CREAT. If it does not exist, create it exclusively, close that descriptor, and retry through the existing-file path. A racing creator retries after EEXIST. This ensures that every retained descriptor permits another downloader to unlink the staging path. Add an unlink-while-indexing test that does not require FIFOs and can therefore run on MinGW. Signed-off-by: Ted Nyman Signed-off-by: Junio C Hamano --- http.c | 17 ++++++++++++++++- t/t5550-http-fetch-dumb.sh | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/http.c b/http.c index 4965e892489ebf..a53099536397d4 100644 --- a/http.c +++ b/http.c @@ -2746,7 +2746,22 @@ struct http_pack_request *new_direct_http_pack_request( odb_pack_name(the_repository, &preq->tmpfile, packed_git_hash, "pack"); strbuf_addstr(&preq->tmpfile, ".temp"); - fd = open(preq->tmpfile.buf, O_RDWR | O_CREAT, 0666); + /* + * MinGW's non-append O_RDWR open grants FILE_SHARE_DELETE only for an + * existing file; reopen a newly created file so others may unlink it. + */ + for (;;) { + fd = open(preq->tmpfile.buf, O_RDWR); + if (fd >= 0 || errno != ENOENT) + break; + fd = open(preq->tmpfile.buf, O_RDWR | O_CREAT | O_EXCL, 0666); + if (fd >= 0) { + close(fd); + continue; + } + if (errno != EEXIST) + break; + } if (fd < 0) { error_errno("unable to open local file %s for pack", preq->tmpfile.buf); diff --git a/t/t5550-http-fetch-dumb.sh b/t/t5550-http-fetch-dumb.sh index 76cfcb8006d7d3..1660baa0d1a69c 100755 --- a/t/t5550-http-fetch-dumb.sh +++ b/t/t5550-http-fetch-dumb.sh @@ -328,6 +328,27 @@ test_expect_success 'http-fetch --packfile resumes a partial download' ' git -C packfileclient-resume cat-file -e "$HASH" ' +test_expect_success 'http-fetch --packfile permits unlink while indexing' ' + git init packfileclient-unlink && + p=$(cd "$HTTPD_DOCUMENT_ROOT_PATH"/repo_pack.git && + ls objects/pack/pack-*.pack) && + tmpfile="packfileclient-unlink/.git/objects/pack/pack-$ARBITRARY.pack.temp" && + write_script git-unlink-index-pack <<-\EOF && + test -f "$GIT_TEST_PACK_TEMP" || exit 1 + rm "$GIT_TEST_PACK_TEMP" || exit 1 + exec git index-pack "$@" + EOF + test_when_finished "rm -f git-unlink-index-pack" && + PATH="$TRASH_DIRECTORY:$PATH" \ + GIT_TEST_PACK_TEMP="$TRASH_DIRECTORY/$tmpfile" \ + git -C packfileclient-unlink http-fetch --packfile="$ARBITRARY" \ + --index-pack-arg=unlink-index-pack \ + --index-pack-arg=--stdin --index-pack-arg=--keep \ + "$HTTPD_URL/dumb/repo_pack.git/$p" >out && + test_path_is_missing "$tmpfile" && + git -C packfileclient-unlink cat-file -e "$HASH" +' + test_expect_success PERL,PIPE 'concurrent http-fetch --packfile cannot corrupt an overlapping download' ' git init packfileclient-overlap && blob=$(test-tool genrandom pack-overlap 2m | From c4244bdfe6d28ed552e10f1e37387978549b36ed Mon Sep 17 00:00:00 2001 From: Ted Nyman Date: Sun, 26 Jul 2026 17:28:43 -0700 Subject: [PATCH 07/13] fetch-pack: accept "pack" output for packfile URIs When index-pack finds an existing keep file it reports pack rather than keep. Accept either result from http-fetch, and only register a keep lockfile when this fetch created it. Read the pack/keep prefix and hash without consuming any following fsck output, validate the reported pack hash against the advertised hash, and exercise a packfile URI fetch with a pre-existing keep file. Signed-off-by: Ted Nyman Signed-off-by: Junio C Hamano --- fetch-pack.c | 33 ++++++++++++++++++--------------- t/t5702-protocol-v2.sh | 31 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/fetch-pack.c b/fetch-pack.c index 29c41132ee0495..e9f24fbd6301b1 100644 --- a/fetch-pack.c +++ b/fetch-pack.c @@ -1887,9 +1887,10 @@ static struct ref *do_fetch_pack_v2(struct fetch_pack_args *args, } for (i = 0; i < packfile_uris.nr; i++) { + bool created_keep; int j; struct child_process cmd = CHILD_PROCESS_INIT; - char packname[GIT_MAX_HEXSZ + 1]; + char packhash[GIT_MAX_HEXSZ + 1]; const char *uri = packfile_uris.items[i].string + the_hash_algo->hexsz + 1; @@ -1907,16 +1908,17 @@ static struct ref *do_fetch_pack_v2(struct fetch_pack_args *args, if (start_command(&cmd)) die("fetch-pack: unable to spawn http-fetch"); - if (read_in_full(cmd.out, packname, 5) < 0 || - memcmp(packname, "keep\t", 5)) - die("fetch-pack: expected keep then TAB at start of http-fetch output"); + if (read_in_full(cmd.out, packhash, 5) != 5 || + (memcmp(packhash, "keep\t", 5) && + memcmp(packhash, "pack\t", 5))) + die("fetch-pack: expected pack or keep then TAB at start of http-fetch output"); + created_keep = !memcmp(packhash, "keep\t", 5); - if (read_in_full(cmd.out, packname, - the_hash_algo->hexsz + 1) < 0 || - packname[the_hash_algo->hexsz] != '\n') - die("fetch-pack: expected hash then LF at end of http-fetch output"); - - packname[the_hash_algo->hexsz] = '\0'; + if (read_in_full(cmd.out, packhash, + the_hash_algo->hexsz + 1) != the_hash_algo->hexsz + 1 || + packhash[the_hash_algo->hexsz] != '\n') + die("fetch-pack: expected hash then LF in http-fetch output"); + packhash[the_hash_algo->hexsz] = '\0'; parse_gitmodules_oids(cmd.out, &fsck_options.gitmodules_found); @@ -1925,16 +1927,17 @@ static struct ref *do_fetch_pack_v2(struct fetch_pack_args *args, if (finish_command(&cmd)) die("fetch-pack: unable to finish http-fetch"); - if (memcmp(packfile_uris.items[i].string, packname, + if (memcmp(packfile_uris.items[i].string, packhash, the_hash_algo->hexsz)) die("fetch-pack: pack downloaded from %s does not match expected hash %.*s", uri, (int) the_hash_algo->hexsz, packfile_uris.items[i].string); - string_list_append_nodup(pack_lockfiles, - xstrfmt("%s/pack/pack-%s.keep", - repo_get_object_directory(the_repository), - packname)); + if (created_keep) + string_list_append_nodup(pack_lockfiles, + xstrfmt("%s/pack/pack-%s.keep", + repo_get_object_directory(the_repository), + packhash)); } string_list_clear(&packfile_uris, 0); strvec_clear(&index_pack_args); diff --git a/t/t5702-protocol-v2.sh b/t/t5702-protocol-v2.sh index 9f6cf4142d5b83..1861eb7d7ccdd1 100755 --- a/t/t5702-protocol-v2.sh +++ b/t/t5702-protocol-v2.sh @@ -1291,6 +1291,37 @@ test_expect_success 'packfile URIs with fetch instead of clone' ' fetch "$HTTPD_URL/smart/http_parent" ' +test_expect_success 'packfile URI preserves an existing keep file' ' + P="$HTTPD_DOCUMENT_ROOT_PATH/http_parent" && + rm -rf "$P" http_child keep.expect && + + git init "$P" && + git -C "$P" config uploadpack.allowsidebandall true && + + echo my-blob >"$P/my-blob" && + git -C "$P" add my-blob && + git -C "$P" commit -m x && + configure_exclusion "$P" my-blob >h && + + git init http_child && + packhash=$(cat packh) && + keep="http_child/.git/objects/pack/pack-$packhash.keep" && + echo pre-existing >"$keep" && + cp "$keep" keep.expect && + + GIT_TEST_SIDEBAND_ALL=1 \ + git -C http_child -c protocol.version=2 \ + -c fetch.uriprotocols=http,https \ + fetch "$HTTPD_URL/smart/http_parent" && + + test_path_is_file \ + "http_child/.git/objects/pack/pack-$packhash.pack" && + test_path_is_file \ + "http_child/.git/objects/pack/pack-$packhash.idx" && + test_cmp keep.expect "$keep" && + git -C http_child cat-file -e "$(cat h)" +' + test_expect_success 'fetching with valid packfile URI but invalid hash fails' ' P="$HTTPD_DOCUMENT_ROOT_PATH/http_parent" && rm -rf "$P" http_child log && From 341e32111cc0bbf2542fbd8e604b5ed5187c3f98 Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Fri, 31 Jul 2026 05:56:02 -0700 Subject: [PATCH 08/13] read-cache: reindent I do not know how this happened without anybody noticing, but a few months ago we added a16c4a245a (read-cache: submodule add need --force given ignore=all configuration, 2026-02-06), and almost all lines the patch added were incorrectly indented. Reindent these lines so that they play better with surrounding lines in the same file. Signed-off-by: Junio C Hamano --- read-cache.c | 70 +++++++++++++++++++++++++++------------------------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/read-cache.c b/read-cache.c index 38b55323dd739a..58c378414a6618 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3910,32 +3910,33 @@ static int fix_unmerged_status(struct diff_filepair *p, } static int skip_submodule(const char *path, - struct repository *repo, - struct pathspec *pathspec, - int ignored_too) -{ - struct stat st; - const struct submodule *sub; - int pathspec_matches = 0; - int ps_i; - char *norm_pathspec = NULL; - - /* Only consider if path is a directory */ - if (lstat(path, &st) || !S_ISDIR(st.st_mode)) + struct repository *repo, + struct pathspec *pathspec, + int ignored_too) +{ + struct stat st; + const struct submodule *sub; + int pathspec_matches = 0; + int ps_i; + char *norm_pathspec = NULL; + + /* Only consider if path is a directory */ + if (lstat(path, &st) || !S_ISDIR(st.st_mode)) return 0; - /* Check if it's a submodule with ignore=all */ - sub = submodule_from_path(repo, null_oid(the_hash_algo), path); - if (!sub || !sub->name || !sub->ignore || strcmp(sub->ignore, "all")) + /* Check if it's a submodule with ignore=all */ + sub = submodule_from_path(repo, null_oid(the_hash_algo), path); + if (!sub || !sub->name || !sub->ignore || strcmp(sub->ignore, "all")) return 0; - trace_printf("ignore=all: %s\n", path); - trace_printf("pathspec %s\n", (pathspec && pathspec->nr) - ? "has pathspec" - : "no pathspec"); + trace_printf("ignore=all: %s\n", path); + trace_printf("pathspec %s\n", + ((pathspec && pathspec->nr) + ? "has pathspec" + : "no pathspec")); - /* Check if submodule path is explicitly mentioned in pathspec */ - if (pathspec) { + /* Check if submodule path is explicitly mentioned in pathspec */ + if (pathspec) { for (ps_i = 0; ps_i < pathspec->nr; ps_i++) { const char *m = pathspec->items[ps_i].match; if (!m) @@ -3949,28 +3950,29 @@ static int skip_submodule(const char *path, } FREE_AND_NULL(norm_pathspec); } - } + } - /* If explicitly matched and forced, allow adding */ - if (pathspec_matches) { + /* If explicitly matched and forced, allow adding */ + if (pathspec_matches) { if (ignored_too && ignored_too > 0) { trace_printf("Add submodule due to --force: %s\n", path); return 0; } else { advise_if_enabled(ADVICE_ADD_IGNORED_FILE, - _("Skipping submodule due to ignore=all: %s\n" - "Use --force if you really want to add the submodule."), path); + _("Skipping submodule due to ignore=all: %s\n" + "Use --force if you really want to " + "add the submodule."), path); return 1; } - } + } - /* No explicit pathspec match -> skip silently */ - trace_printf("Pathspec to submodule does not match explicitly: %s\n", path); - return 1; + /* No explicit pathspec match -> skip silently */ + trace_printf("Pathspec to submodule does not match explicitly: %s\n", path); + return 1; } static void update_callback(struct diff_queue_struct *q, - struct diff_options *opt UNUSED, void *cbdata) + struct diff_options *opt UNUSED, void *cbdata) { int i; struct update_callback_data *data = cbdata; @@ -3980,7 +3982,7 @@ static void update_callback(struct diff_queue_struct *q, const char *path = p->one->path; if (!data->include_sparse && - !path_in_sparse_checkout(path, data->index)) + !path_in_sparse_checkout(path, data->index)) continue; switch (fix_unmerged_status(p, data)) { @@ -3989,8 +3991,8 @@ static void update_callback(struct diff_queue_struct *q, case DIFF_STATUS_MODIFIED: case DIFF_STATUS_TYPE_CHANGED: if (skip_submodule(path, data->repo, - data->pathspec, - data->ignored_too)) + data->pathspec, + data->ignored_too)) continue; if (add_file_to_index(data->index, path, data->flags)) { From e28c701fe41825013220e1b9fefe8da038070aa2 Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Fri, 31 Jul 2026 05:56:03 -0700 Subject: [PATCH 09/13] merge-ll: consolidate conflict marker scanning logic The diff.c:is_conflict_marker() and rerere.c:is_cmarker() functions implement duplicate logic for identifying conflict marker lines (lines that begin with a run of '<', '=', '>', and '|' characters). diff.c's original version from 049540435f (diff --check: detect leftover conflict markers, 2008-06-26) accepts any whitespace (such as a newline) immediately following '<<<<<<<' and '>>>>>>>', whereas rerere.c's version from 191f241717 (rerere: prepare for customizable conflict marker length, 2010-01-16) strictly requires a space character (' ') after them. Implement is_conflict_marker_line() in merge-ll.c to serve as a replacement for both, and update diff.c and rerere.c to use the new helper. The unified helper intentionally adopts rerere's stricter rule, as the conflicts generated by Git always show the "ours" and "theirs" labels after these markers separated by a space. Signed-off-by: Junio C Hamano --- diff.c | 25 +------------------------ merge-ll.c | 31 +++++++++++++++++++++++++++++++ merge-ll.h | 1 + rerere.c | 38 ++++++-------------------------------- 4 files changed, 39 insertions(+), 56 deletions(-) diff --git a/diff.c b/diff.c index 589c1969e45a4e..cfe515af4e1759 100644 --- a/diff.c +++ b/diff.c @@ -3519,29 +3519,6 @@ struct checkdiff_t { int last_line_kind; }; -static int is_conflict_marker(const char *line, int marker_size, unsigned long len) -{ - char firstchar; - int cnt; - - if (len < marker_size + 1) - return 0; - firstchar = line[0]; - switch (firstchar) { - case '=': case '>': case '<': case '|': - break; - default: - return 0; - } - for (cnt = 1; cnt < marker_size; cnt++) - if (line[cnt] != firstchar) - return 0; - /* line[1] through line[marker_size-1] are same as firstchar */ - if (len < marker_size + 1 || !isspace(line[marker_size])) - return 0; - return 1; -} - static void checkdiff_consume_hunk(void *priv, long ob UNUSED, long on UNUSED, long nb, long nn UNUSED, @@ -3571,7 +3548,7 @@ static int checkdiff_consume(void *priv, char *line, unsigned long len) if (line[0] == '+') { unsigned bad; data->lineno++; - if (is_conflict_marker(line + 1, marker_size, len - 1)) { + if (is_conflict_marker_line(line + 1, len - 1, marker_size)) { data->status |= 1; fprintf(data->o->file, "%s%s:%d: leftover conflict marker\n", diff --git a/merge-ll.c b/merge-ll.c index fafe2c91971856..41c97fb90a0630 100644 --- a/merge-ll.c +++ b/merge-ll.c @@ -468,3 +468,34 @@ int ll_merge_marker_size(struct index_state *istate, const char *path) } return marker_size; } + +int is_conflict_marker_line(const char *line, unsigned long len, int marker_size) +{ + char firstchar; + int cnt; + + if (len < marker_size + 1) + return 0; + + firstchar = line[0]; + switch (firstchar) { + case '=': case '>': case '<': case '|': + break; + default: + return 0; + } + + for (cnt = 1; cnt < marker_size; cnt++) { + if (line[cnt] != firstchar) + return 0; + } + + if (((firstchar == '<') || (firstchar == '>')) && + line[marker_size] != ' ') + return 0; + + if (!isspace((unsigned char)line[marker_size])) + return 0; + + return firstchar; +} diff --git a/merge-ll.h b/merge-ll.h index d038ee0c1e81f7..b348aee15d7824 100644 --- a/merge-ll.h +++ b/merge-ll.h @@ -109,6 +109,7 @@ enum ll_merge_result ll_merge(mmbuffer_t *result_buf, const struct ll_merge_options *opts); int ll_merge_marker_size(struct index_state *istate, const char *path); +int is_conflict_marker_line(const char *line, unsigned long len, int marker_size); void reset_merge_attributes(void); #endif diff --git a/rerere.c b/rerere.c index 216100925a1843..924a1f2e30bec7 100644 --- a/rerere.c +++ b/rerere.c @@ -331,33 +331,6 @@ static int rerere_file_getline(struct strbuf *sb, struct rerere_io *io_) return strbuf_getwholeline(sb, io->input, '\n'); } -/* - * Require the exact number of conflict marker letters, no more, no - * less, followed by SP or any whitespace - * (including LF). - */ -static int is_cmarker(char *buf, int marker_char, int marker_size) -{ - int want_sp; - - /* - * The beginning of our version and the end of their version - * always are labeled like "<<<<< ours" or ">>>>> theirs", - * hence we set want_sp for them. Note that the version from - * the common ancestor in diff3-style output is not always - * labelled (e.g. "||||| common" is often seen but "|||||" - * alone is also valid), so we do not set want_sp. - */ - want_sp = (marker_char == '<') || (marker_char == '>'); - - while (marker_size--) - if (*buf++ != marker_char) - return 0; - if (want_sp && *buf != ' ') - return 0; - return isspace(*buf); -} - static void rerere_strbuf_putconflict(struct strbuf *buf, int ch, size_t size) { strbuf_addchars(buf, ch, size); @@ -375,7 +348,8 @@ static int handle_conflict(struct strbuf *out, struct rerere_io *io, int has_conflicts = -1; while (!io->getline(&buf, io)) { - if (is_cmarker(buf.buf, '<', marker_size)) { + int marker = is_conflict_marker_line(buf.buf, buf.len, marker_size); + if (marker == '<') { if (handle_conflict(&conflict, io, marker_size, NULL) < 0) break; if (hunk == RR_SIDE_1) @@ -383,15 +357,15 @@ static int handle_conflict(struct strbuf *out, struct rerere_io *io, else strbuf_addbuf(&two, &conflict); strbuf_release(&conflict); - } else if (is_cmarker(buf.buf, '|', marker_size)) { + } else if (marker == '|') { if (hunk != RR_SIDE_1) break; hunk = RR_ORIGINAL; - } else if (is_cmarker(buf.buf, '=', marker_size)) { + } else if (marker == '=') { if (hunk != RR_SIDE_1 && hunk != RR_ORIGINAL) break; hunk = RR_SIDE_2; - } else if (is_cmarker(buf.buf, '>', marker_size)) { + } else if (marker == '>') { if (hunk != RR_SIDE_2) break; if (strbuf_cmp(&one, &two) > 0) @@ -442,7 +416,7 @@ static int handle_path(unsigned char *hash, struct rerere_io *io, int marker_siz git_hash_init(&ctx, the_hash_algo); while (!io->getline(&buf, io)) { - if (is_cmarker(buf.buf, '<', marker_size)) { + if (is_conflict_marker_line(buf.buf, buf.len, marker_size) == '<') { has_conflicts = handle_conflict(&out, io, marker_size, hash ? &ctx : NULL); if (has_conflicts < 0) From 341ce9229e9b5619b686ed9d169b54e33b29f594 Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Fri, 31 Jul 2026 05:56:04 -0700 Subject: [PATCH 10/13] read-cache: add remove_file_from_index_with_flags() add_file_to_index() takes flags such as ADD_CACHE_PRETEND and ADD_CACHE_VERBOSE and internally handles both reporting (e.g., "add 'path'") and suppressing index updates during dry runs. In contrast, remove_file_from_index() takes only istate and path without flags. Callers that perform file removals (such as update_callback() in read-cache.c) are forced to manually inspect ADD_CACHE_PRETEND and ADD_CACHE_VERBOSE flags for removed files. Introduce remove_file_from_index_with_flags() to encapsulate pretend mode and verbose reporting for index removals. Update update_callback() to use the new helper. Signed-off-by: Junio C Hamano --- read-cache-ll.h | 3 +++ read-cache.c | 19 +++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/read-cache-ll.h b/read-cache-ll.h index 71b87615ebc6d3..8eb266cfd13308 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -391,11 +391,14 @@ int remove_index_entry_at(struct index_state *, int pos); void remove_marked_cache_entries(struct index_state *istate, int invalidate); int remove_file_from_index(struct index_state *, const char *path); +int remove_file_from_index_with_flags(struct index_state *, const char *, int); + #define ADD_CACHE_VERBOSE 1 #define ADD_CACHE_PRETEND 2 #define ADD_CACHE_IGNORE_ERRORS 4 #define ADD_CACHE_IGNORE_REMOVAL 8 #define ADD_CACHE_INTENT 16 + /* * These two are used to add the contents of the file at path * to the index, marking the working tree up-to-date by storing diff --git a/read-cache.c b/read-cache.c index 58c378414a6618..ad77c0d5e25928 100644 --- a/read-cache.c +++ b/read-cache.c @@ -638,6 +638,20 @@ int remove_file_from_index(struct index_state *istate, const char *path) return 0; } +int remove_file_from_index_with_flags(struct index_state *istate, + const char *path, + int flags) +{ + int verbose = flags & (ADD_CACHE_VERBOSE | ADD_CACHE_PRETEND); + int pretend = flags & ADD_CACHE_PRETEND; + + if (verbose) + printf(_("remove '%s'\n"), path); + if (pretend) + return 0; + return remove_file_from_index(istate, path); +} + static int compare_name(struct cache_entry *ce, const char *path, int namelen) { return namelen != ce_namelen(ce) || memcmp(path, ce->name, namelen); @@ -4004,10 +4018,7 @@ static void update_callback(struct diff_queue_struct *q, case DIFF_STATUS_DELETED: if (data->flags & ADD_CACHE_IGNORE_REMOVAL) break; - if (!(data->flags & ADD_CACHE_PRETEND)) - remove_file_from_index(data->index, path); - if (data->flags & (ADD_CACHE_PRETEND|ADD_CACHE_VERBOSE)) - printf(_("remove '%s'\n"), path); + remove_file_from_index_with_flags(data->index, path, data->flags); break; } } From 94cf62176ec5e416b7748d60b59bf8f6aa6dd7b3 Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Fri, 31 Jul 2026 05:56:05 -0700 Subject: [PATCH 11/13] add: introduce '--resolved' option During a conflicted merge, rebase, or cherry-pick, 'git add -u' is a handy way to add modified paths to the index. However, '-u' indiscriminately adds all modified tracked paths, including unmerged paths that may still contain unresolved conflict markers. It also adds tracked files modified in the worktree that are not involved in the ongoing merge. The latter is not a huge problem for "git rebase", which refuses to start with any local changes, but is a problem for "git merge", which is often run with local changes in maintainer workflows. Introduce 'git add --resolved' to add only unmerged paths, limited by an optional pathspec, where no conflict markers remain in the working tree. Before modifying the index, scan unmerged regular files for leftover conflict markers using a new helper, has_conflict_markers(), defined in merge-ll.c in terms of the is_conflict_marker_line() helper we introduced earlier. If any unmerged path still contains conflict markers, show an error listing the conflicted paths and abort without updating the index. Otherwise, add these unmerged paths that do not have conflict markers to the index. Note that unmerged paths without conflict markers (such as binary files and deletions) are added as resolved using add_file_to_index() and remove_file_from_index_with_flags(). Tracked files that were not in a conflicted state are ignored by '--resolved'. Signed-off-by: Junio C Hamano --- Documentation/git-add.adoc | 10 +++- builtin/add.c | 92 ++++++++++++++++++++++++++++--- merge-ll.c | 25 +++++++++ merge-ll.h | 1 + t/meson.build | 1 + t/t2207-add-resolved.sh | 108 +++++++++++++++++++++++++++++++++++++ 6 files changed, 230 insertions(+), 7 deletions(-) create mode 100755 t/t2207-add-resolved.sh diff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc index 941135dc637d90..16b06e38e185d1 100644 --- a/Documentation/git-add.adoc +++ b/Documentation/git-add.adoc @@ -11,7 +11,7 @@ SYNOPSIS git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p] [--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse] [--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] - [--chmod=(+|-)x] [--pathspec-from-file= [--pathspec-file-nul]] + [--resolved] [--chmod=(+|-)x] [--pathspec-from-file= [--pathspec-file-nul]] [--] [...] DESCRIPTION @@ -195,6 +195,14 @@ for `git add --no-all ...`, i.e. ignored removed files. while a _CRLF_ cleans to _LF_, a _CRCRLF_ sequence is only partially cleaned to _CRLF_. +`--resolved`:: + Update the index for unmerged paths matching __ where + no conflict markers remain in the working tree. Unmerged paths + without conflict markers (including binary files and file + deletions) are staged as resolved, while any path with leftover + conflict markers causes the command to refuse to stage any files. + Cannot be combined with `-u` or `-A`. + `--chmod=(+|-)x`:: Override the executable bit of the added files. The executable bit is only changed in the index, the files on disk are left diff --git a/builtin/add.c b/builtin/add.c index 60ffbede2be58a..eab8f03cad31d6 100644 --- a/builtin/add.c +++ b/builtin/add.c @@ -26,6 +26,7 @@ #include "strvec.h" #include "submodule.h" #include "add-interactive.h" +#include "merge-ll.h" static const char * const builtin_add_usage[] = { N_("git add [] [--] ..."), @@ -35,6 +36,7 @@ static int patch_interactive, add_interactive, edit_interactive; static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT; static int take_worktree_changes; static int add_renormalize; +static int add_resolved; static int pathspec_file_nul; static int include_sparse; static const char *pathspec_from_file; @@ -265,6 +267,7 @@ static struct option builtin_add_options[] = { OPT__FORCE(&ignored_too, N_("allow adding otherwise ignored files"), 0), OPT_BOOL('u', "update", &take_worktree_changes, N_("update tracked files")), OPT_BOOL(0, "renormalize", &add_renormalize, N_("renormalize EOL of tracked files (implies -u)")), + OPT_BOOL(0, "resolved", &add_resolved, N_("add conflict-resolved tracked files")), OPT_BOOL('N', "intent-to-add", &intent_to_add, N_("record only the fact that the path will be added later")), OPT_BOOL('A', "all", &addremove_explicit, N_("add changes from all tracked and untracked files")), OPT_CALLBACK_F(0, "ignore-removal", &addremove_explicit, @@ -379,6 +382,76 @@ static int add_files(struct repository *repo, struct dir_struct *dir, int flags) return exit_status; } +static int failed_to_add(int flags, const char *path) +{ + if (!(flags & ADD_CACHE_IGNORE_ERRORS)) + die(_("updating file '%s' failed"), path); + return 1; +} + +static int add_resolved_files(struct repository *repo, + const struct pathspec *pathspec, + int flags) +{ + struct index_state *istate = repo->index; + struct string_list unmerged_paths = STRING_LIST_INIT_DUP; + struct string_list unresolved_paths = STRING_LIST_INIT_DUP; + int exit_status = 0; + size_t i; + + for (i = 0; i < istate->cache_nr; i++) { + struct cache_entry *ce = istate->cache[i]; + if (!ce_stage(ce)) + continue; + if (pathspec->nr && !ce_path_match(istate, ce, pathspec, NULL)) + continue; + if (!unmerged_paths.nr || + strcmp(unmerged_paths.items[unmerged_paths.nr - 1].string, ce->name)) + string_list_append(&unmerged_paths, ce->name); + } + + if (!unmerged_paths.nr) { + string_list_clear(&unmerged_paths, 0); + return 0; + } + + for (i = 0; i < unmerged_paths.nr; i++) { + const char *path = unmerged_paths.items[i].string; + struct stat st; + + if (!lstat(path, &st) && S_ISREG(st.st_mode)) { + if (has_conflict_markers(istate, path)) + string_list_append(&unresolved_paths, path); + } + } + + if (unresolved_paths.nr) { + struct strbuf sb = STRBUF_INIT; + for (i = 0; i < unresolved_paths.nr; i++) + strbuf_addf(&sb, "\t%s\n", unresolved_paths.items[i].string); + die(_("the following paths still have conflict markers:\n%s"), sb.buf); + } + + for (i = 0; i < unmerged_paths.nr; i++) { + const char *path = unmerged_paths.items[i].string; + struct stat st; + + if (lstat(path, &st)) { + if (errno != ENOENT) + die_errno(_("cannot lstat: '%s'"), path); + if (remove_file_from_index_with_flags(istate, path, flags)) + exit_status = failed_to_add(flags, path); + } else { + if (add_file_to_index(istate, path, flags)) + exit_status = failed_to_add(flags, path); + } + } + + string_list_clear(&unmerged_paths, 0); + string_list_clear(&unresolved_paths, 0); + return exit_status; +} + int cmd_add(int argc, const char **argv, const char *prefix, @@ -438,8 +511,9 @@ int cmd_add(int argc, else if (take_worktree_changes && ADDREMOVE_DEFAULT) addremove = 0; /* "-u" was given but not "-A" */ - if (addremove && take_worktree_changes) - die(_("options '%s' and '%s' cannot be used together"), "-A", "-u"); + die_for_incompatible_opt3(take_worktree_changes, "-u/--update", + 0 < addremove_explicit, "-A/--all", + add_resolved, "--resolved"); if (!show_only && ignore_missing) die(_("the option '%s' requires '%s'"), "--ignore-missing", "--dry-run"); @@ -448,8 +522,11 @@ int cmd_add(int argc, chmod_arg[1] != 'x' || chmod_arg[2])) die(_("--chmod param '%s' must be either -x or +x"), chmod_arg); - add_new_files = !take_worktree_changes && !refresh_only && !add_renormalize; - require_pathspec = !(take_worktree_changes || (0 < addremove_explicit)); + add_new_files = !take_worktree_changes && !refresh_only && + !add_renormalize && !add_resolved; + require_pathspec = !(take_worktree_changes || + (0 < addremove_explicit) || + add_resolved); repo_hold_locked_index(repo, &lock_file, LOCK_DIE_ON_ERROR); @@ -481,7 +558,8 @@ int cmd_add(int argc, return 0; } - if (!take_worktree_changes && addremove_explicit < 0 && pathspec.nr) + if (!take_worktree_changes && !add_resolved && + addremove_explicit < 0 && pathspec.nr) /* Turn "git add pathspec..." to "git add -A pathspec..." */ addremove = 1; @@ -584,7 +662,9 @@ int cmd_add(int argc, odb_transaction_begin_or_die(repo->objects, &transaction, 0); ps_matched = xcalloc(pathspec.nr, 1); - if (add_renormalize) + if (add_resolved) + exit_status |= add_resolved_files(repo, &pathspec, flags); + else if (add_renormalize) exit_status |= renormalize_tracked_files(repo, &pathspec, flags); else exit_status |= add_files_to_cache(repo, prefix, diff --git a/merge-ll.c b/merge-ll.c index 41c97fb90a0630..ef5287dee8f11b 100644 --- a/merge-ll.c +++ b/merge-ll.c @@ -499,3 +499,28 @@ int is_conflict_marker_line(const char *line, unsigned long len, int marker_size return firstchar; } + +int has_conflict_markers(struct index_state *istate, const char *path) +{ + FILE *f; + struct strbuf sb = STRBUF_INIT; + int marker_size = ll_merge_marker_size(istate, path); + int has_markers = 0; + + f = fopen(path, "r"); + if (!f) + return 0; + + while (strbuf_getwholeline(&sb, f, '\n') != EOF) { + if (is_conflict_marker_line(sb.buf, sb.len, marker_size)) { + has_markers = 1; + break; + } + if (buffer_is_binary(sb.buf, + ULONG_MAX <= sb.len ? ULONG_MAX : sb.len)) + break; + } + fclose(f); + strbuf_release(&sb); + return has_markers; +} diff --git a/merge-ll.h b/merge-ll.h index b348aee15d7824..f26aef238d2c21 100644 --- a/merge-ll.h +++ b/merge-ll.h @@ -110,6 +110,7 @@ enum ll_merge_result ll_merge(mmbuffer_t *result_buf, int ll_merge_marker_size(struct index_state *istate, const char *path); int is_conflict_marker_line(const char *line, unsigned long len, int marker_size); +int has_conflict_markers(struct index_state *istate, const char *path); void reset_merge_attributes(void); #endif diff --git a/t/meson.build b/t/meson.build index 8ae6ab6c5fe1e2..e0f4b85ad50369 100644 --- a/t/meson.build +++ b/t/meson.build @@ -304,6 +304,7 @@ integration_tests = [ 't2204-add-ignored.sh', 't2205-add-worktree-config.sh', 't2206-add-submodule-ignored.sh', + 't2207-add-resolved.sh', 't2300-cd-to-toplevel.sh', 't2400-worktree-add.sh', 't2401-worktree-prune.sh', diff --git a/t/t2207-add-resolved.sh b/t/t2207-add-resolved.sh new file mode 100755 index 00000000000000..1b88efcb997bba --- /dev/null +++ b/t/t2207-add-resolved.sh @@ -0,0 +1,108 @@ +#!/bin/sh + +test_description='git add --resolved + +Test that "git add --resolved" stages conflict-resolved paths and +refuses to stage when conflict markers remain.' + +. ./test-lib.sh + +test_expect_success 'setup repo' ' + echo base >file1.txt && + echo base >file2.txt && + echo base >file3.txt && + echo base >file4.txt && + git add file1.txt file2.txt file3.txt file4.txt && + git commit -m initial && + + git branch topic && + echo "ours 1" >file1.txt && + echo "ours 2" >file2.txt && + echo "ours 3" >file3.txt && + git commit -a -m ours && + + git checkout topic && + echo "theirs 1" >file1.txt && + echo "theirs 2" >file2.txt && + echo "theirs 3" >file3.txt && + git commit -a -m theirs && + + git checkout @{-1} +' + +test_expect_success 'git add --resolved refuses files with conflict markers' ' + test_when_finished "git reset --hard HEAD" && + test_must_fail git merge topic && + echo "resolved 1" >file1.txt && + test_must_fail git add --resolved 2>err && + test_grep "the following paths still have conflict markers:" err && + test_grep "file2.txt" err && + test_grep "file3.txt" err && + # Index should remain unmerged for all files + git ls-files -u file1.txt >unmerged && + test_line_count = 3 unmerged +' + +test_expect_success 'git add --resolved succeeds when all conflict markers are removed' ' + test_when_finished "git reset --hard HEAD" && + test_must_fail git merge topic && + echo "resolved 1" >file1.txt && + echo "resolved 2" >file2.txt && + echo "resolved 3" >file3.txt && + git add --resolved && + git ls-files -u >unmerged && + test_must_be_empty unmerged && + git ls-files -s file1.txt file2.txt file3.txt >staged && + test_line_count = 3 staged +' + +test_expect_success 'git add --resolved ignores unconflicted modified files' ' + test_when_finished "git reset --hard HEAD" && + echo "unconflicted local change" >>file4.txt && + test_must_fail git merge topic && + echo "resolved 1" >file1.txt && + echo "resolved 2" >file2.txt && + echo "resolved 3" >file3.txt && + git add --resolved && + # file1, file2, file3 should be staged as resolved + git ls-files -u >unmerged && + test_must_be_empty unmerged && + # file4 should remain unstaged in working tree + git diff file4.txt >diff_out && + test_grep "unconflicted local change" diff_out && + git diff --cached file4.txt >cached_out && + test_must_be_empty cached_out +' + +test_expect_success 'git add --resolved handles file removals' ' + test_when_finished "git reset --hard HEAD" && + test_must_fail git merge topic && + echo "resolved 1" >file1.txt && + rm file2.txt && + echo "resolved 3" >file3.txt && + git add --resolved && + git ls-files -s file2.txt >out && + test_must_be_empty out +' + +test_expect_success 'git add --resolved honors pathspec' ' + test_when_finished "git reset --hard HEAD" && + test_must_fail git merge topic && + echo "resolved 1" >file1.txt && + # file2.txt and file3.txt still have conflict markers, + # but pathspec targets only file1.txt + git add --resolved file1.txt && + git ls-files -u file1.txt >unmerged1 && + test_must_be_empty unmerged1 && + git ls-files -u file2.txt >unmerged2 && + test_line_count = 3 unmerged2 +' + +test_expect_success 'git add --resolved incompatibility with -u and -A' ' + test_must_fail git add --resolved -u 2>err1 && + test_grep "cannot be used together" err1 && + test_must_fail git add --resolved -A 2>err2 && + test_grep "cannot be used together" err2 +' + +test_done From d5dd17756dce04fadb5f787376aabfae4d859bb4 Mon Sep 17 00:00:00 2001 From: Kenneth Lorber Date: Sun, 2 Aug 2026 20:41:03 -0400 Subject: [PATCH 12/13] t7528: fix failure under csh Explicitly set sh mode for ssh-agent (ssh-agent -s) to prevent failure when user's login shell is csh-like. The failure is caused by propagation of the $SHELL value from the user's original shell despite the test and test harness explictly using sh, which makes ssh-agent emit initialization code for the wrong shell: > cd t > echo $SHELL /bin/tcsh > ./t7528-signed-commit-ssh.sh --verbose --debug [...] expecting success of 7528.2 'sign commits using literal public keys with ssh-agent': [...] ./t7528-signed-commit-ssh.sh: 1: eval: setenv: not found ./t7528-signed-commit-ssh.sh: 1: eval: setenv: not found [...] Signed-off-by: Kenneth Lorber Acked-by: brian m. carlson Signed-off-by: Junio C Hamano --- t/t7528-signed-commit-ssh.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/t7528-signed-commit-ssh.sh b/t/t7528-signed-commit-ssh.sh index b50306b9b3952d..7bf4a40de20e13 100755 --- a/t/t7528-signed-commit-ssh.sh +++ b/t/t7528-signed-commit-ssh.sh @@ -82,7 +82,7 @@ test_expect_success GPGSSH 'create signed commits' ' test_expect_success GPGSSH 'sign commits using literal public keys with ssh-agent' ' test_when_finished "test_unconfig commit.gpgsign" && test_config gpg.format ssh && - eval $(ssh-agent -T || ssh-agent) && + eval $(ssh-agent -T -s || ssh-agent -s) && test_when_finished "kill ${SSH_AGENT_PID}" && test_when_finished "test_unconfig user.signingkey" && mkdir tmpdir && From 18e66859d87fb4b76599f73460b54f0848c76b16 Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Sat, 15 Aug 2026 09:20:13 -0700 Subject: [PATCH 13/13] The 14th batch Signed-off-by: Junio C Hamano --- Documentation/RelNotes/2.56.0.adoc | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Documentation/RelNotes/2.56.0.adoc b/Documentation/RelNotes/2.56.0.adoc index 0f8676fe93f158..c4bf8b3228f6fd 100644 --- a/Documentation/RelNotes/2.56.0.adoc +++ b/Documentation/RelNotes/2.56.0.adoc @@ -85,6 +85,11 @@ UI, Workflows & Features syscall fails has also been improved to name both the source and the destination. + * 'git add' has been taught a new '--resolved' option to stage + conflict-resolved paths, while leaving unrelated local changes + unstaged. It scans the unmerged paths for leftover conflict + markers and aborts if any are found. + Performance, Internal Implementation, Development Support etc. -------------------------------------------------------------- @@ -334,6 +339,22 @@ Performance, Internal Implementation, Development Support etc. exit codes (0 for success, 1 for non-ancestor, 128 for errors) and to ensure it cannot be combined with '--all'. + * The 'TRACE2_ANCESTRY' prerequisite in the 't0213' test script has been + refined to avoid failures under user-mode emulation by verifying that + the ancestry collector reports the expected process names rather than + the emulator binary name. + + * Concurrent downloads of packfiles via packfile URIs and dumb HTTP are + safer by avoiding concurrent appends to the staging file. Opening in + read-write mode with separate file offsets prevents corruption and + preserves resumability. 'fetch-pack' now tolerates pre-existing + '.keep' files. + + * The 'ssh-agent' tests in 't7528' have been fixed to work when the + user's login shell is csh-like, by explicitly passing '-s' to + 'ssh-agent' to force Bourne shell syntax. + (merge d5dd17756d kl/t7528-ssh-agent-for-csh-users later to maint). + Fixes since v2.55 -----------------