Skip to content

pack-aggregate: cheaply consolidate loose objects and small packs - #2222

Open
newren wants to merge 18 commits into
gitgitgadget:masterfrom
newren:en/pack-aggregate
Open

newren wants to merge 18 commits into
gitgitgadget:masterfrom
newren:en/pack-aggregate

Conversation

@newren

@newren newren commented Sep 9, 2026

Copy link
Copy Markdown

[This should be split into about 3 series before sending to the mailing list...]

Busy or neglected repositories can accumulate enough loose objects and
small push packs to slow every operation that scans the object store. A
full repack eventually repairs that state, but it must operate while the
repository is at its least healthy and can run long enough for more
loose objects and packs to accumulate behind it.

This series introduces the git pack-aggregate plumbing command. It
quickly bundles loose objects and rolls up small packs by copying their
existing representations, without delta search or recompression. Its
output is marked with a new .baddeltas sidecar so a later thorough
repack knows to reconsider the inherited delta layout.

Aggregation can run once as a preliminary cleanup or loop alongside a
long-running repack. The concurrent mode coordinates the two processes
with pack/loose-object exclusion snapshots and temporary .keep
markers, preventing either process from consuming inputs owned by the
other. Limits on loose objects, input packs, and pack object counts keep
individual aggregation steps bounded and allow severely degraded
repositories to improve incrementally.

The series also teaches geometric repacks to roll up .baddeltas
outputs, adds pack-objects plumbing to preserve reusable deltas and
publish input snapshots, excludes protected and in-flight packs, and
adds trace2 regions for cleanup cycles. All repack integration is
opt-in.

@newren
newren force-pushed the en/pack-aggregate branch 3 times, most recently from b1b0dcf to d11f6d7 Compare September 13, 2026 06:05
When considering delta compression, try_delta() skips pairs of
non-delta objects from the same pack, assuming an earlier delta search
already rejected them.  Packs written by fast-import, bulk check-in,
or pack-objects --window=0 need not satisfy that assumption, as Jeff
King discussed:

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

Recognize a .baddeltas sidecar to disable this shortcut for marked
packs.  Existing deltas remain reusable.

Assisted-by: Claude Opus 4.7
Signed-off-by: Elijah Newren <newren@gmail.com>
index-pack creates .keep and .promisor markers without overwriting
existing files.  Extract this writer into pack-write.c so other pack
producers can reuse it, leaving filename selection and output reporting
in index-pack.

Signed-off-by: Elijah Newren <newren@gmail.com>
Packs written without a full delta search need a way to request
reconsideration during later repacks.  Add --mark-bad-deltas to
write an empty .baddeltas marker beside each output pack.  This
option is incompatible with --stdout.

Publish the marker before the index so readers discover the pack
with its marker already in place.

Assisted-by: Claude Opus 4.7
Signed-off-by: Elijah Newren <newren@gmail.com>
With --stdin-packs, the revision walk supplies namehash hints for
delta selection and, with --stdin-packs=follow, finds reachable
objects outside the listed packs.

Skip the walk when --stdin-packs=follow is not in use and delta search
is disabled by --window=0 or --depth=0.

Skipping the walk alone does not eliminate all the unnecessary work:
seeding via add_pending_oid() still parses commits and looks up
objects.  These lookups can map every pack index in the object store,
which in some cases can even exhaust the per-process mapping limit.
Skip the commit seeding as well.

On a repository with about 35 million objects, packing 14,174 objects
from 316 packs in 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 seeding also reduced distinct pack-index
mappings from 1,427 to 316.  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 combining packs with --stdin-packs --window=0, pack-objects
reuses existing deltas without searching for new ones.  However, if
an object appears in multiple included packs, it uses the first copy
it finds, even if that copy stores the object in full and another
pack stores it as a delta.

Add --prefer-reused-deltas to prefer a copy stored as a delta over
one stored in full.  It has no effect with --no-reuse-delta.

On a roughly 37-million-object repository, first-copy selection lost
25,310 existing deltas while combining 7,253 input packs.  Adding
--prefer-reused-deltas retained 20,547 of them (81%).

Assisted-by: Claude Opus 4.8 & GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
When combining packs, one pack can store A as a delta against B
while another stores B as a delta against A.  --prefer-reused-deltas
can select both representations, creating a two-object delta cycle.

break_delta_chains() repairs this by dropping one delta.  However,
the object whose delta is dropped then needs to be recompressed,
rather than reusing a copy stored in full.

Reject a candidate delta when its base is already selected as a
delta against the same object.  This preserves the selected full
copy and avoids the recompression, while still retaining one delta
for the pair.

Limit this check to two-object cycles so each candidate requires
only inspecting its base's selected representation, not walking a
delta chain.  Longer cycles remain handled by break_delta_chains().
This is a bounded optimization of delta selection, not a replacement
for the general cycle-breaking pass.

In a synthetic 1,000-pack workload with 50 reciprocal pairs of 1 MiB
objects, this avoided 50 MiB of recompression and reduced median
elapsed time from 1.86s to 0.44s, without changing the delta count or
output size.  A separate workload with 10,000 candidate deltas but no
cycles showed no measurable slowdown.  These timings used a warm disk
cache.

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 new packs with `pack-objects
     --window=0 --mark-bad-deltas --delta-base-offset
     --no-write-bitmap-index`, then unlink the loose copies.

  2. Aggregate 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 skip delta search and reuse packed representations where
possible; loose objects still need compression.  Either step can
produce multiple packs under `pack.packSizeLimit`.  Each output carries
a `.baddeltas` marker so a later thorough repack knows to reconsider its
intra-pack deltas.

If packing loose objects produces just one pack, allow it to participate
in the subsequent pack aggregation.  If it produces multiple packs,
likely due to `pack.packSizeLimit`, skip those new packs instead.

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 .baddeltas pack can sit above the geometric split indefinitely,
so pack-objects never gets a chance to reconsider its delta layout.
Include marked packs in the rollup even when their size would
otherwise keep them out.

If their inclusion breaks the estimated progression,
include one more eligible pack, without consuming smaller candidates
unnecessarily.  Keep the retained region sorted for
`pack_geometry_preferred_pack()`.

`.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 tests for forced demotion, geometric balancing, and preservation
of unaffected packs.

Assisted-by: Claude Opus 4.7 & GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
`git pack-aggregate` can select packs with millions of objects outside
the multi-pack-index, even in otherwise well-maintained repositories.
Enumerating those objects and rebuilding their indexes costs work for
little reduction in pack count.

Add a `--max-objects=<n>` cap (and matching `pack.aggregateMaxObjects`
config, default 100000, 0 to disable) to bound that per-object work.
Leave packs above the cap untouched.

Estimate the object count from the size of the pack's `.idx` file,
reusing the `lstat()` already needed to confirm it exists.  This avoids
opening and mapping every candidate index just to read its object count,
saving work and avoiding exhaustion of `vm.max_map_count` when there are
many packs.

Assisted-by: Claude Opus 4.8 & GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
With pack.packSizeLimit=1m, aggregating 700-KiB single-blob packs just
rewrites them without reducing their number.

Skip packs larger than half the effective output limit, using their
already-known sizes.  Add --max-input-pack-size and
pack.aggregateMaxInputPackSize to allow a lower threshold, or an
independent byte cap when output size is unlimited.

Assisted-by: GPT-6 Astra
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 large number of loose-object
files until that invocation finishes, and pack-objects must hold
bookkeeping for the entire backlog at once.

Add --max-loose-objects=<n> and pack.aggregateMaxLooseObjects to limit
how many loose objects each pack-objects invocation processes.  Each
batch can produce multiple packs under pack.packSizeLimit.  Install all
its output packs before unlinking its loose copies and starting the
next batch, 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 loose-object rollup produces multiple packs, leave all of them
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 pack produced from loose objects 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 processes all eligible loose objects in one batch.

Assisted-by: GPT-5.6 Sol
Signed-off-by: Elijah Newren <newren@gmail.com>
pack-aggregate feeds all aggregatable packs to a single pack-objects
invocation.  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.  Processing that
many packs at once increases the memory needed for pack data and indexes
and can exhaust the per-process limit on memory mappings.

Add a --max-packs=<n> option (and a pack.aggregateMaxPacks config) that
caps the number of input packs per pack-objects invocation.  When more
than <n> candidates are present, split them into approximately balanced
batches by pack count, each handled by a separate invocation.  The final
batch may be smaller.  For example, 12000 packs with --max-packs=5000
are processed as three batches of 4000 -- not 5000, 5000, and 2000.

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 input batch.  Each invocation can reuse existing
deltas where possible, though pack.packSizeLimit can split its output
into multiple packs and require expanding deltas whose bases land in
another output pack.

Default --max-packs to 10000; use a value of 0 to process all
aggregatable packs in one batch.

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>
Let pack-objects publish the local packs and loose objects visible
before object enumeration, using `--emit-input-packs` and
`--emit-input-loose`.  A concurrent maintenance process can exclude
these inputs without racing to enumerate them before pack-objects.

Include all local packs, not just those selected for rewriting.  A
geometric repack may include retained packs in its replacement MIDX;
a concurrent aggregator must leave them alone too, or the MIDX could
reference a pack it retired.

The loose-object snapshot reduces duplicate packing: deleting a loose
copy does not prevent pack-objects from writing another packed copy.

Write each file through `<file>.tmp` and rename it into place, so
readers never see a partial list.  These options support the planned
background repack integration and remain 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>
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).  Before running these commands
concurrently, prevent them from selecting each other's staging files.

The object-store scan treats any "*.idx" as a pack regardless of
prefix, while exclusion lists and ".keep" markers use the final
"pack-<hash>" name.  Without a name check, pack-aggregate can delete a
temporary pack owned by another process, or geometric repack can feed
it to pack-objects just before its owner renames it.

Require a canonical "pack-<hash>.pack" basename where packs are
selected, using a shared helper in collect_pack_candidates() and the
geometry rollup.  Keep the object-store scan unchanged: `git repack -ad`
legitimately enumerates stale ".tmp-*" packs in order to delete them.

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.

Have the main pack-objects write its input snapshots to a temporary
directory under `objects/`.  Once both files exist, launch aggregation
with those exclusion lists.  Protect new repack outputs with temporary
`.keep` files before their indexes become visible.

The aggregator already skips packs in the current MIDX.  The exclusions
and temporary markers also protect every pack in the replacement MIDX's
explicit include list, so aggregation cannot retire a pack the repack is
about to reference.

Keep the aggregator running through cruft packing, pack installation,
and the MIDX write.  Stop it before redundant-pack cleanup, then remove
only the `.keep` files this repack created.  The aggregator detects an
unexpected repack exit through a parent pipe.  Mark unrelated pipe ends
close-on-exec so inherited writers cannot suppress EOF and leave either
child waiting.

`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>
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>
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