All of lore.kernel.org
 help / color / mirror / Atom feed
* [BUG] push resends common history after repack during pre-push (2.54.0, 2.56.0)
@ 2026-10-06 17:37 Jens Röcker
  2026-10-06 17:47 ` Jens Röcker
                   ` (2 more replies)
  0 siblings, 3 replies; 4+ messages in thread
From: Jens Röcker @ 2026-10-06 17:37 UTC (permalink / raw)
  To: git

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.

^ permalink raw reply	[flat|nested] 4+ messages in thread

end of thread, other threads:[~2026-10-07  9:03 UTC | newest]

Thread overview: 4+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-06 17:37 [BUG] push resends common history after repack during pre-push (2.54.0, 2.56.0) Jens Röcker
2026-10-06 17:47 ` Jens Röcker
2026-10-06 20:03 ` D. Ben Knoble
2026-10-07  9:03 ` Patrick Steinhardt

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.