Skip to content

Commit 80c5945

Browse files
committed
Scale packed repository operations
1 parent 80c89fa commit 80c5945

10 files changed

Lines changed: 1743 additions & 176 deletions

File tree

README.md

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ pure-python-git/ (repo root)
2828
│ ├── sequencer.py cherry-pick / revert / rebase
2929
│ ├── porcelain_merge.py ff + 3-way merge entry point
3030
│ ├── patch.py unified-diff parser + applier
31-
│ ├── pack.py pack v2 + idx v2, REF_DELTA + OFS_DELTA, encoder
31+
│ ├── pack.py pack v2 + idx v2, bitmaps, MIDX, streaming writer
32+
│ ├── commitgraph.py cached commit-graph reader
33+
│ ├── bloom.py changed-path Bloom filters
3234
│ ├── protocol.py smart HTTPS clone / fetch / push
3335
│ ├── stash.py refs/stash + reflog-backed stash
3436
│ ├── ignore.py .gitignore engine
@@ -197,7 +199,8 @@ The test suite verifies this against the real `git` binary:
197199
| tree / commit objects | `git cat-file -p` |
198200
| index v2 with stages | `git ls-files --stage` |
199201
| pack v2 + idx v2 (with deltas) | `git verify-pack -v` |
200-
| binary commit-graph file | `git commit-graph verify` |
202+
| pack and MIDX bitmap indexes | `git rev-list --test-bitmap` |
203+
| binary commit-graph file with changed-path Bloom filters | `git commit-graph verify` |
201204
| SHA-1/SHA-256 object-format repos | `git fsck`, `git rev-parse --show-object-format` |
202205
| refs / packed-refs / reflog | `git log --all` |
203206
| smart HTTPS push payload | `git receive-pack` |
@@ -211,11 +214,22 @@ The reverse also holds: pythongit reads packs and indexes produced by real
211214

212215
Loose objects under `.git/objects/<oid[:2]>/<oid[2:]>`, zlib-compressed. SHA-1
213216
and SHA-256 repositories are selected by `extensions.objectformat`. Pack objects
214-
live in `.git/objects/pack/pack-*.{pack,idx}`. The pack reader handles both
215-
`REF_DELTA` (delta against a hex object-id base) and `OFS_DELTA` (delta against
216-
an earlier offset in the same pack). `pack.build_pack` also writes deltas:
217-
candidate bases come from a windowed search over recent same-type objects,
218-
accepted when the delta is at most half the raw size.
217+
live in `.git/objects/pack/pack-*.{pack,idx}`. The pack reader mmaps pack files,
218+
binary-searches `.idx` tables, and handles both `REF_DELTA` (delta against a
219+
hex object-id base) and `OFS_DELTA` (delta against an earlier offset in the same
220+
pack). `pack.build_pack` also writes deltas: candidate bases come from a
221+
windowed search over recent same-type objects, accepted when the delta is at
222+
most half the raw size. Large CLI repacks stream non-delta pack records straight
223+
to disk instead of materializing the entire pack in Python memory.
224+
225+
`pack-objects --all` and `repack` write pack `.bitmap` indexes for full
226+
reachable packs. `multi-pack-index write --bitmap` writes `RIDX`/`BTMP` chunks
227+
plus the companion `multi-pack-index-<hash>.bitmap` file. Reachability queries,
228+
`rev-list --count`, pruning, and maintenance paths use pack/MIDX bitmaps when
229+
available. The bitmaps use Git's v1 `BITM` format and EWAH containers; the
230+
first implementation emits literal EWAH words rather than XOR-compressed
231+
chains, prioritizing compatibility and simple verification over minimum file
232+
size.
219233

220234
`translate.ObjectTranslator` converts complete reachable object graphs between
221235
SHA-1 and SHA-256 by rehashing blobs and rewriting embedded object IDs in
@@ -282,11 +296,18 @@ OIDF (256*4) fanout: cumulative counts indexed by first byte of OID
282296
OIDL (N*H) sorted object IDs
283297
CDAT (N*(H+16)) tree(H) + parent1_pos(4) + parent2_pos(4) + gen+time(8)
284298
EDGE (optional) octopus extra parents
299+
BIDX (N*4) cumulative byte offsets for changed-path Bloom filters
300+
BDAT (optional) Bloom settings + concatenated changed-path filters
285301
TRAILER (H) repository hash of all preceding bytes
286302
```
287303

288304
Generation numbers count topological level (1 for roots). The on-disk file is
289-
verifiable by real `git commit-graph verify`.
305+
verifiable by real `git commit-graph verify`. `pygit` also reads and caches the
306+
commit-graph for parent/tree lookups during history walks. Changed-path Bloom
307+
filters use Git's default settings: hash version 1, seven hashes, and ten bits
308+
per changed path; parent directories are included so path-limited history can
309+
test both `dir` and `dir/file`. `blame` uses those filters to avoid tree/blob
310+
work for commits that definitely did not touch the requested path.
290311

291312
### Smart HTTPS
292313

@@ -307,14 +328,14 @@ pip install pythongit[test]
307328
pytest
308329
```
309330

310-
88 tests pass:
331+
93 tests pass:
311332

312333
| File | Coverage |
313334
|-------------------------|----------|
314335
| `unit_objects.py` | hash, encode/decode, signatures, gitlinks |
315336
| `unit_refs.py` | symbolic refs, reflog, packed-refs, abbrev SHA |
316337
| `unit_index.py` | DIRC v2 roundtrip, conflict stages, long paths |
317-
| `unit_pack.py` | delta apply, idx v2, build_pack, binary MIDX, SHA-256 interop |
338+
| `unit_pack.py` | delta apply, idx v2, build_pack, pack/MIDX bitmaps, binary MIDX, SHA-256 interop |
318339
| `unit_modules.py` | diff/merge/patch/ignore/rerere/SMTP unit-level |
319340
| `unit_integration.py` | end-to-end CLI flows incl. conflicts, rerere replay, SHA-256 translation |
320341
| `unit_phase_scripts.py` | wraps the script-style phase tests |
@@ -324,20 +345,19 @@ PATH, so the suite runs cleanly in containers without one.
324345

325346
## What's intentionally NOT implemented
326347

327-
* Bitmap indexes and bloom filters on the commit-graph. Binary
328-
multi-pack-index files are implemented for pack lookup, but MIDX bitmaps are
329-
not.
330348
* `git filter-repo` (it's a separate Python tool anyway, not a git built-in).
331349
* The fancier merge strategies (`recursive`'s rename detection, `ort`'s
332350
three-way for trees). `pygit merge-recursive` aliases to the default
333351
three-way merge.
334352

335353
## Limitations to know about
336354

337-
* Big repos: loose-object discovery and some reachability walks are still
338-
straightforward scans. Pack object lookup can use `.idx` files and the
339-
binary multi-pack-index, but this is still not designed for the
340-
linux-kernel-or-larger end of the spectrum.
355+
* Big repos: packed repositories now use mmap-backed pack reads, binary MIDX
356+
lookup, pack/MIDX bitmaps, commit-graph parent/tree lookup, changed-path
357+
Bloom filters, and streaming on-disk repacks. The remaining scale-sensitive
358+
cases are loose-object-heavy repositories without maintenance data, commands
359+
that inherently inspect every path or blob, and smart-protocol paths that
360+
still assemble response packs in memory.
341361
* The `bisect` heuristic computes exact candidate weights with iterative
342362
bitsets. It avoids Python recursion, but very large candidate DAGs can still
343363
spend noticeable CPU and memory on transitive reachability.

pythongit/bloom.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Changed-path Bloom filters used by commit-graph files."""
2+
from __future__ import annotations
3+
4+
import struct
5+
6+
from . import objects as objs
7+
from . import workdir
8+
from .repo import Repository
9+
10+
11+
BLOOM_HASH_VERSION = 1
12+
BLOOM_NUM_HASHES = 7
13+
BLOOM_BITS_PER_ENTRY = 10
14+
BLOOM_MAX_CHANGED_PATHS = 512
15+
_BLOOM_SEED0 = 0x293AE76F
16+
_BLOOM_SEED1 = 0x7E646E2C
17+
18+
19+
def _murmur3_x86_32(data: bytes, seed: int) -> int:
20+
c1 = 0xCC9E2D51
21+
c2 = 0x1B873593
22+
h = seed & 0xFFFFFFFF
23+
rounded = len(data) & ~3
24+
for pos in range(0, rounded, 4):
25+
k = struct.unpack_from("<I", data, pos)[0]
26+
k = (k * c1) & 0xFFFFFFFF
27+
k = ((k << 15) | (k >> 17)) & 0xFFFFFFFF
28+
k = (k * c2) & 0xFFFFFFFF
29+
h ^= k
30+
h = ((h << 13) | (h >> 19)) & 0xFFFFFFFF
31+
h = (h * 5 + 0xE6546B64) & 0xFFFFFFFF
32+
33+
k = 0
34+
tail = data[rounded:]
35+
if len(tail) == 3:
36+
k ^= tail[2] << 16
37+
if len(tail) >= 2:
38+
k ^= tail[1] << 8
39+
if len(tail) >= 1:
40+
k ^= tail[0]
41+
k = (k * c1) & 0xFFFFFFFF
42+
k = ((k << 15) | (k >> 17)) & 0xFFFFFFFF
43+
k = (k * c2) & 0xFFFFFFFF
44+
h ^= k
45+
46+
h ^= len(data)
47+
h ^= h >> 16
48+
h = (h * 0x85EBCA6B) & 0xFFFFFFFF
49+
h ^= h >> 13
50+
h = (h * 0xC2B2AE35) & 0xFFFFFFFF
51+
h ^= h >> 16
52+
return h & 0xFFFFFFFF
53+
54+
55+
def _collect_tree_paths(repo: Repository, tree_sha: str, prefix: str, out: set[str]) -> None:
56+
stack = [(prefix, tree_sha)]
57+
while stack:
58+
cur_prefix, cur_tree = stack.pop()
59+
try:
60+
entries = workdir._tree_entries(repo, cur_tree)
61+
except KeyError:
62+
continue
63+
for entry in entries:
64+
path = f"{cur_prefix}{entry.name}"
65+
if entry.is_dir():
66+
stack.append((path + "/", entry.sha))
67+
else:
68+
out.add(path)
69+
70+
71+
def _diff_trees(repo: Repository, old_tree: str | None, new_tree: str | None, prefix: str, out: set[str]) -> None:
72+
if old_tree == new_tree:
73+
return
74+
if old_tree is None:
75+
if new_tree is not None:
76+
_collect_tree_paths(repo, new_tree, prefix, out)
77+
return
78+
if new_tree is None:
79+
_collect_tree_paths(repo, old_tree, prefix, out)
80+
return
81+
try:
82+
old_entries = {entry.name: entry for entry in workdir._tree_entries(repo, old_tree)}
83+
new_entries = {entry.name: entry for entry in workdir._tree_entries(repo, new_tree)}
84+
except KeyError:
85+
return
86+
for name in set(old_entries) | set(new_entries):
87+
old = old_entries.get(name)
88+
new = new_entries.get(name)
89+
path = f"{prefix}{name}"
90+
if old is None:
91+
if new is not None and new.is_dir():
92+
_collect_tree_paths(repo, new.sha, path + "/", out)
93+
else:
94+
out.add(path)
95+
continue
96+
if new is None:
97+
if old.is_dir():
98+
_collect_tree_paths(repo, old.sha, path + "/", out)
99+
else:
100+
out.add(path)
101+
continue
102+
if (old.mode, old.sha) == (new.mode, new.sha):
103+
continue
104+
if old.is_dir() and new.is_dir():
105+
_diff_trees(repo, old.sha, new.sha, path + "/", out)
106+
else:
107+
out.add(path)
108+
109+
110+
def _with_parent_dirs(paths: set[str]) -> list[str]:
111+
expanded: set[str] = set()
112+
for path in paths:
113+
parts = path.split("/")
114+
for i in range(1, len(parts)):
115+
expanded.add("/".join(parts[:i]))
116+
expanded.add(path)
117+
return sorted(expanded)
118+
119+
120+
def changed_paths_for_commit(repo: Repository, commit: objs.Commit) -> list[str]:
121+
previous_tree = None
122+
if commit.parents:
123+
try:
124+
parent_type, parent_data = objs.read_object(repo, commit.parents[0])
125+
except KeyError:
126+
parent_type, parent_data = "", b""
127+
if parent_type == "commit":
128+
previous_tree = objs.parse_commit(parent_data).tree
129+
changed: set[str] = set()
130+
_diff_trees(repo, previous_tree, commit.tree, "", changed)
131+
return _with_parent_dirs(changed)
132+
133+
134+
def bloom_filter_for_paths(paths: list[str]) -> bytes:
135+
if not paths:
136+
return b"\0"
137+
if len(paths) > BLOOM_MAX_CHANGED_PATHS:
138+
return b"\xff"
139+
byte_count = max(1, (len(paths) * BLOOM_BITS_PER_ENTRY + 7) // 8)
140+
bit_count = byte_count * 8
141+
out = bytearray(byte_count)
142+
for path in paths:
143+
data = path.encode("utf-8")
144+
h0 = _murmur3_x86_32(data, _BLOOM_SEED0)
145+
h1 = _murmur3_x86_32(data, _BLOOM_SEED1)
146+
for i in range(BLOOM_NUM_HASHES):
147+
bit = (h0 + i * h1) % bit_count
148+
out[bit // 8] |= 1 << (bit % 8)
149+
return bytes(out)
150+
151+
152+
def bloom_maybe_contains(filter_data: bytes, path: str) -> bool:
153+
if filter_data == b"\xff":
154+
return True
155+
if not filter_data or filter_data == b"\0":
156+
return False
157+
bit_count = len(filter_data) * 8
158+
data = path.encode("utf-8")
159+
h0 = _murmur3_x86_32(data, _BLOOM_SEED0)
160+
h1 = _murmur3_x86_32(data, _BLOOM_SEED1)
161+
for i in range(BLOOM_NUM_HASHES):
162+
bit = (h0 + i * h1) % bit_count
163+
if not (filter_data[bit // 8] & (1 << (bit % 8))):
164+
return False
165+
return True
166+
167+
168+
def build_commit_graph_bloom_chunks(
169+
repo: Repository,
170+
shas: list[str],
171+
commits: dict[str, objs.Commit],
172+
) -> tuple[bytes, bytes]:
173+
offsets = bytearray()
174+
filters = bytearray()
175+
end = 0
176+
for sha in shas:
177+
filter_data = bloom_filter_for_paths(changed_paths_for_commit(repo, commits[sha]))
178+
filters += filter_data
179+
end += len(filter_data)
180+
offsets += struct.pack(">I", end)
181+
bdat = struct.pack(">III", BLOOM_HASH_VERSION, BLOOM_NUM_HASHES, BLOOM_BITS_PER_ENTRY) + bytes(filters)
182+
return bytes(offsets), bdat
183+
184+
185+
def read_commit_graph_bloom_filters(bidx: bytes, bdat: bytes, commit_count: int) -> list[bytes]:
186+
if len(bidx) != commit_count * 4:
187+
raise ValueError("invalid BIDX chunk length")
188+
if len(bdat) < 12:
189+
raise ValueError("invalid BDAT chunk length")
190+
version, hashes, bits_per_entry = struct.unpack(">III", bdat[:12])
191+
if (version, hashes, bits_per_entry) != (
192+
BLOOM_HASH_VERSION,
193+
BLOOM_NUM_HASHES,
194+
BLOOM_BITS_PER_ENTRY,
195+
):
196+
raise ValueError("unsupported commit-graph Bloom settings")
197+
data = bdat[12:]
198+
filters: list[bytes] = []
199+
previous = 0
200+
for i in range(commit_count):
201+
end = struct.unpack(">I", bidx[i * 4 : i * 4 + 4])[0]
202+
if end < previous or end > len(data):
203+
raise ValueError("invalid BIDX offset")
204+
filters.append(data[previous:end])
205+
previous = end
206+
if previous != len(data):
207+
raise ValueError("unused BDAT filter bytes")
208+
return filters

pythongit/bridges.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,11 +277,13 @@ def _upload_pack(self, repo: Repository):
277277
# send NAK + pack on side-band
278278
self.request.sendall(_pkt(b"NAK\n"))
279279
shas: list[str] = []
280+
seen_shas: set[str] = set()
280281
from .protocol import _collect_objects
281282
for w in wants:
282283
for o in _collect_objects(repo, w, set()):
283-
if o not in shas:
284+
if o not in seen_shas:
284285
shas.append(o)
286+
seen_shas.add(o)
285287
pack_bytes, _ = pack_mod.build_pack(repo, shas)
286288
# chunk side-band channel 1
287289
i = 0
@@ -440,10 +442,12 @@ def http_backend(method: str, path: str, body: bytes, base: Path) -> tuple[int,
440442
wants.append(line[5:].split()[0])
441443
from .protocol import _collect_objects
442444
shas: list[str] = []
445+
seen_shas: set[str] = set()
443446
for w in wants:
444447
for o in _collect_objects(repo, w, set()):
445-
if o not in shas:
448+
if o not in seen_shas:
446449
shas.append(o)
450+
seen_shas.add(o)
447451
pack_bytes, _ = pack_mod.build_pack(repo, shas)
448452
out = bytearray()
449453
out += _pkt(b"NAK\n")

0 commit comments

Comments
 (0)