From: Jens Röcker Date: Tue, 06 Oct 2026 17:47:48 GMT Subject: Re: [BUG] push resends common history after repack during pre-push (2.54.0, 2.56.0) Message-ID: In-Reply-To: Hello Git developers, Gmail hard-wrapped several long lines in the inline Python script in my previous report. Please use the attached reproduce.txt instead. It is the same tested script, supplied as a text/plain attachment to preserve its exact contents. Run it with: python3 reproduce.txt --git /path/to/git For an uninstalled Git build, also pass: --exec-path /path/to/git/build/directory All reported measurements remain unchanged: 300 bytes without repacking, and 4,196,026 bytes with repacking, with or without push.negotiate=true, for both Apple Git 2.54.0 and upstream Git 2.56.0. Sorry for the formatting issue. Attachment SHA-256: 932b10a426aca11eb5831cd338ad5f61d83db1301e334764a2d9df17ab4e6b76 Am Di., 6. Okt. 2026 um 19:37 Uhr schrieb Jens Röcker : > > 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. -- Mit freundlichen Grüßen Jens Röcker #!/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()