repack: support combining '--geometric' with '--cruft'

Teach 'git repack' to accept '--geometric' and '--cruft' together. When both are given, the geometric repack rolls up non-cruft packs as usual, and a separate cruft pack is written to collect unreachable objects. Previously, '--cruft' implied `ALL_INTO_ONE`, which is fundamentally incompatible with geometric repacking. Relax this so that '--cruft' only implies `ALL_INTO_ONE` when '--geometric' is not also given. When combining the two modes: - Use the new '--stdin-packs=follow-reachable' mode so that only reachable objects from the rolled-up packs (and any reachable loose objects) appear in the geometric pack. Unreachable objects are left for the cruft writer to collect. - Plumb our `pack_geometry` into `write_cruft_pack()`, so that the latter can tell 'pack-objects' which non-kept packs are below the split (excluded, so their unreachable objects are candidates for the cruft pack) versus above the split (included, so they are treated as reachable). - Handle promisor packs in the cruft writer's geometry path, since promisor packs have their own split point. - Use the refs snapshot (when available) so that pack-objects and the MIDX bitmap writer see the same set of reference tips. Signed-off-by: Taylor Blau <me@ttaylorr.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Taylor Blau committed Jun 26, 2026 at 15:02 UTC a53c3b6193d783f1dd2bd283782b1c3033ef0b3e
5 files changed +300 -11
Documentation/git-repack.adoc
+11
@@ -70,6 +70,11 @@ to the new separate pack will be written.
70 are packed into a separate cruft pack. Unreachable objects can
71 be pruned using the normal expiry rules with the next `git gc`
72 invocation (see linkgit:git-gc[1]). Incompatible with `-k`.
73 ++
74 +When combined with `--geometric`, `--cruft` does not imply `-a`. Instead,
75 +the geometric repack rolls up packs as usual, and a separate cruft pack is
76 +written to collect unreachable objects. Only reachable objects from the
77 +rolled-up packs are included in the resulting geometric pack.
78
79 --cruft-expiration=<approxidate>::
80 Expire unreachable objects older than `<approxidate>`
@@ -245,6 +250,12 @@ progression.
250 Loose objects are implicitly included in this "roll-up", without respect to
251 their reachability. This is subject to change in the future.
252 +
253 +When combined with `--cruft`, only reachable objects from rolled-up packs
254 +are included in the geometric pack, along with any reachable loose objects.
255 +Unreachable objects (both from rolled-up packs and loose) are collected
256 +into a separate cruft pack. Existing cruft packs are retained. See
257 +`--cruft` above for details.
258 ++
259 When writing a multi-pack bitmap, `git repack` selects the largest resulting
260 pack as the preferred pack for object selection by the MIDX (see
261 linkgit:git-multi-pack-index[1]).
builtin/repack.c
+16 -7
@@ -260,7 +260,7 @@ int cmd_repack(int argc,
260 keep_unreachable, "-k/--keep-unreachable",
261 pack_everything & PACK_CRUFT, "--cruft");
262
263 - if (pack_everything & PACK_CRUFT)
263 + if (pack_everything & PACK_CRUFT && !geometry.split_factor)
264 pack_everything |= ALL_INTO_ONE;
265
266 if (write_bitmaps < 0) {
@@ -296,7 +296,8 @@ int cmd_repack(int argc,
296 die(_("invalid value for %s: %d"), "--midx-new-layer-threshold",
297 config_ctx.midx_new_layer_threshold);
298
299 - if (write_midx != REPACK_WRITE_MIDX_NONE && write_bitmaps) {
299 + if ((write_midx != REPACK_WRITE_MIDX_NONE && write_bitmaps) ||
300 + (geometry.split_factor && (pack_everything & PACK_CRUFT))) {
301 struct strbuf path = STRBUF_INIT;
302
303 strbuf_addf(&path, "%s/%s_XXXXXX",
@@ -317,7 +318,7 @@ int cmd_repack(int argc,
318 existing_packs_collect(&existing, &keep_pack_list);
319
320 if (geometry.split_factor) {
320 - if (pack_everything)
321 + if (pack_everything & ~PACK_CRUFT)
322 die(_("options '%s' and '%s' cannot be used together"), "--geometric", "-A/-a");
323 if (write_midx == REPACK_WRITE_MIDX_INCREMENTAL) {
324 geometry.midx_layer_threshold = config_ctx.midx_new_layer_threshold;
@@ -393,10 +394,16 @@ int cmd_repack(int argc,
394 pack_geometry_repack_promisors(repo, &po_args, &geometry,
395 &names, packtmp);
396
396 - if (midx_must_contain_cruft)
397 + if (pack_everything & PACK_CRUFT) {
398 + strvec_push(&cmd.args, "--stdin-packs=follow-reachable");
399 + if (refs_snapshot)
400 + strvec_pushf(&cmd.args, "--refs-snapshot=%s",
401 + get_tempfile_path(refs_snapshot));
402 + } else if (midx_must_contain_cruft)
403 strvec_push(&cmd.args, "--stdin-packs");
404 else
405 strvec_push(&cmd.args, "--stdin-packs=follow");
406 +
407 strvec_push(&cmd.args, "--unpacked");
408 } else {
409 strvec_push(&cmd.args, "--unpacked");
@@ -431,7 +438,8 @@ int cmd_repack(int argc,
438 const char *basename = pack_basename(geometry.pack[i]);
439 char marker = '^';
440
434 - if (!midx_must_contain_cruft &&
441 + if ((pack_everything & PACK_CRUFT ||
442 + !midx_must_contain_cruft) &&
443 !string_list_has_string(&existing.midx_packs,
444 basename)) {
445 /*
@@ -505,7 +513,8 @@ int cmd_repack(int argc,
513
514 ret = write_cruft_pack(&opts, cruft_expiration,
515 combine_cruft_below_size, &names,
508 - &existing);
516 + &existing,
517 + geometry.split_factor ? &geometry : NULL);
518 if (ret)
519 goto cleanup;
520
@@ -540,7 +549,7 @@ int cmd_repack(int argc,
549 */
550 opts.destination = expire_to;
551 ret = write_cruft_pack(&opts, NULL, 0ul, &names,
543 - &existing);
552 + &existing, NULL);
553 if (ret)
554 goto cleanup;
555 }
repack-cruft.c
+20 -3
@@ -36,7 +36,8 @@ int write_cruft_pack(const struct write_pack_opts *opts,
36 const char *cruft_expiration,
37 unsigned long combine_cruft_below_size,
38 struct string_list *names,
39 - struct existing_packs *existing)
39 + struct existing_packs *existing,
40 + struct pack_geometry *geometry)
41 {
42 struct child_process cmd = CHILD_PROCESS_INIT;
43 struct string_list_item *item;
@@ -81,8 +82,24 @@ int write_cruft_pack(const struct write_pack_opts *opts,
82 else
83 for_each_string_list_item(item, &existing->cruft_packs)
84 fprintf(in, "-%s.pack\n", item->string);
84 - for_each_string_list_item(item, &existing->non_kept_packs)
85 - fprintf(in, "-%s.pack\n", item->string);
85 + if (geometry) {
86 + uint32_t j;
87 + for (j = 0; j < geometry->split; j++)
88 + fprintf(in, "-%s\n",
89 + pack_basename(geometry->pack[j]));
90 + for (; j < geometry->pack_nr; j++)
91 + fprintf(in, "%s\n",
92 + pack_basename(geometry->pack[j]));
93 + for (j = 0; j < geometry->promisor_split; j++)
94 + fprintf(in, "-%s\n",
95 + pack_basename(geometry->promisor_pack[j]));
96 + for (; j < geometry->promisor_pack_nr; j++)
97 + fprintf(in, "%s\n",
98 + pack_basename(geometry->promisor_pack[j]));
99 + } else {
100 + for_each_string_list_item(item, &existing->non_kept_packs)
101 + fprintf(in, "-%s.pack\n", item->string);
102 + }
103 for_each_string_list_item(item, &existing->kept_packs)
104 fprintf(in, "%s.pack\n", item->string);
105 fclose(in);
repack.h
+2 -1
@@ -169,6 +169,7 @@ int write_cruft_pack(const struct write_pack_opts *opts,
169 const char *cruft_expiration,
170 unsigned long combine_cruft_below_size,
171 struct string_list *names,
172 - struct existing_packs *existing);
172 + struct existing_packs *existing,
173 + struct pack_geometry *geometry);
174
175 #endif /* REPACK_H */
t/t7704-repack-cruft.sh
+251
@@ -891,4 +891,255 @@ test_expect_success 'repack rescues once-cruft objects above geometric split' '
891 git repack --geometric=2 -d --write-midx --write-bitmap-index
892 '
893
894 +test_expect_success 'repack --geometric --cruft combines packs and writes cruft' '
895 + git init geometric-cruft-basic &&
896 + (
897 + cd geometric-cruft-basic &&
898 +
899 + test_commit A &&
900 + test_commit B &&
901 +
902 + B="$(git rev-parse B)" &&
903 +
904 + git reset --hard $B^ &&
905 + git tag -d B &&
906 + git reflog expire --all --expire=all &&
907 +
908 + # Initial state: one non-cruft pack, one cruft pack.
909 + git repack -d --cruft &&
910 +
911 + ls $packdir/pack-*.mtimes >cruft.before &&
912 + test_line_count = 1 cruft.before &&
913 +
914 + test_commit C &&
915 + git repack &&
916 +
917 + # At this point we have three packs:
918 + # - the non-cruft pack from A
919 + # - the cruft pack from B
920 + # - a new non-cruft pack from C
921 + #
922 + # The two non-cruft packs are not in a geometric
923 + # progression, so they should be rolled up.
924 + git repack -d --geometric=2 --cruft &&
925 +
926 + # The old cruft pack for B is retained, since the
927 + # geometric repack does not touch cruft packs.
928 + ls $packdir/pack-*.mtimes >cruft.after &&
929 + test_line_count = 1 cruft.after &&
930 +
931 + # Ensure that all reachable objects are present.
932 + git fsck
933 + )
934 +'
935 +
936 +test_expect_success 'repack --geometric --cruft writes new cruft for loose unreachable' '
937 + git init geometric-cruft-new-cruft &&
938 + (
939 + cd geometric-cruft-new-cruft &&
940 +
941 + git config set maintenance.auto false &&
942 +
943 + test_commit A &&
944 + git repack &&
945 +
946 + test_commit B &&
947 + git repack &&
948 +
949 + # Create an unreachable commit whose objects are
950 + # still loose (never packed).
951 + test_commit C &&
952 + C="$(git rev-parse C)" &&
953 + git reset --hard $C^ &&
954 + git tag -d C &&
955 + git reflog expire --all --expire=all &&
956 +
957 + # At this point we have two non-cruft packs of
958 + # similar size that are not in geometric progression,
959 + # and loose unreachable objects from commit C.
960 + ls $packdir/pack-*.idx >packs.before &&
961 + test_line_count = 2 packs.before &&
962 +
963 + # Geometric+cruft repack should roll up the two
964 + # non-cruft packs and write a new cruft pack for C
965 + # (whose objects are loose and unreachable).
966 + git repack -d --geometric=2 --cruft &&
967 +
968 + ls $packdir/pack-*.mtimes >cruft.after &&
969 + test_line_count = 1 cruft.after &&
970 +
971 + git fsck
972 + )
973 +'
974 +
975 +test_expect_success 'repack --geometric --cruft -d deletes rolled-up packs' '
976 + git init geometric-cruft-delete &&
977 + (
978 + cd geometric-cruft-delete &&
979 +
980 + test_commit A &&
981 + git repack -d &&
982 +
983 + test_commit B &&
984 + git repack -d &&
985 +
986 + ls $packdir/pack-*.idx >before &&
987 +
988 + git repack -d --geometric=2 --cruft &&
989 +
990 + # Two packs should have been rolled into one. No cruft
991 + # pack is written because there are no unreachable objects.
992 + ls $packdir/pack-*.idx >after &&
993 + test_line_count = 1 after &&
994 +
995 + # The rolled-up packs should be gone.
996 + ! test_cmp before after
997 + )
998 +'
999 +
1000 +test_expect_success 'repack --geometric --cruft collects loose unreachable objects' '
1001 + git init geometric-cruft-loose &&
1002 + (
1003 + cd geometric-cruft-loose &&
1004 +
1005 + test_commit A &&
1006 + git repack -d &&
1007 +
1008 + test_commit B &&
1009 + git repack &&
1010 +
1011 + # Create a loose unreachable object by making it
1012 + # orphaned (not in any pack).
1013 + loose="$(echo "cruft object" | git hash-object -w --stdin)" &&
1014 +
1015 + # We have two non-cruft packs and a loose unreachable
1016 + # object. The geometric+cruft repack should roll up
1017 + # the packs AND write a cruft pack for the loose
1018 + # unreachable object.
1019 + git repack -d --geometric=2 --cruft &&
1020 +
1021 + ls $packdir/pack-*.mtimes >cruft.packs &&
1022 + test_line_count = 1 cruft.packs &&
1023 +
1024 + git fsck
1025 + )
1026 +'
1027 +
1028 +test_expect_success 'repack --geometric --cruft accumulates cruft packs' '
1029 + git init geometric-cruft-accumulate &&
1030 + (
1031 + cd geometric-cruft-accumulate &&
1032 +
1033 + git config set maintenance.auto false &&
1034 +
1035 + test_commit A &&
1036 + git repack &&
1037 +
1038 + # First round: create unreachable objects and do a
1039 + # geometric+cruft repack.
1040 + unreachable_1="$(echo "cruft 1" | git hash-object -w --stdin)" &&
1041 + git repack -d --geometric=2 --cruft &&
1042 +
1043 + ls $packdir/pack-*.mtimes >cruft.1 &&
1044 + test_line_count = 1 cruft.1 &&
1045 +
1046 + test_commit B &&
1047 + git repack &&
1048 +
1049 + # Second round: create more unreachable objects and
1050 + # repack again. The old cruft pack should be retained
1051 + # and a new one written.
1052 + unreachable_2="$(echo "cruft 2" | git hash-object -w --stdin)" &&
1053 + git repack -d --geometric=2 --cruft &&
1054 +
1055 + ls $packdir/pack-*.mtimes >cruft.2 &&
1056 + test_line_count = 2 cruft.2 &&
1057 +
1058 + git fsck
1059 + )
1060 +'
1061 +
1062 +test_expect_success 'repack --geometric --cruft --combine-cruft-below-size' '
1063 + git init geometric-cruft-combine &&
1064 + (
1065 + cd geometric-cruft-combine &&
1066 +
1067 + git config set maintenance.auto false &&
1068 +
1069 + test_commit A &&
1070 + git repack &&
1071 +
1072 + # Create a small cruft pack.
1073 + unreachable_1="$(echo "cruft 1" | git hash-object -w --stdin)" &&
1074 + git repack -d --geometric=2 --cruft &&
1075 +
1076 + ls $packdir/pack-*.mtimes >cruft.before &&
1077 + test_line_count = 1 cruft.before &&
1078 +
1079 + test_commit B &&
1080 + git repack &&
1081 +
1082 + # Create another small cruft pack.
1083 + unreachable_2="$(echo "cruft 2" | git hash-object -w --stdin)" &&
1084 + git repack -d --geometric=2 --cruft &&
1085 +
1086 + ls $packdir/pack-*.mtimes >cruft.mid &&
1087 + test_line_count = 2 cruft.mid &&
1088 +
1089 + test_commit C &&
1090 + git repack &&
1091 +
1092 + # With --combine-cruft-below-size, the two small cruft
1093 + # packs should be combined into one.
1094 + unreachable_3="$(echo "cruft 3" | git hash-object -w --stdin)" &&
1095 + git repack -d --geometric=2 --cruft \
1096 + --combine-cruft-below-size=10M &&
1097 +
1098 + ls $packdir/pack-*.mtimes >cruft.after &&
1099 + test_line_count = 1 cruft.after &&
1100 +
1101 + git fsck
1102 + )
1103 +'
1104 +
1105 +test_expect_success 'repack --geometric --cruft --expire-to' '
1106 + git init geometric-cruft-expire-to &&
1107 + (
1108 + cd geometric-cruft-expire-to &&
1109 +
1110 + git config set maintenance.auto false &&
1111 +
1112 + test_commit A &&
1113 + git repack &&
1114 +
1115 + test_commit B &&
1116 + git repack &&
1117 +
1118 + # Create unreachable objects and record them.
1119 + test_commit C &&
1120 + C="$(git rev-parse C)" &&
1121 + git rev-list --objects --no-object-names B..C >unreachable.raw &&
1122 + sort unreachable.raw >unreachable.want &&
1123 +
1124 + git reset --hard $C^ &&
1125 + git tag -d C &&
1126 + git reflog expire --all --expire=all &&
1127 +
1128 + git init --bare expired.git &&
1129 + git repack -d --geometric=2 --cruft \
1130 + --cruft-expiration=now \
1131 + --expire-to="expired.git/objects/pack/pack" &&
1132 +
1133 + # The expired objects should appear in the
1134 + # expire-to location.
1135 + expired="$(ls expired.git/objects/pack/pack-*.idx)" &&
1136 + test_path_is_file "${expired%.idx}.mtimes" &&
1137 + git show-index <"$expired" >expired.raw &&
1138 + cut -d" " -f2 expired.raw | sort >expired.objects &&
1139 + test_cmp unreachable.want expired.objects &&
1140 +
1141 + git fsck
1142 + )
1143 +'
1144 +
1145 test_done