Skip to content

Maintenance inception - #2146

Open
newren wants to merge 25 commits into
gitgitgadget:masterfrom
newren:maintenance-inception
Open

Maintenance inception#2146
newren wants to merge 25 commits into
gitgitgadget:masterfrom
newren:maintenance-inception

Conversation

@newren

@newren newren commented Jun 11, 2026

Copy link
Copy Markdown

During the time between when a repack starts and when it completes,
large repositories can gain thousands of packfiles and tens of thousands
of loose objects that are not part of that repack. Not only does this
mean that the repository immediately needs maintenance again, but git
operations slow down dramatically during the repack -- sometimes to the
point that the service is effectively considered offline.

This series proposes a simple (optional, off by default) solution:

  • Doing maintenance during maintenance.

or, more specifically:

  • Doing (cheap mere aggregation) maintenance (of new objects and packs)
    during (far more thorough) maintenance (of older objects and packs).

=== Concerns/Objection ===

The description above probably raises several serious concerns; let me
attempt to address them (feedback on whether I've missed any, or
insufficiently addressed some very welcome):

Objection 1: What does "cheap mere aggregation" mean?

It means omitting all of the following:

  • delta compression or even delta searching
  • reachability checks, and anything related to them
    • commit-graph updates
    • bitmap generation
    • cruft pack generation or updates
  • multi-pack index creation or updates

Objection 2: Aggregating packs without delta searches causes bad deltas

Yes, but this series adds a .baddeltas marker file (similar to .keep,
.mtimes, .bitmap, .promisor that already exist) to notify future
pack-objects invocations that two objects within that pack should be
checked for deltas, so the effect does not persist.

(On the flip side, see Trade-off 1)

Objection 3: Repack or its subprocess can fail if packfiles disappear

repack calls pack-objects, which enumerates the existing packs, and
only repacks those (or a subset of those if doing a geometric repack),
and then writes a new pack. The remainder of repack (e.g. multi-pack
index creation, bitmap generation) -- at least up until the
odb_reprepare() call immediately before deleting redundant packs --
only works on this subset of packs. Thus, if we have pack-objects
tell us the packs it is working on and we avoid touching any packs:

  • in an existing multi-pack-index
  • in the list of packs that repack's pack-objects is working on
  • that is written by repack's pack-objects process
  • that are covered by other sidecars (mainly '.keep' and '.mtimes', but
    we throw in '.promisor' and '.bitmap' for good measure)

Then we should be safe; this does require modifying pack-objects to:

  • let us know exactly what packs it is working on
  • create its new pack with a (temporary, see below) .keep file

(We also have pack-objects notify us which loose objects exist at that
time, even though that is not needed for correctness, but does allow
us to generally avoid putting a loose object into two new packs.)

Objection 4: The .keep created by pack-objects might persist

It is removed at the end of the repack process.

Further, if for some reason the repack process is killed or dies, the
.keep file contains a marker making it clear the keep was meant to be
temporary and has the corresponding PID of the repack process, meaning
that if that PID no longer exists, the .keep file can be cleaned up.
See also the next point.

Objection 5: Will this "cheap" maintenance actually be cheap?

There's an unnecessary reachability walk today which makes this kind
of "cheap" maintenance somewhat expensive. Patch 3 removes it and
makes a typical case 500x faster and take 50x less memory.

That's fast enough that I not only run "cheap" maintenance during
"real" maintenance, I also run a "cheap" maintenance step before "real"
maintenance. The reasons for this inserted preparatory step are:

  • it allows other git operations to be faster while waiting for
    the "real" maintenance to complete
  • it reduces the number of packfiles and loose objects that need
    to be tracked for exclusion
  • the preparatory step can look for leftover ".keep" files from a
    previous repack process that died early and remove them

=== Important tradeoffs ===

Trade-off 1: need to repeat some delta checks

Two objects being in the same pack that aren't a delta against each
other usually means a previous process has checked and determined
those two objects are not a good delta. Any such objects placed into
a .baddeltas pack will lose that information and we'll have to
re-check for deltas.

Trade-off 2: mtime delay for expiring objects

When we repack loose objects into a (non-cruft) pack, that effectively
updates their mtime when they could have been ready to expire. If
these objects are unreachable, then the "real" maintenance's
reachability check will determine that and either unpack these objects
or move them to a cruft pack. Either way, they'll be stamped with the
pack's mtime they were removed from. For large repositories where we
essentially continuously run maintenance all day long, this can only
add a delay equal to how long a maintenance run takes and is thus
considered inconsequential.

=== Series overview ===

Patch 1: Add support for a ".baddeltas" marker for packs, in try_delta()
Patch 2: Support --mark-bad-deltas option in pack-objects
Patch 3: Accelerate pack-objects when delta search is off
Patch 4: Add --emit-input-{packs,loose} plumbing options to pack-objects

This patch is critical, because we need to know what packs the
regular repacking will be working with so we avoid them.

Trying to enumerate the packs outside of repack/pack-objects risks a
race with newly pushed packs, and in large repositories which push
multiple times per second, losing that race is quite likely.

Patch 5: New pack-aggregate builtin for cheap object/pack aggregation
Patch 6: Allow repack to spawn pack-aggregate while doing regular repacking

When considering an object pair for delta compression, try_delta()
short-circuits (skipping a real delta search) when both objects came
from the same on-disk pack and neither is stored there as a delta.
The reasoning is that an earlier pack-objects run already had the
chance to try this pair and chose not to delta either against the
other, so trying again is unlikely to find anything new.

That heuristic is correct only if the source pack was actually built by
something that did a real delta search.  That may not be the case if the
pack was written by `git fast-import` (which only does a basic delta
comparison for objects in the pack it writes), the bulk_checkin
framework when streaming large blobs straight into a pack, or
pack-objects with --window=0.

Add a `.baddeltas` sidecar marker, parallel to `.keep`, `.promisor`,
and `.mtimes`, that lets a producer flag its output pack as one whose
delta layout should not be trusted by this skip.

For context on the underlying delta skip and earlier discussions of a
similar marker, see Jeff King's analysis on the list:

  https://lore.kernel.org/git/20231009202149.GA3281325@coredump.intra.peff.net/

Assisted-by: Claude Opus 4.7
Signed-off-by: Elijah Newren <newren@gmail.com>
@newren
newren force-pushed the maintenance-inception branch 3 times, most recently from 9a7521d to 24bdb43 Compare June 11, 2026 14:34
"git cat-file --batch-all-objects" opens each pack's .idx lazily, as
its walk reaches that pack.  If a concurrent repack removes a pack
first, opening its index fails.  The object database already turns this
into a non-zero return from odb_for_each_object_ext(), but
batch_each_object() discarded it and we reported success -- silently
omitting the vanished pack's objects while still exiting 0, which is
dangerous for tooling that trusts the listing to be complete.

Propagate the error: return it from batch_each_object() and, when set,
exit non-zero with a diagnostic instead of pretending a truncated
listing was whole.

To reproduce, loop "git repack -adq" in one process while another loops
"git cat-file --batch-all-objects --batch-check"; before this change
the reader occasionally stopped with status 0 mid-repack, and now it
errors instead.

Assisted-by: Claude Opus 4.8
Signed-off-by: Elijah Newren <newren@gmail.com>
"git cat-file --batch-all-objects --unordered" reads each object from
the pack and offset the walk handed it, rather than looking it up
again.  If a concurrent repack removes that pack in between,
packed_object_info() calls use_pack(), which cannot reopen the unlinked
pack and dies with "packfile ... cannot be accessed" -- even though the
object still lives in the pack repack just wrote.

Guard the read with is_pack_valid(): if the pack can no longer be
opened, clear the pointer so we fall back to a normal object-database
lookup, which finds the object in whatever pack now holds it.
is_pack_valid() opens and pins the pack's fd, so once it succeeds the
pack stays readable even if it is unlinked an instant later.

Assisted-by: Claude Opus 4.8
Signed-off-by: Elijah Newren <newren@gmail.com>
The previous commit made "git cat-file --batch-all-objects" error out
when a concurrent repack removes a pack mid-walk.  That is correct but
pessimistic on servers that repack often.

Reduce how often it triggers by opening every pack index up front,
before the walk.  An index mmap survives unlink() of the .idx and pack
fd pressure (close_one_pack() closes only the pack fd), so the object
set is fixed once the indexes are open.  For --batch-all-objects this
adds no work: the walk opens every index anyway.

Like f6b2625 (fsck: snapshot default refs before object walk,
2026-01-09), this narrows rather than closes the race; the residue is
still caught by the previous commit rather than silently dropped.

Assisted-by: Claude Opus 4.8
Signed-off-by: Elijah Newren <newren@gmail.com>
"git fsck" enumerates packs up front and later verifies each pack's
reverse index.  If a concurrent "git repack" removes one of those packs
in the meantime, load_pack_revindex_from_disk() fails and fsck reports
"unable to load rev-index", implying corruption.  That is misleading:
the objects are safe in the replacement pack and a quiescent retry
succeeds.

When the load fails because the ".pack" itself is gone (ENOENT), say so
and suggest retrying once maintenance completes, rather than blaming the
rev-index.  A failure with the pack still present is reported as before,
so genuine corruption still surfaces.

This can be provoked by looping "git repack -adq" in one process while
another loops "git fsck --connectivity-only": occasionally fsck trips
over a pack removed after it was enumerated, and now says the pack
disappeared instead of reporting a bad rev-index.

Assisted-by: Claude Opus 4.8
Signed-off-by: Elijah Newren <newren@gmail.com>
"git multi-pack-index verify" (which "git fsck" always spawns) walks the
midx's packs, closing and reopening each by name as it checks object
offsets.  A concurrent "git repack" that unlinks a redundant pack in
that window makes the reopen fail, and verify reports "failed to load
pack in position N", "failed to load pack entry for oid[N]", or "failed
to load pack-index for packfile ..." -- all of which read like midx
corruption, though the objects are safe in the replacement pack and a
quiescent retry succeeds.

When such a failure is explained by a midx-referenced ".pack" having
vanished (repack unlinks a redundant pack's ".idx" before its ".pack",
so a missing ".pack" is the tell-tale), add a one-time hint to retry
when quiescent.  The existing per-failure messages are kept, so genuine
corruption is still reported as before.

To provoke it, "git multi-pack-index write" a repo with several packs
and run "git multi-pack-index verify" while "git repack -adq" loops in
the background; verify occasionally fails with one of the messages
above, now followed by the retry hint.

Assisted-by: Claude Opus 4.8
Signed-off-by: Elijah Newren <newren@gmail.com>
The previous patch taught pack-objects to honor a `.baddeltas` sidecar
marker that tells the same-pack delta skip in try_delta() to fall
through to a real delta search.  Add a `--mark-bad-deltas` option that
writes such a marker alongside each output pack.

The flag is incompatible with `--stdout`, since the marker lives next
to the output pack in `$GIT_DIR/objects/pack` and `--stdout` has no
on-disk output to mark.  When pack-objects splits its output across
multiple packs (with `--max-pack-size`), a marker is written for each
resulting pack inside the existing per-pack output loop, between
stage_tmp_packfiles() and rename_tmp_packfile_idx() so the marker is
in place before the `.idx` anchor becomes visible.

Document the option, and extend t5335 to cover both the producer side
(marker is written and disables the same-pack skip for the resulting
pack) and the `--stdout` incompatibility.

Assisted-by: Claude Opus 4.7
Signed-off-by: Elijah Newren <newren@gmail.com>
pack-objects performs a somewhat expensive revision walk to:

  * fill in object namehashes, improving delta selection;

  * with --stdin-packs=follow, pull in objects from unlisted packs to
    close the set under reachability.

When --stdin-packs=follow is not in use and delta search is disabled
with --window=0 or --depth=0, neither purpose applies.  Skip the walk
in that case.

Skipping the walk alone does not eliminate all of its cost.  While
reading the listed packs, add_object_entry_from_pack() unconditionally
seeds the walk with every commit via add_pending_oid().  Even when the
walk will not run, this parses those commits and performs otherwise
unnecessary object lookups.  If the commits are not covered by a
commit-graph or multi-pack-index and the listed packs do not sort to
the front, those lookups can map every pack index in the object store
and exhaust the system's per-process limit on memory mappings.

Gate the seeding on the same condition as the walk.  On a repository
with about 35 million objects, packing 14,174 objects from 316 packs
drawn from a store of 1,427 packs gave:

  variant                        time     max RSS
  -----------------------------  -------  --------
  seed and walk (before)          176s    8.2 GiB
  skip walk, still seed          0.38s    154 MiB
  skip walk and seeding (after)  0.31s     66 MiB

In the worst case, skipping the seeding also reduced distinct pack
index mappings from:

  1427 mmaps -> 316 mmaps

The output pack was byte-identical in all three runs.

Assisted-by: Claude Opus 4.7
Signed-off-by: Elijah Newren <newren@gmail.com>
When `git pack-aggregate` rolls up small pushed packs with `pack-objects
--stdin-packs --window=0`, it tries to reuse existing on-disk deltas
rather than searching for new ones.  However, the same object may appear
as a base in some packs and as deltas in others.  When the base copy is
seen first, its delta twin is discarded, and the object is written out
as a base even though a perfectly reusable delta was sitting in a
sibling pack.  A subsequent geometric/full repack then has to
delta-search that object from scratch, losing the reuse the aggregate
could have preserved.  Measured on a ~37M-object repository, aggregating
7253 candidate packs turned 25,310 delta-capable objects into bases
purely from this first-seen selection.

Add a `--prefer-reused-deltas` option (only meaningful with
`--stdin-packs`) that prefers recording the delta representation of an
object over a base one, when possible.  Only the object header is
read to classify each copy, so the extra cost is small.  On the same
repository the option recovered 20,547 of the 25,310 lost deltas (81%),
and the resulting pack verifies cleanly.

Assisted-by: Claude Opus 4.8 & GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
--prefer-reused-deltas points an object's entry at a delta copy found in
another included pack when the copy first recorded was a plain base.  When
the object it would become a delta against is itself already recorded as a
delta against this same object, the switch creates a two-object delta
cycle, which a later break_delta_chains() pass then has to cut.

Cutting such a cycle keeps exactly one of the two deltas, and so does
simply not forming it, so declining the switch does not change how many
deltas survive for the pair.  It does avoid the wasted work of building a
cycle only to break it, and keeps the result from depending on the order
in which break_delta_chains() happens to cut.

Resolve the candidate delta's base cheaply (headers only) by exporting the
existing get_delta_base_oid() and wrapping it in a small
pack_entry_delta_base() helper, then skip the switch when that base is
already a delta against the object we are about to convert.  Whichever
member of the pair is reached first keeps its delta and the other stays a
base.

Assisted-by: Claude Opus 4.8
Signed-off-by: Elijah Newren <newren@gmail.com>
Repositories that have gone without maintenance can accumulate large
numbers of loose objects and small packfiles.  Unrelated Git commands
then pay the cost of scanning the resulting sprawl in `objects/` and
`objects/pack/`, even when those commands would otherwise be quick.

Introduce a new plumbing builtin, `git pack-aggregate`, as a cheap
cleanup pass.  With `--once`, it performs two steps:

  1. Bundle local loose objects into a new pack with `pack-objects
     --window=0 --mark-bad-deltas --delta-base-offset
     --no-write-bitmap-index`, then unlink the loose copies.

  2. Aggregate small local packs, excluding packs in the
     multi-pack-index and packs carrying `.keep`, `.promisor`,
     `.mtimes`, or `.bitmap` sidecars, then unlink the source packs.

Both steps copy each object's existing on-disk representation without
delta search or recompression.  The output carries a `.baddeltas`
marker so a later thorough repack knows to reconsider its intra-pack
deltas.  The loose-object pack from the first step is normally folded
into the output of the second.

This gives repositories with many loose objects or small packs a way
to return quickly to a healthier state.  A later `git repack` can then
perform the more expensive work needed to optimize deltas and pack
layout.

Make this a separate builtin rather than another `git repack` mode.  Its
contract is deliberately narrow and cheap, unlike repack's range of
optimization modes.  A later integration will run both processes at
once; distinct command names make their roles clear in process listings
and traces, while keeping the aggregator's looping and coordination
options out of repack's already broad option and configuration surface.

Use `pipe_command()` and run-command's child-cleanup machinery to avoid
I/O deadlocks and orphaned `pack-objects` processes.

Delete only the consumed pack's `.pack`, `.idx`, `.rev`, and
`.baddeltas` files after rechecking its protective sidecars.
`unlink_pack_path()` is not suitable here: its non-force mode protects
only `.keep` and otherwise also removes `.bitmap`, `.promisor`, and
`.mtimes` sidecars that cause aggregation to leave a pack alone.

Like `git repack`, `git pack-aggregate` takes no locks of its own.  Do
not run it concurrently with other pack-rewriting maintenance.

Assisted-by: Claude Opus 4.7 & GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
A pack-aggregate run rolls many small packs up into one aggregator
output and writes a sibling `.baddeltas` marker, since the new
pack reuses raw delta-chains from its inputs without any
cross-input delta search.  That marker tells future
`pack-objects` runs over the pack to treat its existing deltas as
stale and recompute new ones.

The aggregator deliberately does not install a `.keep` marker on
its output: we want the next opportunity to re-delta its contents
to roll up the pack like any other.  But a `git repack
--geometric=N` pass walks the packs sorted ascending by object
count and only rolls up packs below the geometric split; anything
already at the heavy end of the progression sits above the split
and is kept as-is.  Aggregator outputs are commonly sized to land
there, so a single aggregator pass can leave a `.baddeltas` pack
camped above the split for many subsequent geometric passes -- long
enough that the unrelated bad-delta marker is the only signal that
the pack's deltas are stale, and that signal gets ignored.

Teach `split_pack_geometry()` to do a post-pass demotion of any
`.baddeltas` packs from the kept region into the rollup region.
The demotion preserves the ascending sort order of the kept region
so that `get_preferred_pack()` continues to return the
largest local kept pack.  Demotion is unconditional: a
`.baddeltas` marker is an explicit "these deltas need redoing"
tag from a previous repack step, and the next geometric pass is
the cheapest place to honor it.

`.keep`-marked packs are still excluded from the rollup by the
existing `pack_kept_objects` / `remove_redundant_packs` paths, so
a user who really wants to pin a `.baddeltas` pack in place has
that escape hatch.

Promisor packs are intentionally not touched: the aggregator skips
them, and rolling a promisor pack up into a normal pack would
silently strip its "objects may live remotely" semantics.

Add two tests to t5336: one that confirms a `.baddeltas` pack
above the natural split is rolled up and its marker disappears
from the resulting pack, and a control test that confirms a
non-`.baddeltas` pack above the split is still preserved.

Assisted-by: Claude Opus 4.7 & GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
`git pack-aggregate` is meant to cheaply roll up small push packs and
loose objects that accumulate during a long maintenance pass, so that
unrelated operations do not pay readdir cost in a sprawling object
directory.  Its candidate selection, however, only filtered on MIDX
membership, protective sidecars, and `.idx` presence -- it had no notion
of pack size, so any eligible pack was rolled up no matter how large.

That is a poor fit for the intended use.  A large repository can carry
very large packs that are not in the multi-pack-index even when it is
being maintained as aggressively as possible; rolling those up rewrites
tens of millions of already-packed objects for little file-count
benefit.

Add a `--max-objects=<n>` cap (and matching `pack.aggregateMaxObjects`
config, default 100000, 0 to disable) that leaves any pack with more
than <n> objects untouched.  We gauge a pack's heft by object count
rather than byte size because the work we want to bound -- enumerating
objects and rebuilding the output index -- scales with object count.
A pack containing a few enormous blobs can therefore fall below the
cap despite being large in bytes, but such packs are uncommon in the
target workload and aggregation copies their existing representation
without delta search or recompression.

Estimate the object count from the size of the pack's `.idx` file,
which is linear in the number of objects for the v2 index format:

    size = 8 + 1024 + N*(rawsz + 8) + 2*rawsz   (+ 8 per large offset)

We already `lstat()` the `.idx` to confirm it exists, so reusing that
`st_size` is much faster than opening, mapping, and reading each
candidate index.  The tempting alternative, `p->num_objects`, is only
populated by `open_pack_index()`, which mmaps the index; on repositories
with an enormous number of packs, mapping every candidate merely to
apply this gate can also exhaust `vm.max_map_count`.

Ignoring the rare large-offset table makes the estimate slightly high,
which errs toward skipping a borderline pack; that is the safe direction
here.  Packs already in the MIDX remain excluded regardless of the cap,
so the new limit only affects packs outside it.

Assisted-by: Claude Opus 4.8 & GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
pack-aggregate currently feeds every eligible loose object to a single
pack-objects process.  That is acceptable for the normal case, but an
extremely neglected repository can have millions of loose objects.  Such a
repository remains burdened by the full loose-object fanout until that one
large pack is complete, and the pack-objects process must hold bookkeeping
for the entire backlog at once.

Add --max-loose-objects=<n> and pack.aggregateMaxLooseObjects to split the
loose-object backlog into bounded tranches.  Install each completed pack
and unlink its loose copies before starting the next tranche, allowing the
repository to improve incrementally and preserving completed work if the
process is interrupted.

Create a temporary marker in the object directory and use its filesystem
mtime as the cycle cutoff.  Rescans ignore objects that are not older than
that marker, so concurrent writers cannot keep a cycle running
indefinitely.  Using a marker on the same filesystem also avoids comparing
file mtimes against a potentially different host clock.

When more than one loose-object tranche is needed, leave all of those
output packs out of the pack-aggregation phase of the same cycle.  This
avoids immediately copying the entire recovered loose-object backlog a
second time.  A single loose-object output retains the existing behavior
and can be combined with other small packs immediately.

Default the limit to 100000.  On a synthetic repository containing ten
million small loose blobs, the following limits produced:

    limit       first pack     total time     peak RSS
    100000          12.4s         1520.0s         26MB
    500000          64.1s         1530.8s        115MB
    1000000        132.6s         1537.0s        226MB

The total time was effectively unchanged, while 100000 made the first
improvement visible much sooner and used substantially less memory.  A
value of 0 disables tranche splitting.

Assisted-by: GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
pack-aggregate folds all aggregatable packs into a single output pack.
On a normally-maintained repository that is fine, but repositories can
accumulate an extreme number of packs -- tens or even hundreds of
thousands -- before maintenance catches up.  Folding that many packs
into one output pack forces the pack-objects run to keep the .idx,
.pack, and .rev files of every input pack mmapped at once (three
mappings per pack), which can exceed the vm.max_map_count limit and
fail outright.

Add a --max-packs=<n> option (and a pack.aggregateMaxPacks config) that
caps how many packs are folded into each output pack.  When more than
<n> candidates are present, split them into several evenly-sized batches
and roll each batch up into its own output pack.  For example, 12000
packs with --max-packs=5000 are rolled up as three output packs of 4000
-- not 5000, 5000, and 2000 -- bounding the mmap count of any single
pack-objects run.

Split on whole-pack boundaries.  Because on-disk packs are always
self-contained (git never stores thin packs), a delta and its base stay
together in the same batch, so each batch can reuse the existing deltas
as-is without having to reason about how to segregate the objects within
a pack.

Default --max-packs to 10000; use a value of 0 to fold all aggregatable
packs into one output pack.

Assisted-by: Claude Opus 4.8 & GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
Repositories that have gone without maintenance can accumulate enough
loose objects and small packs to slow down every Git command that needs
to inspect the object store.  A thorough repack will eventually repair
that state, but it must first operate while the repository is still at
its least healthy.

Teach `git repack` to optionally run `git pack-aggregate --once` before
it inspects the existing packs and loose objects.  This cheap preliminary
pass quickly reduces the number of files the main repack must contend
with, leaving the thorough repack to optimize deltas and pack layout.

Add `--aggregate-once` and `--no-aggregate-once`, together with the
matching `repack.aggregateOnce` configuration variable.  Keep the
behavior disabled by default.

Run the aggregation pass before repack snapshots refs for a MIDX bitmap,
reads the existing MIDX, or computes geometric pack selection.  If the
aggregation fails, abort before starting the main pack-objects process.

Since aggregation mutates the repository, validate incompatible
`--geometric` and `-a`/`-A` options, and `--filter-to` without
`--filter`, before invoking it.  Invalid commands therefore remain
side-effect free.

Like `git repack` and `git pack-aggregate` themselves, this integration
takes no locks.  Serialization with other pack-rewriting maintenance
remains the caller's responsibility.

Signed-off-by: Elijah Newren <newren@gmail.com>
If a process desires to work on packs other than what pack-objects may
preserve or consume, there is currently no way to identify a safe,
disjoint set.  Callers cannot simply enumerate packs before calling
pack-objects, because that leaves a race between their enumeration and
pack-objects' enumeration; large repositories push often enough to make
losing that race inevitable.

Address this with `--emit-input-packs`, which writes a conservative
snapshot of every local pack visible after pack-objects prepares its
packing data.  Emitting all local packs rather than only those selected
for rewriting is intentional.  In particular, a geometric repack leaves
the upper portion of its pack geometry untouched but may include those
packs in its replacement MIDX.  A concurrent pack aggregator must
therefore leave those packs alone too, or the new MIDX could reference a
pack that the aggregator retired.

Similarly, add `--emit-input-loose` to write the local loose objects
visible at the same point.  An external caller could enumerate loose
objects itself: losing that race would merely cause an object to appear
temporarily in multiple packs, since deleting one loose copy does not
prevent pack-objects from writing another packed copy.  Emitting the
snapshot from pack-objects nevertheless reduces that duplication.

Both options write to `<file>.tmp` first and then rename, so the file
appears atomically and a reader either sees the previous contents (if
any) or the complete new contents, never a partial write.

Since these options exist only to support the handshake between repack
and the upcoming pack-aggregate builtin, and are not meant for end
users, they are currently undocumented.

Assisted-by: Claude Opus 4.7 & GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
The `--once` mode provides a quick manual cleanup pass, but a
long-running repack leaves time for new loose objects and small push
packs to accumulate while it works.  Repeated aggregation can keep
that new material under control without interfering with the larger
repack, provided the two processes operate on disjoint inputs.

Add a `--loop` mode that runs aggregation passes repeatedly, sleeping
`--interval` seconds between them.  Add exclusion files for packs and
loose objects so a concurrent `pack-objects` can declare the inputs it
owns; the aggregator skips those inputs on every pass.

Add `--parent-pipe-fd` so a parent process can keep the aggregator
alive by holding a pipe open.  Poll the pipe during the interval so
the aggregator exits promptly when its parent goes away.

Handle termination signals by recording a stop request so the current
cycle can unwind and the loop can exit at a safe boundary.  An in-flight
`pack-objects` is already marked `clean_on_exit`, so run-command's
standard signal cleanup forwards the signal to it before chaining to
the loop's handler.  This stops the child promptly while allowing the
loop to unwind in an orderly way.

Assisted-by: Claude Opus 4.7/4.8 & GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
On a busy server hosting a large repository, `git repack` routinely
takes long enough for thousands of packs and tens of thousands of loose
objects to accumulate before it finishes.  Every subsequent Git
operation pays the price: each pack lookup walks the longer list, and
each loose-object miss has to consult more sources.

Wire `git repack` to optionally drive `git pack-aggregate` as a
background subprocess, controlled by `repack.aggregateLoop` or the
equivalent `--aggregate-loop` and `--no-aggregate-loop` command-line
options.  Keep the behavior disabled by default and independent of the
one-shot aggregation pass.

When enabled:

  * Create a small tempdir under `objects/` for two exclude files.
  * Pass `--emit-input-packs=<tmp>/packs --emit-input-loose=<tmp>/loose`
    to the main pack-objects so it records the local packs and loose
    objects visible when it prepares its inputs.
  * Launch `git pack-aggregate --loop --exclude-pack-file=<tmp>/packs
    --exclude-loose-file=<tmp>/loose` once the emit files exist.
  * Drop a `.keep` marker on every pack written by the main repacking
    work, so the aggregator does not roll it up.

Together these protections cover the parent's MIDX transition.
pack-aggregate independently skips packs named by the current MIDX; the
emitted pack list protects the parent's existing-pack snapshot, while
the temporary `.keep` files protect its new packs before their indexes
become visible and remain through the MIDX write.  The explicit MIDX
include list is drawn from those protected sets, so the aggregator
cannot retire a pack that the parent is about to reference.

Mark unrelated pipe ends close-on-exec before launching pack-aggregate.
Without these flags, an inherited geometric pack-objects input writer
could prevent it from seeing EOF and deadlock the repack, while an
inherited parent-pipe writer could prevent pack-aggregate from detecting
that its parent exited.  If either flag cannot be set, skip
pack-aggregate and clean up any newly-created pipe.

pack-aggregate keeps running through the cruft pack-objects subprocess
and through `write_midx_included_packs()`; it is SIGTERM'd only when it
comes time to delete redundant packs and objects.

Finally, unlink the recorded `.keep` markers after pack-aggregate has
been shut down.

`git repack` itself takes no locks, and neither does the aggregator we
spawn; serialization across maintenance runs is the caller's
responsibility (typically `git gc` or a server-side maintenance driver).
This matches the prior behavior of `git repack`.

Assisted-by: Claude Opus 4.7 & GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
Both `git repack` and `git pack-aggregate` stage a new pack as
".tmp-<pid>-pack-<hash>.*" and rename it to "pack-<hash>.*" only once it
is fully written (the .idx last).  In combined mode the two run
concurrently, so one process's in-flight staging files are visible to
the other.  The object-store scan treats any "*.idx" as a pack
regardless of prefix, while the aggregator's exclude list and ".keep"
markers key on the final "pack-<hash>" name; nothing then stops a temp
pack from being picked up, and pack-aggregate deletes it out from under
its owner while geometric repack feeds it to pack-objects only for it to
vanish mid-run.

Require the canonical "pack-<hash>" basename where packs are selected --
in collect_pack_candidates() and in the geometry roll-up -- rather than
in the object-store scan, since `git repack -ad` legitimately enumerates
stale ".tmp-*" packs in order to delete them.

Signed-off-by: Elijah Newren <newren@gmail.com>
A pack-aggregate cycle that finds nothing to do leaves no on-disk
artifact, so process lifetime and repository state are insufficient to
count cleanup cycles.

Add trace2 regions around each pack-aggregate cycle and repack's
redundant-pack removal so trace consumers can account for these
operations, including no-op aggregation cycles.

Signed-off-by: Elijah Newren <newren@gmail.com>
MIDX verification closes packs between object groups and later reopens
them by name. A concurrent repack can remove one in that interval,
causing verification to fail on an otherwise benign race.

When the referenced packs fit within a conservative fd budget, open
them before verifying offsets and keep them open through the walk. Most
geometrically maintained MIDXes have only O(log N) packs; larger MIDX
chains retain the close-as-we-go path and its race-aware diagnostic.

Do not use do_not_close for this: find_lru_pack() ignores it, so
close_one_pack() may still reclaim the fd under pressure.

Assisted-by: Claude Opus 4.8
Signed-off-by: Elijah Newren <newren@gmail.com>
@newren
newren force-pushed the maintenance-inception branch from 24bdb43 to 07ca9ca Compare September 9, 2026 06:45
@gitgitgadget

gitgitgadget Bot commented Sep 9, 2026

Copy link
Copy Markdown

There are merge commits in this Pull Request:

e07ce1447ba5ba087c7daa5fa3ad7c4c8e7268b2
275ee320dd359e669f34bcc476d9316a772af331
07ca9cab4b9d8f3040070fe34538a535efab3990

Please rebase the branch and force-push.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant