Skip to content

Commit f0877c2

Browse files
committed
Implement Git packfile parsing: Add support for packfile header parsing, object decompression, and delta resolution
1 parent a28c580 commit f0877c2

1 file changed

Lines changed: 199 additions & 6 deletions

File tree

app/models/clone.py

Lines changed: 199 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import hashlib
12
import re
3+
import struct
4+
import zlib
25
from dataclasses import dataclass
6+
from functools import cache
7+
from pprint import pprint
38

49
DEFAULT_URL = "https://github.com/octocat/Hello-World"
510

@@ -30,6 +35,106 @@ def from_line(cls, line: str):
3035
return cls(length, sha1, ref_name)
3136

3237

38+
@dataclass
39+
class PackHeader:
40+
version: int
41+
num_objects: int
42+
43+
@classmethod
44+
def from_bytes(cls, data: bytes):
45+
version, num_objects = struct.unpack('>II', data[4:12])
46+
return cls(version, num_objects)
47+
48+
@dataclass
49+
class PackObject:
50+
type: int
51+
size: int
52+
data: bytes
53+
pack_offset: int = 0 # offset in pack file where this object starts
54+
55+
56+
# Object type constants
57+
OBJ_COMMIT = 1
58+
OBJ_TREE = 2
59+
OBJ_BLOB = 3
60+
OBJ_TAG = 4
61+
OBJ_OFS_DELTA = 6
62+
OBJ_REF_DELTA = 7
63+
64+
TYPE_NAMES = {
65+
OBJ_COMMIT: "commit",
66+
OBJ_TREE: "tree",
67+
OBJ_BLOB: "blob",
68+
OBJ_TAG: "tag",
69+
}
70+
71+
72+
def compute_sha1(obj_type: int, data: bytes) -> str:
73+
"""Compute git object SHA-1."""
74+
type_name = TYPE_NAMES[obj_type]
75+
header = f"{type_name} {len(data)}\x00".encode()
76+
return hashlib.sha1(header + data).hexdigest()
77+
78+
79+
def read_delta_size(delta: bytes, offset: int) -> tuple[int, int]:
80+
"""Read size varint from delta header."""
81+
size = 0
82+
shift = 0
83+
while True:
84+
byte = delta[offset]
85+
size |= (byte & 0x7F) << shift
86+
offset += 1
87+
if not (byte & 0x80):
88+
break
89+
shift += 7
90+
return size, offset
91+
92+
93+
def apply_delta(base: bytes, delta: bytes) -> bytes:
94+
"""Apply delta instructions to base object."""
95+
offset = 0
96+
_base_size, offset = read_delta_size(delta, offset)
97+
_result_size, offset = read_delta_size(delta, offset)
98+
99+
result = bytearray()
100+
while offset < len(delta):
101+
cmd = delta[offset]
102+
offset += 1
103+
104+
if cmd & 0x80: # Copy from base
105+
copy_offset = 0
106+
copy_size = 0
107+
if cmd & 0x01:
108+
copy_offset |= delta[offset]
109+
offset += 1
110+
if cmd & 0x02:
111+
copy_offset |= delta[offset] << 8
112+
offset += 1
113+
if cmd & 0x04:
114+
copy_offset |= delta[offset] << 16
115+
offset += 1
116+
if cmd & 0x08:
117+
copy_offset |= delta[offset] << 24
118+
offset += 1
119+
if cmd & 0x10:
120+
copy_size |= delta[offset]
121+
offset += 1
122+
if cmd & 0x20:
123+
copy_size |= delta[offset] << 8
124+
offset += 1
125+
if cmd & 0x40:
126+
copy_size |= delta[offset] << 16
127+
offset += 1
128+
if copy_size == 0:
129+
copy_size = 0x10000
130+
result.extend(base[copy_offset:copy_offset + copy_size])
131+
elif cmd: # Insert literal (cmd = number of bytes)
132+
result.extend(delta[offset:offset + cmd])
133+
offset += cmd
134+
135+
return bytes(result)
136+
137+
33138
class RefParser:
34139
@staticmethod
35140
def parse_refs(refs_lines: list[str]):
@@ -82,10 +187,8 @@ def format_pkt_line(payload: str | bytes) -> bytes:
82187
return hex_length.encode() + payload
83188

84189
def send_want_request(self):
85-
caps = " ".join(self.capabilities) if self.capabilities else ""
86-
87190
body_parts = [
88-
self.format_pkt_line(f"want {self.refs['HEAD'].sha1} {caps}\n"),
191+
self.format_pkt_line(f"want {self.refs['HEAD'].sha1}"),
89192
b"0000",
90193
self.format_pkt_line("done\n"),
91194
]
@@ -104,10 +207,100 @@ def send_want_request(self):
104207
content=request_body,
105208
headers=headers,
106209
)
107-
print(response.content)
108-
return response
210+
data = response.content
211+
if data.startswith(b"0008NAK\n"):
212+
return data[8:]
213+
return response.content
214+
215+
def parse_pack_header(self, data: bytes) -> PackHeader:
216+
"""Parse pack file header, return (version, num_objects)."""
217+
return PackHeader.from_bytes(data)
218+
219+
def parse_pack_objects(self, data: bytes, num_objects: int) -> list[PackObject]:
220+
offset = 12 # skip header
221+
objects = []
222+
objects_by_offset = {} # for ofs_delta lookup
223+
objects_by_sha1 = {} # for ref_delta lookup
224+
225+
# First pass: parse all objects
226+
for _ in range(num_objects):
227+
obj_start = offset
228+
229+
# Read variable-length header
230+
byte = data[offset]
231+
obj_type = (byte >> 4) & 0x07
232+
size = byte & 0x0F
233+
shift = 4
234+
offset += 1
235+
236+
while byte & 0x80: # continue bit set
237+
byte = data[offset]
238+
size |= (byte & 0x7F) << shift
239+
shift += 7
240+
offset += 1
241+
242+
# Handle delta base references
243+
delta_base_offset = None
244+
delta_base_sha1 = None
245+
246+
if obj_type == OBJ_OFS_DELTA:
247+
# Read negative offset (variable-length encoding)
248+
byte = data[offset]
249+
delta_base_offset = byte & 0x7F
250+
offset += 1
251+
while byte & 0x80:
252+
byte = data[offset]
253+
delta_base_offset = ((delta_base_offset + 1) << 7) | (byte & 0x7F)
254+
offset += 1
255+
delta_base_offset = obj_start - delta_base_offset
256+
257+
elif obj_type == OBJ_REF_DELTA:
258+
# Read 20-byte base SHA-1
259+
delta_base_sha1 = data[offset:offset + 20].hex()
260+
offset += 20
261+
262+
# Decompress zlib data
263+
decompressor = zlib.decompressobj()
264+
decompressed = decompressor.decompress(data[offset:])
265+
offset += len(data[offset:]) - len(decompressor.unused_data)
266+
267+
obj = PackObject(obj_type, size, decompressed, obj_start)
268+
obj._delta_base_offset = delta_base_offset
269+
obj._delta_base_sha1 = delta_base_sha1
270+
271+
objects.append(obj)
272+
objects_by_offset[obj_start] = obj
273+
274+
# Index non-delta objects by SHA-1
275+
if obj_type in TYPE_NAMES:
276+
sha1 = compute_sha1(obj_type, decompressed)
277+
objects_by_sha1[sha1] = obj
278+
279+
# Second pass: resolve deltas
280+
def resolve(obj: PackObject) -> PackObject:
281+
if obj.type == OBJ_OFS_DELTA:
282+
base_obj = resolve(objects_by_offset[obj._delta_base_offset])
283+
resolved_data = apply_delta(base_obj.data, obj.data)
284+
obj.data = resolved_data
285+
obj.type = base_obj.type
286+
obj.size = len(resolved_data)
287+
elif obj.type == OBJ_REF_DELTA:
288+
base_obj = resolve(objects_by_sha1[obj._delta_base_sha1])
289+
resolved_data = apply_delta(base_obj.data, obj.data)
290+
obj.data = resolved_data
291+
obj.type = base_obj.type
292+
obj.size = len(resolved_data)
293+
return obj
294+
295+
for obj in objects:
296+
resolve(obj)
297+
298+
return objects
109299

110300

111301
if __name__ == "__main__":
112302
with GitClone(DEFAULT_URL) as clone:
113-
clone.send_want_request()
303+
pack_data = clone.send_want_request()
304+
pack_header = clone.parse_pack_header(pack_data)
305+
objects = clone.parse_pack_objects(pack_data, pack_header.num_objects)
306+
pprint(objects)

0 commit comments

Comments
 (0)