From: Jens Röcker Date: Tue, 06 Oct 2026 17:37:47 GMT Subject: [BUG] push resends common history after repack during pre-push (2.54.0, 2.56.0) Message-ID: 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.