[BUG] push resends common history after repack during pre-push (2.54.0, 2.56.0)
- From
- Jens Röcker <jens.roecker@gmail.com>
- Date
- Oct 6, 2026, 17:37 UTC
- Message-ID
- <CA+tGzvYYKm=Yo88knZb4oavG9dH5smUCXnoqa-RR9-7YEBycVA@mail.gmail.com>
Hello Git developers,
A push can resend common history if its pre-push hook repacks the local object database and removes previously loose common objects. I reproduced this with Apple Git 2.54.0 (Apple Git-157) and an unmodified build of the current upstream Git 2.56.0 release on macOS 27.0 / arm64.
The attached inline Python script creates fresh local repositories, seeds a bare receiver with a deterministic, incompressible 4-MiB historical blob, and pushes one tiny text-file commit. The common base is initially loose. The receiver uses receive.unpackLimit=1 so the added pack is measurable. Each case starts from a separate fresh repository pair. All pushes succeed and the receiver ends at the expected tip.
Observed added receiver pack sizes, in bytes:
Apple Git 2.54.0 upstream Git 2.56.0 no hook 300 300 repack in pre-push 4,196,026 4,196,026 repack + negotiate 4,196,026 4,196,026
The repacking hook is simply:
#!/bin/sh set -eu cat >/dev/null git repack -adq git prune-packed
Expected: the already-advertised common history should still be excluded when its storage moves from loose objects to a newly created pack. Actual: the historical blob is transmitted again. The receiver stores a new pack roughly the size of the historical blob. Enabling push.negotiate=true does not prevent the redundant transfer in this test.
Possible mechanism, based on source inspection:
In 2.54.0, send-pack.c:feed_object() drops negative OIDs when odb_has_object(..., 0) returns false. In 2.56.0, the same quick check is in append_negative_object(). In both versions, odb_has_object() uses OBJECT_INFO_QUICK unless ODB_HAS_OBJECT_RECHECK_PACKED is set. A parent process with a stale pack catalogue may therefore miss the base after the hook removes the loose copy; the fresh pack generator then sees the new pack and walks history without that excluded base. This is a proposed explanation of the measured effect, not an instrumented proof of the parent process's in-memory state.
Relevant release source: https://github.com/git/git/blob/v2.54.0/send-pack.c https://github.com/git/git/blob/v2.56.0/send-pack.c https://github.com/git/git/blob/v2.56.0/odb.c
The upstream 2.56.0 binary was built from the kernel.org release tarball with optional gettext, curl, Tcl/Tk, Perl, Python and Rust components disabled. Neither global Git configuration nor the installed system Git was changed. The script isolates system/global Git configuration and uses only local transport. Measurements are receiver pack-file sizes, rather than network-byte counters. A separate large-repository incident motivated this test, but this report includes only synthetic fixtures.
To reproduce, save the inline script as reproduce.py and run:
python3 reproduce.py --git /path/to/git
For a Git binary built in-place, add:
--exec-path /path/to/git/build/directory
The script requires Python 3 and Git; its default mode removes only its own temporary test repositories when the run completes. To retain all fixture repositories and push logs, pass --output with a new directory.
Minimal reproducer follows:
#!/usr/bin/env python3 """Reproduce redundant push history after loose objects move into a new pack.
Uses fresh local test repositories only. Requires Python 3 and Git. Example: python3 reproduce.py --git /path/to/git --output /new/results/path For an uninstalled Git build, add --exec-path /path/to/build/directory. """
import argparse import hashlib import json import os from pathlib import Path import platform import shutil import subprocess import tempfile import time
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--git", default=shutil.which("git"))
parser.add_argument("--exec-path")
parser.add_argument("--output", type=Path)
args = parser.parse_args()
binary = str(Path(args.git).resolve())
temporary = None
if args.output:
root = args.output.resolve()
root.mkdir(parents=True, exist_ok=False)
else:
temporary = tempfile.TemporaryDirectory(prefix="git-push-repack-repro-")
root = Path(temporary.name) env = {key: value for key, value in os.environ.items()
if not key.startswith("GIT_")}
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull,
GIT_AUTHOR_DATE="2001-01-01T00:00:00+0000",
GIT_COMMITTER_DATE="2001-01-01T00:00:00+0000")
env["PATH"] = str(Path(binary).parent) + os.pathsep + env.get("PATH", "")
if args.exec_path:
env["GIT_EXEC_PATH"] = str(Path(args.exec_path).resolve()) def git(cwd, *words):
return subprocess.run([binary, *words], cwd=cwd, env=env,
text=True, capture_output=True, check=True,
timeout=60) results = []
for mode in ("no-hook", "repack", "repack-negotiate"):
case = root / mode
case.mkdir()
repo, remote = case / "repo", case / "remote.git"
git(case, "init", "-q", "-b", "main", str(repo))
git(case, "init", "-q", "--bare", "-b", "main", str(remote))
for key, value in (("user.name", "Git bug reproduction"),
("user.email", "test@example.invalid"),
("commit.gpgsign", "false"), ("gc.auto", "0"),
("core.hooksPath", str(repo / ".git/hooks"))):
git(repo, "config", key, value)
git(remote, "config", "gc.auto", "0")
git(remote, "config", "receive.unpackLimit", "1")
(repo / "history.bin").write_bytes(
hashlib.shake_256(b"historical fixture").digest(4 * 1024 * 1024))
git(repo, "add", "--", "history.bin")
git(repo, "commit", "-q", "-m", "historical seed", "--", "history.bin")
base = git(repo, "rev-parse", "HEAD").stdout.strip()
git(repo, "push", "-q", str(remote), "HEAD:refs/heads/main")
(repo / "change.txt").write_text("tiny change\n")
git(repo, "add", "--", "change.txt")
git(repo, "commit", "-q", "-m", "tiny change", "--", "change.txt")
tip = git(repo, "rev-parse", "HEAD").stdout.strip()
assert (repo / ".git/objects" / base[:2] / base[2:]).is_file()
if mode != "no-hook":
hook = repo / ".git/hooks/pre-push"
hook.parent.mkdir(parents=True, exist_ok=True)
hook.write_text("#!/bin/sh\nset -eu\ncat >/dev/null\n"
"git repack -adq\ngit prune-packed\n")
hook.chmod(0o700)
before = set((remote / "objects/pack").glob("*.pack"))
config = ["-c", "push.negotiate=true"] if mode ==
"repack-negotiate" else []
start = time.monotonic()
result = git(repo, *config, "push", "--progress", str(remote),
"HEAD:refs/heads/main")
elapsed = time.monotonic() - start
(case / "push.log").write_text(result.stdout + result.stderr)
packs = set((remote / "objects/pack").glob("*.pack")) - before
remote_tip = git(remote, "rev-parse", "refs/heads/main").stdout.strip()
assert remote_tip == tip
results.append({"case": mode, "push_rc": result.returncode,
"new_remote_pack_bytes": sum(p.stat().st_size
for p in packs),
"elapsed_seconds": round(elapsed, 6),
"remote_tip_matches": True})
report = {"git_version": git(root, "version",
"--build-options").stdout.strip(),
"platform": {"system": platform.system(), "machine":
platform.machine(),
"macos": platform.mac_ver()[0]},
"fixture_bytes": 4 * 1024 * 1024,
"transport": "local bare repository", "results": results}
encoded = json.dumps(report, indent=2) + "\n"
(root / "results.json").write_text(encoded)
print(encoded, end="")
if temporary:
temporary.cleanup()if __name__ == "__main__":
main()Thank you.