ref:07c6e3eeffbb204ca0cc8f4b7e66c878b2f6ec25

feat(maintenance): server-side repack — the git gc equivalent

`receive_pack` explodes every pushed packfile into individual loose objects, and nothing ever put them back together. Production is the predictable result: **236,756 loose objects across 39 repositories, 4 packs in total**. The largest repository is 1.1 GB spread across 134,921 files. That is a latency problem before it is a disk problem. `ObjectResolver` reads packs first and falls back to loose, so with everything loose every object read is a separate `File.read` — and on the S3 backend, a separate HTTP GET. Adds `ExGitObjectstore.Maintenance`: * `repack/2` — walk refs, pack `loose ∩ reachable`, verify, delete the loose copies. Incremental (`git repack` without `-a`): objects already in a pack stay put. * `needs_repack?/2` — the `gc --auto` check, defaulting to git's own thresholds (`gc.auto` 6700 loose, `gc.autoPackLimit` 50 packs). Lists directory entries only, so it is cheap enough to call after a push. * `stats/1` — loose and pack counts. Also adds `delete_object/3` to the `Storage` behaviour, implemented for Filesystem, S3 and Memory. There was no way to remove a loose object at all before this — objects could be written and never reclaimed, which is half of why the problem was unbounded. ## Three properties, deliberately **Ordering.** A reader resolves packs first, then loose, so the only safe order is write pack -> verify every object reads back -> delete loose. If the process dies mid-repack the worst case is loose objects that are also packed: wasted disk, no data loss, and the next run cleans up. `verify_pack/3` clears the per-process pack caches first, otherwise it would re-read the pre-repack view and verify nothing. **Unreachable objects are kept, never pruned.** A push in flight can have written objects that no ref points at yet; git's own prune grace period is two weeks. They are counted and reported so a future pruning pass has a number to work from. Deleting them here would be the one mistake in this module that loses data permanently. **Bounded memory.** Repacking a 1.1 GB repository must not need 1.1 GB of heap — the production host has 3 GB total. Object contents are never accumulated across a batch boundary; `:max_batch_bytes` (default 64 MB) caps one packfile pass. What does span the run is one SHA per object. ## Measured on real data A production-shaped all-loose repository rebuilt from the real fangorn/hephaestus object graph (17,602 objects, 272 MB): before 17,602 loose, 0 packs after 0 loose, 10 packs 25.0 s peak memory +67 MB over baseline verification 17,602 objects byte-identical to source, 0 mismatched second run packed=0 deleted=0 (idempotent) read pass, all loose 3895.0 ms 221 us/object read pass, all packed 1585.7 ms 90 us/object 2.5x The 2.5x is a floor: that was a warm page cache on a local SSD. Production reads cold DigitalOcean block storage on 2 vCPUs, where a per-object syscall costs relatively more. Ten packs for one repository is more than ideal — it falls out of the 64 MB batch cap, which exists because `put_pack/5` takes the pack as a binary. Fewer, larger packs need a streaming pack write; `needs_repack?/2` counts packs partly so that this stays visible. Filed as follow-up. Tests cover the properties rather than the counts: objects readable and byte-identical after repack, unreachable objects surviving, reachability through tag refs and non-default branches, no loss across batch boundaries, idempotence, dry-run, empty repo, and `delete_object/3` idempotence. Full suite 1038 passed, `mix format --check-formatted` clean, `mix dialyzer` 0 errors. Refs fangorn/anvil#324
SHA: 07c6e3eeffbb204ca0cc8f4b7e66c878b2f6ec25
Author: CI <ci@fangorn.io>
Date: 2026-07-29 19:45
Parents: 0bf85c6
8 files changed +664 -0
Type
lib/ex_git_objectstore/maintenance.ex +359 −0
@@ -1,0 +1,359 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Maintenance do
@moduledoc """
Server-side repack — the `git gc` equivalent for objectstore repositories.
`receive_pack` explodes every pushed packfile into individual loose objects,
and nothing ever put them back together, so repositories accumulate loose
objects without bound. A production repository observed while investigating
this held **236,756 loose objects across 39 repositories with 4 packs total**;
the largest single repository was 1.1 GB spread over 134,921 files.
That is a latency problem before it is a disk problem. `ObjectResolver` reads
packs first and falls back to loose, so with everything loose every read is a
separate `File.read` on the filesystem backend — and a separate HTTP GET on
the S3 backend.
## What `repack/2` does
1. Walks every ref to collect the set of reachable object SHAs.
2. Lists the loose objects.
3. Packs `loose ∩ reachable`, in batches, into new packfiles.
4. Verifies each object reads back out of the pack.
5. Only then deletes the loose copies.
Objects that are already in a pack are left where they are — this is an
incremental repack (`git repack` without `-a`), not a full rewrite.
## What it deliberately does not do
**It never deletes an unreachable object.** Pruning needs a grace period,
because a push in flight can have written objects that no ref points at yet;
git's default is two weeks. Unreachable loose objects are counted and
reported so a future pruning pass has a number to work from, and otherwise
left alone. Deleting them here would be the one mistake in this module that
loses data permanently.
## Ordering is a correctness property, not a style choice
A reader resolves packs first, then loose. So the pack must exist and be
readable *before* the loose copy goes away:
write pack -> verify readable -> delete loose
Any other order leaves a window where an object exists nowhere. If the
process dies mid-repack, the worst case is loose objects that are also in a
pack — wasted disk, no data loss — and the next run cleans them up.
## Memory
Repacking a 1.1 GB repository must not need 1.1 GB of heap; the production
host has 3 GB total. Object *contents* are therefore never accumulated across
a batch boundary: `:max_batch_bytes` caps how much raw object content one
packfile pass holds (default 64 MB). What does span the whole run is one SHA
string per object — for 236,756 objects that is tens of megabytes, which is
the price of knowing what is reachable.
"""
require Logger
alias ExGitObjectstore.Object.{Blob, Commit, Tag, Tree}
alias ExGitObjectstore.{ObjectResolver, Repo}
alias ExGitObjectstore.Pack.Writer
@default_max_batch_bytes 64 * 1024 * 1024
@default_max_batch_objects 50_000
# `git gc --auto` fires at 6700 loose objects. The same number is used here
# for the same reason: it is large enough that ordinary pushes don't trigger
# constant repacking, small enough that lookups stay fast.
@default_loose_threshold 6_700
# Many small packs are their own problem — every lookup miss scans another
# index. git's `gc.autoPackLimit` default is 50.
@default_pack_threshold 50
@type stats :: %{
loose_before: non_neg_integer(),
reachable: non_neg_integer(),
packed: non_neg_integer(),
deleted: non_neg_integer(),
unreachable_kept: non_neg_integer(),
packs_written: [String.t()]
}
@doc """
Counts loose objects and packs without changing anything.
"""
@spec stats(Repo.t()) :: {:ok, %{loose: non_neg_integer(), packs: non_neg_integer()}}
def stats(%Repo{} = repo) do
{:ok, %{loose: length(list_loose(repo)), packs: length(list_packs(repo))}}
end
@doc """
Whether this repository is worth repacking right now — the `git gc --auto`
check.
True when loose objects exceed `:loose_threshold` (default #{@default_loose_threshold},
matching git's `gc.auto`) or packs exceed `:pack_threshold` (default
#{@default_pack_threshold}, matching git's `gc.autoPackLimit`).
Cheap enough to call after a push: it lists directory entries, it does not
read objects.
"""
@spec needs_repack?(Repo.t(), keyword()) :: boolean()
def needs_repack?(%Repo{} = repo, opts \\ []) do
loose_threshold = Keyword.get(opts, :loose_threshold, @default_loose_threshold)
pack_threshold = Keyword.get(opts, :pack_threshold, @default_pack_threshold)
length(list_loose(repo)) > loose_threshold or
length(list_packs(repo)) > pack_threshold
end
@doc """
Pack loose reachable objects and remove the loose copies.
## Options
* `:max_batch_bytes` — cap on raw object content held per packfile pass
(default #{@default_max_batch_bytes}). Bounds peak memory.
* `:max_batch_objects` — cap on objects per packfile pass
(default #{@default_max_batch_objects}).
* `:dry_run` — compute and report, write nothing (default `false`).
Returns `{:ok, stats}`. Safe to run repeatedly; a second run over an
already-packed repository packs nothing.
"""
@spec repack(Repo.t(), keyword()) :: {:ok, stats()} | {:error, term()}
def repack(%Repo{} = repo, opts \\ []) do
dry_run? = Keyword.get(opts, :dry_run, false)
loose = repo |> list_loose() |> Enum.uniq()
with {:ok, reachable} <- reachable_shas(repo) do
to_pack = loose |> Enum.filter(&Map.has_key?(reachable, &1)) |> Enum.sort()
unreachable_kept = length(loose) - length(to_pack)
base = %{
loose_before: length(loose),
reachable: map_size(reachable),
packed: 0,
deleted: 0,
unreachable_kept: unreachable_kept,
packs_written: []
}
cond do
to_pack == [] ->
{:ok, base}
dry_run? ->
{:ok, %{base | packed: length(to_pack)}}
true ->
run_batches(repo, to_pack, base, opts)
end
end
end
# ── Batched packing ──────────────────────────────────────────────────
defp run_batches(repo, to_pack, stats, opts) do
max_bytes = Keyword.get(opts, :max_batch_bytes, @default_max_batch_bytes)
max_objects = Keyword.get(opts, :max_batch_objects, @default_max_batch_objects)
to_pack
|> batch_by_size(repo, max_bytes, max_objects)
|> Enum.reduce_while({:ok, stats}, fn batch, {:ok, acc} ->
case pack_batch(repo, batch) do
{:ok, pack_sha, entries} ->
deleted = delete_packed(repo, entries)
{:cont,
{:ok,
%{
acc
| packed: acc.packed + length(entries),
deleted: acc.deleted + deleted,
packs_written: acc.packs_written ++ [pack_sha]
}}}
{:error, _} = err ->
{:halt, err}
end
end)
end
# Groups SHAs into batches whose combined raw content stays under the byte
# cap. Sizes come from the objects themselves, so this reads each object
# once here and once when packing — deliberate: holding every object's bytes
# to avoid the second read is exactly the memory blow-up being avoided.
defp batch_by_size(shas, repo, max_bytes, max_objects) do
shas
|> Enum.chunk_while(
{[], 0, 0},
fn sha, {batch, bytes, count} ->
size = object_size(repo, sha)
if batch != [] and (bytes + size > max_bytes or count + 1 > max_objects) do
{:cont, Enum.reverse(batch), {[sha], size, 1}}
else
{:cont, {[sha | batch], bytes + size, count + 1}}
end
end,
fn
{[], _, _} -> {:cont, []}
{batch, _, _} -> {:cont, Enum.reverse(batch), {[], 0, 0}}
end
)
|> Enum.reject(&(&1 == []))
end
defp object_size(repo, sha) do
case ObjectResolver.read(repo, sha) do
{:ok, %Blob{content: c}} -> byte_size(c)
{:ok, _other} -> 4_096
_ -> 0
end
end
defp pack_batch(repo, shas) do
entries =
shas
|> Enum.map(&pack_entry(repo, &1))
|> Enum.reject(&is_nil/1)
if entries == [] do
{:ok, nil, []}
else
{pack_data, idx_data, pack_sha} = Writer.generate_with_index(entries)
case Repo.storage_call(repo, :put_pack, [pack_sha, pack_data, idx_data]) do
:ok -> verify_pack(repo, pack_sha, entries)
{:error, _} = err -> err
end
end
end
defp pack_entry(repo, sha) do
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{} = c} -> {:commit, Commit.encode_content(c), sha}
{:ok, %Tree{} = t} -> {:tree, Tree.encode_content(t), sha}
{:ok, %Blob{content: content}} -> {:blob, content, sha}
{:ok, %Tag{} = t} -> {:tag, Tag.encode_content(t), sha}
_ -> nil
end
end
# Every object must be readable from the new pack before any loose copy is
# removed. The per-process pack caches in `ObjectResolver` are cleared first,
# otherwise this would re-read the pre-repack view and verify nothing.
defp verify_pack(repo, pack_sha, entries) do
ObjectResolver.clear_pack_cache()
missing =
Enum.reject(entries, fn {_type, _content, sha} ->
match?({:ok, _}, ObjectResolver.read(repo, sha))
end)
if missing == [] do
{:ok, pack_sha, entries}
else
shas = Enum.map(missing, fn {_, _, sha} -> sha end)
Logger.error(
"Maintenance.repack: pack #{pack_sha} written but #{length(shas)} objects " <>
"did not read back; keeping all loose copies. First: #{inspect(Enum.take(shas, 5))}"
)
{:error, {:verify_failed, shas}}
end
end
defp delete_packed(repo, entries) do
Enum.count(entries, fn {_type, _content, sha} ->
Repo.storage_call(repo, :delete_object, [sha]) == :ok
end)
end
# ── Reachability ─────────────────────────────────────────────────────
# Iterative worklist, not recursion: a deep commit chain would otherwise be
# bounded by stack rather than by the visited set. Only SHAs are retained.
# A plain map, not a MapSet: the set is only ever probed by key, and a map
# keeps dialyzer's opaque-type checking out of a hot recursive call.
@spec reachable_shas(Repo.t()) :: {:ok, %{optional(String.t()) => true}}
defp reachable_shas(repo) do
roots = ref_targets(repo)
{:ok, walk(repo, roots, %{})}
end
@spec walk(Repo.t(), [String.t()], %{optional(String.t()) => true}) ::
%{optional(String.t()) => true}
defp walk(_repo, [], visited), do: visited
defp walk(repo, [sha | rest], visited) do
if Map.has_key?(visited, sha) do
walk(repo, rest, visited)
else
walk(repo, children(repo, sha) ++ rest, Map.put(visited, sha, true))
end
end
defp children(repo, sha) do
case ObjectResolver.read(repo, sha) do
{:ok, %Commit{tree: tree, parents: parents}} -> [tree | parents]
{:ok, %Tree{entries: entries}} -> Enum.map(entries, & &1.sha)
{:ok, %Tag{object: object}} -> [object]
_ -> []
end
end
defp ref_targets(repo) do
case Repo.storage_call(repo, :list_refs, ["refs/"]) do
{:ok, refs} ->
refs
|> Enum.map(fn
{_name, sha} -> sha
sha when is_binary(sha) -> sha
end)
|> Enum.filter(&valid_sha?/1)
|> Enum.uniq()
_ ->
[]
end
end
defp valid_sha?(sha) when is_binary(sha), do: byte_size(sha) == 40
defp valid_sha?(_), do: false
# ── Storage listings ─────────────────────────────────────────────────
defp list_loose(repo) do
case Repo.storage_call(repo, :list_objects, []) do
{:ok, shas} -> shas
_ -> []
end
end
defp list_packs(repo) do
case Repo.storage_call(repo, :list_packs, []) do
{:ok, packs} -> packs
_ -> []
end
end
end
lib/ex_git_objectstore/storage.ex +12 −0
@@ -32,6 +32,18 @@
@callback object_exists?(config, prefix, sha) :: boolean()
@callback list_objects(config, prefix) :: {:ok, [String.t()]} | {:error, term()}
@doc """
Remove a loose object.
Only ever called for an object whose bytes have already been written into a
packfile and read back successfully — see `ExGitObjectstore.Maintenance`.
Deleting a loose object that is not in a pack destroys it.
Returns `:ok` when the object is gone, including when it was already absent,
so a partially-completed repack is safe to re-run.
"""
@callback delete_object(config, prefix, sha) :: :ok | {:error, term()}
# -- Pack operations --
@callback list_packs(config, prefix) :: {:ok, [String.t()]} | {:error, term()}
lib/ex_git_objectstore/storage/filesystem.ex +11 −0
@@ -59,5 +59,16 @@
end
@impl true
def delete_object(config, prefix, sha) do
case File.rm(object_path(config, prefix, sha)) do
:ok -> :ok
# Already gone: a previous repack pass deleted it, or a concurrent one
# got there first. Either way the postcondition holds.
{:error, :enoent} -> :ok
{:error, reason} -> {:error, reason}
end
end
@impl true
def list_objects(config, prefix) do
objects_dir = safe_path(config.root, Path.join(prefix, "objects"))
lib/ex_git_objectstore/storage/memory.ex +9 −0
@@ -55,6 +55,15 @@
end
@impl true
def delete_object(%{pid: pid}, prefix, sha) do
Agent.update(pid, fn state ->
%{state | objects: Map.delete(state.objects, key(prefix, sha))}
end)
:ok
end
@impl true
def object_exists?(%{pid: pid}, prefix, sha) do
Agent.get(pid, fn state ->
Map.has_key?(state.objects, key(prefix, sha))
lib/ex_git_objectstore/storage/s3.ex +7 −0
@@ -110,5 +110,12 @@
end
@impl true
def delete_object(config, prefix, sha) do
# S3 DELETE is idempotent — removing an absent key succeeds — which is the
# semantics the callback wants for a re-run repack.
s3_delete(config, object_key(prefix, sha))
end
@impl true
def list_objects(config, prefix) do
objects_prefix = "#{prefix}/objects/"
test/ex_git_objectstore/maintenance_test.exs +257 −0
@@ -1,0 +1,257 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.MaintenanceTest do
@moduledoc """
`Maintenance.repack/2` moves loose objects into packfiles and deletes the
loose copies. The thing that must never happen is that an object becomes
unreadable, so most of these tests assert readability *after* a repack
rather than asserting on counts.
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.Maintenance
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.{ObjectResolver, Repo}
setup context do
tmp = Path.join(System.tmp_dir!(), "exgo-maint-#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(Path.join([tmp, "repos"]))
on_exit(fn -> File.rm_rf(tmp) end)
id = "maint-#{:erlang.unique_integer([:positive])}"
repo = Repo.new(id, storage: {ExGitObjectstore.Storage.Filesystem, %{root: tmp}})
:ok = ExGitObjectstore.init(repo)
%{repo: repo, tmp: tmp, ctx: context}
end
# Builds `n` commits on `main`, each adding one file. Returns every object
# SHA that was written, so tests can assert all of them survive.
defp build_history(repo, n) do
Enum.reduce(1..n, {nil, []}, fn i, {parent, shas} ->
{:ok, blob} = ExGitObjectstore.write_object(repo, %Blob{content: "content #{i}\n"})
{:ok, tree} =
ExGitObjectstore.write_object(repo, %Tree{
entries: [%{mode: "100644", name: "file#{i}.txt", sha: blob}]
})
ts = 1_700_000_000 + i
who = "Test <t@example.com> #{ts} +0000"
{:ok, commit} =
ExGitObjectstore.write_object(repo, %Commit{
tree: tree,
parents: if(parent, do: [parent], else: []),
author: who,
committer: who,
message: "commit #{i}\n"
})
{commit, shas ++ [blob, tree, commit]}
end)
end
defp loose_count(repo) do
{:ok, %{loose: loose}} = Maintenance.stats(repo)
loose
end
defp pack_count(repo) do
{:ok, %{packs: packs}} = Maintenance.stats(repo)
packs
end
describe "repack/2" do
test "every object is still readable afterwards, byte-identically", %{repo: repo} do
{tip, shas} = build_history(repo, 12)
:ok = ExGitObjectstore.create_branch(repo, "main", tip)
before =
Map.new(shas, fn sha ->
{:ok, obj} = ObjectResolver.read(repo, sha)
{sha, obj}
end)
assert {:ok, stats} = Maintenance.repack(repo)
assert stats.packed > 0
ObjectResolver.clear_pack_cache()
for {sha, original} <- before do
assert {:ok, ^original} = ObjectResolver.read(repo, sha),
"object #{sha} did not survive the repack intact"
end
end
test "loose objects are gone and a pack exists", %{repo: repo} do
{tip, shas} = build_history(repo, 8)
:ok = ExGitObjectstore.create_branch(repo, "main", tip)
assert loose_count(repo) == length(shas)
assert pack_count(repo) == 0
assert {:ok, stats} = Maintenance.repack(repo)
assert stats.packed == length(shas)
assert stats.deleted == length(shas)
assert pack_count(repo) == 1
assert loose_count(repo) == 0
end
test "is idempotent — a second run packs nothing", %{repo: repo} do
{tip, _} = build_history(repo, 5)
:ok = ExGitObjectstore.create_branch(repo, "main", tip)
assert {:ok, first} = Maintenance.repack(repo)
assert first.packed > 0
assert {:ok, second} = Maintenance.repack(repo)
assert second.packed == 0
assert second.deleted == 0
assert pack_count(repo) == 1, "a no-op repack must not write an empty pack"
end
test "unreachable objects are KEPT, not pruned", %{repo: repo} do
{tip, _} = build_history(repo, 3)
:ok = ExGitObjectstore.create_branch(repo, "main", tip)
# An object no ref points at — e.g. a push whose ref update never landed.
{:ok, orphan} = ExGitObjectstore.write_object(repo, %Blob{content: "orphan\n"})
assert {:ok, stats} = Maintenance.repack(repo)
assert stats.unreachable_kept == 1
ObjectResolver.clear_pack_cache()
assert {:ok, %Blob{content: "orphan\n"}} = ObjectResolver.read(repo, orphan),
"an unreachable object must survive: a concurrent push may be about to reference it"
end
test "objects reachable only from a tag ref survive", %{repo: repo} do
{tip, shas} = build_history(repo, 3)
# No branch at all — only a tag points into this history.
:ok = ExGitObjectstore.create_tag(repo, "v1.0", tip)
assert {:ok, stats} = Maintenance.repack(repo)
assert stats.packed == length(shas)
ObjectResolver.clear_pack_cache()
for sha <- shas do
assert {:ok, _} = ObjectResolver.read(repo, sha),
"object #{sha} was reachable from refs/tags/v1.0 but was not packed"
end
end
test "objects reachable only from a non-default branch survive", %{repo: repo} do
{tip, shas} = build_history(repo, 3)
:ok = ExGitObjectstore.create_branch(repo, "some-feature", tip)
assert {:ok, _} = Maintenance.repack(repo)
ObjectResolver.clear_pack_cache()
for sha <- shas do
assert {:ok, _} = ObjectResolver.read(repo, sha)
end
end
test "batching produces several packs and still loses nothing", %{repo: repo} do
{tip, shas} = build_history(repo, 10)
:ok = ExGitObjectstore.create_branch(repo, "main", tip)
# Force many batches: 2 objects per pack.
assert {:ok, stats} = Maintenance.repack(repo, max_batch_objects: 2)
assert length(stats.packs_written) > 1,
"expected the batch cap to split the run across multiple packs"
assert stats.packed == length(shas)
assert loose_count(repo) == 0
ObjectResolver.clear_pack_cache()
for sha <- shas do
assert {:ok, _} = ObjectResolver.read(repo, sha),
"object #{sha} lost across a batch boundary"
end
end
test "dry_run reports without writing or deleting", %{repo: repo} do
{tip, shas} = build_history(repo, 4)
:ok = ExGitObjectstore.create_branch(repo, "main", tip)
assert {:ok, stats} = Maintenance.repack(repo, dry_run: true)
assert stats.packed == length(shas)
assert stats.deleted == 0
assert stats.packs_written == []
assert pack_count(repo) == 0
assert loose_count(repo) == length(shas)
end
test "an empty repository is a no-op", %{repo: repo} do
assert {:ok, stats} = Maintenance.repack(repo)
assert stats.packed == 0
assert stats.deleted == 0
assert pack_count(repo) == 0
end
end
describe "needs_repack?/2" do
test "false on a small repo, true once loose objects pass the threshold", %{repo: repo} do
{tip, _} = build_history(repo, 4)
:ok = ExGitObjectstore.create_branch(repo, "main", tip)
refute Maintenance.needs_repack?(repo),
"4 commits must not trip the default git gc.auto threshold"
assert Maintenance.needs_repack?(repo, loose_threshold: 3)
end
test "false again after a repack", %{repo: repo} do
{tip, _} = build_history(repo, 6)
:ok = ExGitObjectstore.create_branch(repo, "main", tip)
assert Maintenance.needs_repack?(repo, loose_threshold: 2)
assert {:ok, _} = Maintenance.repack(repo)
refute Maintenance.needs_repack?(repo, loose_threshold: 2)
end
test "trips on pack count once there are too many packs", %{repo: repo} do
{tip, _} = build_history(repo, 10)
:ok = ExGitObjectstore.create_branch(repo, "main", tip)
assert {:ok, stats} = Maintenance.repack(repo, max_batch_objects: 1)
assert length(stats.packs_written) > 3
assert Maintenance.needs_repack?(repo, pack_threshold: 3),
"many small packs are themselves a reason to repack"
end
end
describe "storage delete_object/3" do
test "removes the object and is idempotent", %{repo: repo} do
{:ok, sha} = ExGitObjectstore.write_object(repo, %Blob{content: "bye\n"})
assert {:ok, _} = ObjectResolver.read(repo, sha)
assert :ok = Repo.storage_call(repo, :delete_object, [sha])
assert {:error, _} = ObjectResolver.read(repo, sha)
assert :ok = Repo.storage_call(repo, :delete_object, [sha]),
"deleting an absent object must succeed so a re-run repack is safe"
end
end
end
test/support/failing_storage.ex +3 −0
@@ -69,6 +69,9 @@
def object_exists?(config, prefix, sha), do: Memory.object_exists?(mem_cfg(config), prefix, sha)
@impl true
def delete_object(config, prefix, sha), do: Memory.delete_object(mem_cfg(config), prefix, sha)
@impl true
def list_objects(config, prefix), do: Memory.list_objects(mem_cfg(config), prefix)
@impl true
test/support/tracking_memory.ex +6 −0
@@ -70,6 +70,12 @@
end
@impl true
def delete_object(config, prefix, sha) do
track(config, {:delete_object, prefix, sha})
Memory.delete_object(mem_config(config), prefix, sha)
end
@impl true
def list_packs(config, prefix) do
track(config, {:list_packs, prefix})
Memory.list_packs(mem_config(config), prefix)