Re: [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:47 UTC
- Message-ID
- <CA+tGzva9Pzn=+zcVr9hkKa7HJEfHDQaB8wkYV7WBwjMchC_4xg@mail.gmail.com>
- In-Reply-To
- <CA+tGzvYYKm=Yo88knZb4oavG9dH5smUCXnoqa-RR9-7YEBycVA@mail.gmail.com>
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 <jens.roecker@gmail.com>:
Show 189 quoted lines
>
> 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()