Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion builtin/mktree.c
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ static void mktree_line(struct repository *repo, char *buf, int nul_term_line, i
oi.typep = &obj_type;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jeff King wrote on the Git mailing list (how to reply to this email):

On Sat, Aug 29, 2026 at 07:00:30AM +0000, Elijah Newren via GitGitGadget wrote:

> mktree_line() checks each referenced object's type with
> odb_read_object_info_extended() under OBJECT_INFO_QUICK.  QUICK skips the
> reprepare-and-retry that reloads the on-disk pack set, so a resident
> "git mktree --batch" reader reports an object that a concurrent repack
> just relocated into a new pack as missing, and rejects the entry.
> 
> QUICK entered this lookup in 817b0f602710 (mktree: do not check type of
> remote objects, 2022-06-21) only to avoid lazily fetching promisor
> objects; OBJECT_INFO_SKIP_FETCH_OBJECT already provides that.  Drop
> OBJECT_INFO_QUICK and keep OBJECT_INFO_SKIP_FETCH_OBJECT, so mktree still
> avoids a promisor fetch but recovers an object that was merely repacked.

I think this line of reasoning is fine.

We probably _could_ use QUICK when the caller specified --missing, which
would optimize out the SECOND_READ effort if the caller told us they
expect (or at least allow) some items to be missing. But:

  1. It's not clear how people use --missing. If you are just trying to
     be gentle with an occasional missing entry, then the optimization
     is not that interesting. If you run mktree all the time to make
     synthetic trees full of objects you don't have, then maybe you do
     care about the optimization. But if you are doing that then you
     probably are better off with an option that avoids the lookup
     entirely (i.e., we should just trust the type found in the input).

     So there's maybe room for a --yolo argument to mktree, though I
     guess in practice you could just use "hash-object" for that. But
     either way that is way out of scope for this patch.

  2. Prior to 817b0f602710 we were not QUICK either! And that commit was
     only trying to trigger SKIP_FETCH_OBJECT. So whether there is an
     argument for linking --missing and QUICK or not, it should be made
     separately. This patch is just fixing the extra flag that probably
     should not have been added by 817b0f602710.

> +test_expect_success PIPE 'mktree --batch survives a concurrent repack retiring a pack' '

OK. I was hoping we could test this without all of the PIPE complexity,
but I don't think we can. We really need a case where the first lookup
fails but SECOND_READ succeeds, which is inherently a race. Feeding one
entry at a time lets us implement that in a deterministic way, and I
think is the simplest we can get.

So the patch looks good to me overall.

-Peff

if (odb_read_object_info_extended(repo->objects, &oid, &oi,
OBJECT_INFO_LOOKUP_REPLACE |
OBJECT_INFO_QUICK |
OBJECT_INFO_SKIP_FETCH_OBJECT) < 0)
obj_type = -1;

Expand Down Expand Up @@ -200,8 +199,11 @@ int cmd_mktree(int ac,
puts(oid_to_hex(&oid));
Comment thread
newren marked this conversation as resolved.
fflush(stdout);
}
for (int i = 0; i < used; i++)
free(entries[i]);
used=0; /* reset tree entry buffer for re-use in batch mode */
}
free(entries);
strbuf_release(&sb);

return 0;
Expand Down
2 changes: 1 addition & 1 deletion builtin/pack-objects.c
Original file line number Diff line number Diff line change
Expand Up @@ -1786,7 +1786,7 @@ static int want_object_in_pack_mtime(const struct object_id *oid,
struct multi_pack_index *m = get_multi_pack_index(files->packed);
Comment thread
newren marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jeff King wrote on the Git mailing list (how to reply to this email):

On Sat, Aug 29, 2026 at 07:00:31AM +0000, Elijah Newren via GitGitGadget wrote:

> +	/*
> +	 * Recovery for a concurrent-repack race: a stale MIDX may still name a
> +	 * vanished owning pack even though the object survives in another pack
> +	 * the same MIDX covers.  The regular fallback above skips MIDX-covered
> +	 * packs, and repreparing the on-disk pack set does not reload the
> +	 * borrowed, cached MIDX, so scan its packs directly for the survivor.
> +	 *
> +	 * Do this only on the second read, by which point repreparing packs has
> +	 * already had a chance to find an object merely relocated into a new,
> +	 * uncovered pack; only a genuine hidden duplicate reaches here.
> +	 */
> +	if (midx_result == MIDX_FILL_OWNER_UNAVAILABLE &&
> +	    (flags & OBJECT_INFO_SECOND_READ)) {
> +		struct multi_pack_index *m = store->midx;
> +		uint32_t i;
> +
> +		for (i = 0; i < m->num_packs + m->num_packs_in_base; i++) {
> +			struct packed_git *p;
> +
> +			if (prepare_midx_pack(m, i))
> +				continue;
> +			p = nth_midxed_pack(m, i);
> +			if (p && packfile_fill_entry(p, oid, e, bad_pack))
> +				return 1;
> +		}
> +	}

So I think this workaround is fine to do (as long as we are not going to
actually refresh the midx on SECOND_READ, which I agree is probably a
bigger change).

I always get confused about m->num_packs and m->num_packs_in_base, and
whether we are looking at the packs in a midx slice versus the whole
thing. I _think_ what you have here is correct, because we are iterating
from 0 up to the total number of packs, and prepare_midx_pack() etc will
look back through the incremental slices as necessary.

But I wonder if it would be simpler to just iterate over the actual pack
list in the usual way, since we already do that in this function. I
_thought_ this would work:

diff --git a/odb/source-packed.c b/odb/source-packed.c
index 90d88c0a12..86e6a80d2f 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -33,40 +33,19 @@ static int find_pack_entry(struct odb_source_packed *store,
 	for (l = store->packs.head; l; l = l->next) {
 		struct packed_git *p = l->pack;
 
-		if (!p->multi_pack_index && packfile_fill_entry(p, oid, e, bad_pack)) {
+		/* ...explain tricky race case here... */
+		if (p->multi_pack_index &&
+		    (midx_result != MIDX_FILL_OWNER_UNAVAILABLE ||
+		     !(flags & OBJECT_INFO_SECOND_READ)))
+			continue;
+
+		if (packfile_fill_entry(p, oid, e, bad_pack)) {
 			if (!store->skip_mru_updates)
 				packfile_list_prepend(&store->packs, p);
 			return 1;
 		}
 	}
 
-	/*
-	 * Recovery for a concurrent-repack race: a stale MIDX may still name a
-	 * vanished owning pack even though the object survives in another pack
-	 * the same MIDX covers.  The regular fallback above skips MIDX-covered
-	 * packs, and repreparing the on-disk pack set does not reload the
-	 * borrowed, cached MIDX, so scan its packs directly for the survivor.
-	 *
-	 * Do this only on the second read, by which point repreparing packs has
-	 * already had a chance to find an object merely relocated into a new,
-	 * uncovered pack; only a genuine hidden duplicate reaches here.
-	 */
-	if (midx_result == MIDX_FILL_OWNER_UNAVAILABLE &&
-	    (flags & OBJECT_INFO_SECOND_READ)) {
-		struct multi_pack_index *m = store->midx;
-		uint32_t i;
-
-		for (i = 0; i < m->num_packs + m->num_packs_in_base; i++) {
-			struct packed_git *p;
-
-			if (prepare_midx_pack(m, i))
-				continue;
-			p = nth_midxed_pack(m, i);
-			if (p && packfile_fill_entry(p, oid, e, bad_pack))
-				return 1;
-		}
-	}
-
 	return 0;
 }
 

but it doesn't because we don't always load the midx'd packs into the
pack list (we do it on-demand as they become useful to us). So I think
you'd essentially end up needing to do a loop like the one you have
anyway to prepare_midx_pack() on them all.

And we want to avoid doing that if we can find it outside the midx
(since that was the whole point of waiting for SECOND_READ). Which would
happen...in that loop. So we really do want to have our own
midx-specific loop like you have here.

Sorry, I know that was a lot of text to end up at "you have already
written it the best way", but it took me a while to reason through it.

The patch looks good to me. ;)

-Peff

struct pack_entry e;

if (m && fill_midx_entry(m, oid, &e, NULL)) {
if (m && midx_fill_entry(m, oid, &e, NULL) == MIDX_FILL_HIT) {
want = want_object_in_pack_one(e.p, oid, exclude, found_pack, found_offset, found_mtime);
if (want != -1)
return want;
Expand Down
20 changes: 10 additions & 10 deletions midx.c
Original file line number Diff line number Diff line change
Expand Up @@ -589,23 +589,23 @@ uint32_t nth_midxed_pack_int_id(struct multi_pack_index *m, uint32_t pos)
(off_t)pos * MIDX_CHUNK_OFFSET_WIDTH);
}

int fill_midx_entry(struct multi_pack_index *m,
const struct object_id *oid,
struct pack_entry *e,
struct packed_git **bad_pack)
enum midx_fill_result midx_fill_entry(struct multi_pack_index *m,
const struct object_id *oid,
struct pack_entry *e,
struct packed_git **bad_pack)
{
uint32_t pos;
uint32_t pack_int_id;
struct packed_git *p;

if (!bsearch_midx(oid, m, &pos))
return 0;
return MIDX_FILL_MISS;

midx_for_object(&m, pos);
pack_int_id = nth_midxed_pack_int_id(m, pos);

if (prepare_midx_pack(m, pack_int_id))
return 0;
return MIDX_FILL_OWNER_UNAVAILABLE;
p = m->packs[pack_int_id - m->num_packs_in_base];

/*
Expand All @@ -616,19 +616,19 @@ int fill_midx_entry(struct multi_pack_index *m,
* loaded!
*/
if (!is_pack_valid(p))
return 0;
return MIDX_FILL_OWNER_UNAVAILABLE;

if (oidset_size(&p->bad_objects) &&
oidset_contains(&p->bad_objects, oid)) {
if (bad_pack && !*bad_pack)
*bad_pack = p;
return 0;
return MIDX_FILL_MISS;
}

e->offset = nth_midxed_offset(m, pos);
e->p = p;

return 1;
return MIDX_FILL_HIT;
}

/* Match "foo.idx" against either "foo.pack" _or_ "foo.idx". */
Expand Down Expand Up @@ -1032,7 +1032,7 @@ int verify_midx_file(struct odb_source_packed *source, unsigned flags)

nth_midxed_object_oid(&oid, m, pairs[i].pos);

if (!fill_midx_entry(m, &oid, &e, NULL)) {
if (midx_fill_entry(m, &oid, &e, NULL) != MIDX_FILL_HIT) {
midx_report(_("failed to load pack entry for oid[%d] = %s"),
pairs[i].pos, oid_to_hex(&oid));
continue;
Expand Down
21 changes: 19 additions & 2 deletions midx.h
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,25 @@ uint32_t nth_midxed_pack_int_id(struct multi_pack_index *m, uint32_t pos);
struct object_id *nth_midxed_object_oid(struct object_id *oid,
struct multi_pack_index *m,
uint32_t n);
int fill_midx_entry(struct multi_pack_index *m, const struct object_id *oid,
struct pack_entry *e, struct packed_git **bad_pack);
/*
* Result of looking an object up in a multi-pack-index. MIDX_FILL_HIT means
* "e was filled in"; the two miss variants distinguish an object the midx does
* not know about (MIDX_FILL_MISS) from one it does know about but whose owning
* pack we can no longer open (MIDX_FILL_OWNER_UNAVAILABLE -- the signature of a
* concurrent repack having removed that pack). A known-bad (corrupt) object
* reports MIDX_FILL_MISS but also sets *bad_pack, if provided, to the owning
* pack so the caller can tell "corrupt" apart from "absent".
*/
enum midx_fill_result {
MIDX_FILL_MISS = 0,
MIDX_FILL_HIT,
MIDX_FILL_OWNER_UNAVAILABLE,
};

enum midx_fill_result midx_fill_entry(struct multi_pack_index *m,
const struct object_id *oid,
struct pack_entry *e,
struct packed_git **bad_pack);
int midx_contains_pack(struct multi_pack_index *m,
const char *idx_or_pack_name);
int midx_layer_contains_pack(struct multi_pack_index *m,
Expand Down
42 changes: 37 additions & 5 deletions odb/source-packed.c
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,18 @@
static int find_pack_entry(struct odb_source_packed *store,
const struct object_id *oid,
struct pack_entry *e,
enum object_info_flags flags,
struct packed_git **bad_pack)
{
struct packfile_list_entry *l;
enum midx_fill_result midx_result = MIDX_FILL_MISS;

odb_source_prepare(&store->base, 0);
if (store->midx && fill_midx_entry(store->midx, oid, e, bad_pack))
return 1;
if (store->midx) {
midx_result = midx_fill_entry(store->midx, oid, e, bad_pack);
if (midx_result == MIDX_FILL_HIT)
return 1;
}

for (l = store->packs.head; l; l = l->next) {
struct packed_git *p = l->pack;
Expand All @@ -35,6 +40,33 @@ static int find_pack_entry(struct odb_source_packed *store,
}
Comment thread
newren marked this conversation as resolved.
Comment thread
newren marked this conversation as resolved.
Comment thread
newren marked this conversation as resolved.
Comment thread
newren marked this conversation as resolved.
Comment thread
newren marked this conversation as resolved.
}

/*
* Recovery for a concurrent-repack race: a stale MIDX may still name a
* vanished owning pack even though the object survives in another pack
* the same MIDX covers. The regular fallback above skips MIDX-covered
* packs, and repreparing the on-disk pack set does not reload the
* borrowed, cached MIDX, so scan its packs directly for the survivor.
*
* Do this only on the second read, by which point repreparing packs has
* already had a chance to find an object merely relocated into a new,
* uncovered pack; only a genuine hidden duplicate reaches here.
*/
if (midx_result == MIDX_FILL_OWNER_UNAVAILABLE &&
(flags & OBJECT_INFO_SECOND_READ)) {
struct multi_pack_index *m = store->midx;
uint32_t i;

for (i = 0; i < m->num_packs + m->num_packs_in_base; i++) {
struct packed_git *p;

if (prepare_midx_pack(m, i))
continue;
p = nth_midxed_pack(m, i);
if (p && packfile_fill_entry(p, oid, e, bad_pack))
return 1;
}
}

return 0;
}

Expand All @@ -57,7 +89,7 @@ static enum odb_read_status odb_source_packed_read_object_info(struct odb_source
if (flags & OBJECT_INFO_SECOND_READ)
odb_source_prepare(source, ODB_PREPARE_FLUSH_CACHES);

if (!find_pack_entry(packed, oid, &e, &bad_pack)) {
if (!find_pack_entry(packed, oid, &e, flags, &bad_pack)) {
/*
* The lookup may have failed because the object is known to be
* corrupt in one of the packfiles. Report the object as
Expand Down Expand Up @@ -105,7 +137,7 @@ static int odb_source_packed_read_object_stream(struct odb_read_stream **out,
struct odb_source_packed *packed = odb_source_packed_downcast(source);
struct pack_entry e;

if (!find_pack_entry(packed, oid, &e, NULL))
if (!find_pack_entry(packed, oid, &e, 0, NULL))
return -1;

return packfile_read_object_stream(out, oid, e.p, e.offset);
Expand Down Expand Up @@ -611,7 +643,7 @@ static int odb_source_packed_freshen_object(struct odb_source *source,
timesp = &times;
}

if (!find_pack_entry(packed, oid, &e, NULL))
if (!find_pack_entry(packed, oid, &e, 0, NULL))
return 0;
if (e.p->is_cruft)
return 0;
Expand Down
7 changes: 7 additions & 0 deletions replay.c
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,13 @@ static struct commit *pick_regular_commit(struct repository *repo,
merge_opt->ancestor = NULL;
Comment thread
newren marked this conversation as resolved.
merge_opt->branch2 = NULL;

if (result->clean < 0) {
error(_("merge of %s onto %s failed"),
oid_to_hex(&pickme->object.oid),
oid_to_hex(&replayed_base->object.oid));
return NULL;
}

if (!result->clean)
return NULL;

Expand Down
2 changes: 1 addition & 1 deletion t/helper/test-read-midx.c
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ static int read_midx_file(const char *object_dir, const char *checksum,
for (i = 0; i < m->num_objects; i++) {
nth_midxed_object_oid(&oid, m,
i + m->num_objects_in_base);
fill_midx_entry(m, &oid, &e, NULL);
midx_fill_entry(m, &oid, &e, NULL);

printf("%s %"PRIu64"\t%s\n",
oid_to_hex(&oid), e.offset, e.p->pack_name);
Expand Down
48 changes: 48 additions & 0 deletions t/t1010-mktree.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,52 @@ test_expect_success 'mktree refuses to read ls-tree -r output (2)' '
test_must_fail git mktree <all.withsub
'

test_expect_success PIPE 'mktree --batch survives a concurrent repack retiring a pack' '
test_when_finished "rm -fr race" &&
git init race &&
(
cd race &&
test_commit seed &&
a=$(echo A | git hash-object -w --stdin) &&
b=$(echo B | git hash-object -w --stdin) &&
echo "$a" | git pack-objects .git/objects/pack/pack >pack-a &&
echo "$b" | git pack-objects .git/objects/pack/pack >pack-b &&

# Drop the loose copies so the blobs resolve only through the
# packs the multi-pack-index names.
git prune-packed &&
git multi-pack-index write &&
printf "100644 blob %s\ta\n" "$a" >tree-a &&
printf "100644 blob %s\tb\n" "$b" >tree-b &&

victim=".git/objects/pack/pack-$(cat pack-b)" &&
mkfifo in out &&

# mktree --batch stays resident, so its pack view predates the
# repack below; feed it one tree at a time over a fifo. The
# subshell exit closes the fifos, letting mktree see EOF and quit.
(git mktree --batch <in >out 2>err &) &&
exec 9>in &&
exec 8<out &&

# The first tree makes the reader cache its (soon stale) view.
cat tree-a >&9 && echo >&9 && read tree_a <&8 &&

# Mimic a concurrent repack: a replacement pack holds every
# object, and the pack for b loses its .idx (its .pack lingers),
# matching the order in which unlink_pack_path() removes files.
git cat-file --batch-all-objects --batch-check="%(objectname)" >oids &&
git pack-objects .git/objects/pack/pack <oids >/dev/null &&
rm -f "$victim.idx" &&

# Resolving b used to fail, as its QUICK lookup accepted the
# miss; without QUICK the reader repreps and finds b in the
# replacement pack.
cat tree-b >&9 && echo >&9 && read tree_b <&8 &&
exec 9>&- &&

test -n "$tree_b"
)
'

test_done
34 changes: 34 additions & 0 deletions t/t3650-replay-basics.sh
Original file line number Diff line number Diff line change
Expand Up @@ -565,4 +565,38 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
test_grep "cannot be used with multiple revision ranges" err
'

test_expect_success 'replay fails without segfault when objects are missing' '
test_when_finished "rm -fr unreadable" &&
git init unreadable &&
(
cd unreadable &&

test_write_lines l1 l2 l3 l4 l5 l6 l7 l8 >f &&
git add f &&
git commit -m base &&
git branch base &&

test_write_lines l1 l2 l3 l4 l5 l6 l7 CHANGED >f &&
git commit -am side &&
git branch side &&

git switch -c onto base &&
test_write_lines CHANGED l2 l3 l4 l5 l6 l7 l8 >f &&
git commit -am onto &&

# The replay works while every object is readable.
git replay --onto onto base..side &&

# Removing the onto tree makes parse_tree() fail during the
# incore merge, driving clean < 0 with a NULL result tree.
onto_tree=$(git rev-parse onto^{tree}) &&
obj=$(test_oid_to_path "$onto_tree") &&
mv .git/objects/${obj} saved-tree &&

# Ensure replay gracefully handles the missing object
test_must_fail git replay --onto onto base..side 2>err &&
test_grep -e "Could not read" -e "collecting merge info failed" err
)
'

test_done
40 changes: 40 additions & 0 deletions t/t5319-multi-pack-index.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1393,4 +1393,44 @@ test_expect_success 'pack.preferBitmapTips interprets patterns as hierarchy' '
)
'

test_expect_success 'lookup recovers object whose midx-owning pack was removed' '
test_when_finished "rm -fr repo" &&
git init repo &&
(
cd repo &&

# "keep" ends up only in the big pack; "dup" is deliberately
# placed in two packs so the midx has to choose an owner.
test_commit keep &&
echo duplicated-content >dup &&
git add dup &&
git commit -m dup &&
dup_oid=$(git rev-parse HEAD:dup) &&

# Roll every object, including dup, into a single big pack.
git repack -adq &&

# Build a second, "moderate" pack that also contains dup, so dup
# now lives in two packs that the midx will cover.
moderate=$(echo "$dup_oid" |
git pack-objects --quiet $objdir/pack/pack) &&

# Attribute dup to the moderate pack in the midx.
git multi-pack-index write \
--preferred-pack="pack-$moderate.idx" &&

# Simulate a concurrent "git repack" retiring the moderate pack:
# its files disappear, but the now-stale midx still names it as
# the owner of dup. A valid copy of dup survives in the big pack.
rm -f $objdir/pack/pack-$moderate.* &&

# The midx routes the lookup to the deleted pack, and the regular
# pack fallback skips midx-covered packs, so without recovery dup
# would appear missing even though it is physically present.
echo blob >expect &&
git cat-file -t "$dup_oid" >actual &&
test_cmp expect actual
)
'

test_done
Loading