git/list[1] front-page[2] threads[3] people[4] search[5] about
 

Re: [BUG] push resends common history after repack during pre-push (2.54.0, 2.56.0)

From
JRJens 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()
Previous: Jens RöckerNext: D. Ben Knoble
Message 2 of 4 in “[BUG] push resends common history after repack during pre-push (2.54.0, 2.56.0)”
  1. Jens RöckerOct 6, 2026
  2. Jens RöckerOct 6, 2026
  3. D. Ben KnobleOct 6, 2026
  4. Patrick SteinhardtOct 7, 2026

Read the whole thread, see it on lore, or plain text.

$ cat FOOTERMessages come from the public archive at lore.kernel.org/git, fetched every hour. The front page is chosen and written each morning by an AI editor and can be wrong; the threads themselves are the record. About and API. For agents: an MCP server at https://gitlist.dev/mcp, and any thread, story or person page as Markdown by adding .md to its URL (or sending Accept: text/markdown). Details in /llms.txt.