]> arthur.barton.de Git - bup.git/blob - lib/bup/git.py
git: remove get_commit_dates()
[bup.git] / lib / bup / git.py
1 """Git interaction library.
2 bup repositories are in Git format. This library allows us to
3 interact with the Git data structures.
4 """
5
6 from __future__ import absolute_import, print_function
7 import errno, os, sys, zlib, time, subprocess, struct, stat, re, tempfile, glob
8 from array import array
9 from binascii import hexlify, unhexlify
10 from collections import namedtuple
11 from itertools import islice
12 from numbers import Integral
13
14 from bup import _helpers, compat, hashsplit, path, midx, bloom, xstat
15 from bup.compat import (buffer,
16                         byte_int, bytes_from_byte, bytes_from_uint,
17                         environ,
18                         items,
19                         range,
20                         reraise)
21 from bup.io import path_msg
22 from bup.helpers import (Sha1, add_error, chunkyreader, debug1, debug2,
23                          exo,
24                          fdatasync,
25                          hostname, localtime, log,
26                          merge_dict,
27                          merge_iter,
28                          mmap_read, mmap_readwrite,
29                          parse_num,
30                          progress, qprogress, stat_if_exists,
31                          unlink,
32                          utc_offset_str)
33 from bup.pwdgrp import username, userfullname
34
35
36 verbose = 0
37 repodir = None  # The default repository, once initialized
38
39 _typemap =  {b'blob': 3, b'tree': 2, b'commit': 1, b'tag': 4}
40 _typermap = {v: k for k, v in items(_typemap)}
41
42
43 _total_searches = 0
44 _total_steps = 0
45
46
47 class GitError(Exception):
48     pass
49
50
51 def _gitenv(repo_dir=None):
52     if not repo_dir:
53         repo_dir = repo()
54     return merge_dict(environ, {b'GIT_DIR': os.path.abspath(repo_dir)})
55
56 def _git_wait(cmd, p):
57     rv = p.wait()
58     if rv != 0:
59         raise GitError('%r returned %d' % (cmd, rv))
60
61 def _git_exo(cmd, **kwargs):
62     kwargs['check'] = False
63     result = exo(cmd, **kwargs)
64     _, _, proc = result
65     if proc.returncode != 0:
66         raise GitError('%r returned %d' % (cmd, proc.returncode))
67     return result
68
69 def git_config_get(option, repo_dir=None):
70     cmd = (b'git', b'config', b'--get', option)
71     p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
72                          env=_gitenv(repo_dir=repo_dir),
73                          close_fds=True)
74     r = p.stdout.read()
75     rc = p.wait()
76     if rc == 0:
77         return r
78     if rc != 1:
79         raise GitError('%r returned %d' % (cmd, rc))
80     return None
81
82
83 def parse_tz_offset(s):
84     """UTC offset in seconds."""
85     tz_off = (int(s[1:3]) * 60 * 60) + (int(s[3:5]) * 60)
86     if bytes_from_byte(s[0]) == b'-':
87         return - tz_off
88     return tz_off
89
90
91 # FIXME: derived from http://git.rsbx.net/Documents/Git_Data_Formats.txt
92 # Make sure that's authoritative.
93 _start_end_char = br'[^ .,:;<>"\'\0\n]'
94 _content_char = br'[^\0\n<>]'
95 _safe_str_rx = br'(?:%s{1,2}|(?:%s%s*%s))' \
96     % (_start_end_char,
97        _start_end_char, _content_char, _start_end_char)
98 _tz_rx = br'[-+]\d\d[0-5]\d'
99 _parent_rx = br'(?:parent [abcdefABCDEF0123456789]{40}\n)'
100 # Assumes every following line starting with a space is part of the
101 # mergetag.  Is there a formal commit blob spec?
102 _mergetag_rx = br'(?:\nmergetag object [abcdefABCDEF0123456789]{40}(?:\n [^\0\n]*)*)'
103 _commit_rx = re.compile(br'''tree (?P<tree>[abcdefABCDEF0123456789]{40})
104 (?P<parents>%s*)author (?P<author_name>%s) <(?P<author_mail>%s)> (?P<asec>\d+) (?P<atz>%s)
105 committer (?P<committer_name>%s) <(?P<committer_mail>%s)> (?P<csec>\d+) (?P<ctz>%s)(?P<mergetag>%s?)
106
107 (?P<message>(?:.|\n)*)''' % (_parent_rx,
108                              _safe_str_rx, _safe_str_rx, _tz_rx,
109                              _safe_str_rx, _safe_str_rx, _tz_rx,
110                              _mergetag_rx))
111 _parent_hash_rx = re.compile(br'\s*parent ([abcdefABCDEF0123456789]{40})\s*')
112
113 # Note that the author_sec and committer_sec values are (UTC) epoch
114 # seconds, and for now the mergetag is not included.
115 CommitInfo = namedtuple('CommitInfo', ['tree', 'parents',
116                                        'author_name', 'author_mail',
117                                        'author_sec', 'author_offset',
118                                        'committer_name', 'committer_mail',
119                                        'committer_sec', 'committer_offset',
120                                        'message'])
121
122 def parse_commit(content):
123     commit_match = re.match(_commit_rx, content)
124     if not commit_match:
125         raise Exception('cannot parse commit %r' % content)
126     matches = commit_match.groupdict()
127     return CommitInfo(tree=matches['tree'],
128                       parents=re.findall(_parent_hash_rx, matches['parents']),
129                       author_name=matches['author_name'],
130                       author_mail=matches['author_mail'],
131                       author_sec=int(matches['asec']),
132                       author_offset=parse_tz_offset(matches['atz']),
133                       committer_name=matches['committer_name'],
134                       committer_mail=matches['committer_mail'],
135                       committer_sec=int(matches['csec']),
136                       committer_offset=parse_tz_offset(matches['ctz']),
137                       message=matches['message'])
138
139
140 def get_cat_data(cat_iterator, expected_type):
141     _, kind, _ = next(cat_iterator)
142     if kind != expected_type:
143         raise Exception('expected %r, saw %r' % (expected_type, kind))
144     return b''.join(cat_iterator)
145
146 def get_commit_items(id, cp):
147     return parse_commit(get_cat_data(cp.get(id), b'commit'))
148
149 def _local_git_date_str(epoch_sec):
150     return b'%d %s' % (epoch_sec, utc_offset_str(epoch_sec))
151
152
153 def _git_date_str(epoch_sec, tz_offset_sec):
154     offs =  tz_offset_sec // 60
155     return b'%d %s%02d%02d' \
156         % (epoch_sec,
157            b'+' if offs >= 0 else b'-',
158            abs(offs) // 60,
159            abs(offs) % 60)
160
161
162 def repo(sub = b'', repo_dir=None):
163     """Get the path to the git repository or one of its subdirectories."""
164     repo_dir = repo_dir or repodir
165     if not repo_dir:
166         raise GitError('You should call check_repo_or_die()')
167
168     # If there's a .git subdirectory, then the actual repo is in there.
169     gd = os.path.join(repo_dir, b'.git')
170     if os.path.exists(gd):
171         repo_dir = gd
172
173     return os.path.join(repo_dir, sub)
174
175
176 _shorten_hash_rx = \
177     re.compile(br'([^0-9a-z]|\b)([0-9a-z]{7})[0-9a-z]{33}([^0-9a-z]|\b)')
178
179 def shorten_hash(s):
180     return _shorten_hash_rx.sub(br'\1\2*\3', s)
181
182
183 def repo_rel(path):
184     full = os.path.abspath(path)
185     fullrepo = os.path.abspath(repo(b''))
186     if not fullrepo.endswith(b'/'):
187         fullrepo += b'/'
188     if full.startswith(fullrepo):
189         path = full[len(fullrepo):]
190     if path.startswith(b'index-cache/'):
191         path = path[len(b'index-cache/'):]
192     return shorten_hash(path)
193
194
195 def auto_midx(objdir):
196     args = [path.exe(), b'midx', b'--auto', b'--dir', objdir]
197     try:
198         rv = subprocess.call(args, stdout=open(os.devnull, 'w'))
199     except OSError as e:
200         # make sure 'args' gets printed to help with debugging
201         add_error('%r: exception: %s' % (args, e))
202         raise
203     if rv:
204         add_error('%r: returned %d' % (args, rv))
205
206     args = [path.exe(), b'bloom', b'--dir', objdir]
207     try:
208         rv = subprocess.call(args, stdout=open(os.devnull, 'w'))
209     except OSError as e:
210         # make sure 'args' gets printed to help with debugging
211         add_error('%r: exception: %s' % (args, e))
212         raise
213     if rv:
214         add_error('%r: returned %d' % (args, rv))
215
216
217 def mangle_name(name, mode, gitmode):
218     """Mangle a file name to present an abstract name for segmented files.
219     Mangled file names will have the ".bup" extension added to them. If a
220     file's name already ends with ".bup", a ".bupl" extension is added to
221     disambiguate normal files from segmented ones.
222     """
223     if stat.S_ISREG(mode) and not stat.S_ISREG(gitmode):
224         assert(stat.S_ISDIR(gitmode))
225         return name + b'.bup'
226     elif name.endswith(b'.bup') or name[:-1].endswith(b'.bup'):
227         return name + b'.bupl'
228     else:
229         return name
230
231
232 (BUP_NORMAL, BUP_CHUNKED) = (0,1)
233 def demangle_name(name, mode):
234     """Remove name mangling from a file name, if necessary.
235
236     The return value is a tuple (demangled_filename,mode), where mode is one of
237     the following:
238
239     * BUP_NORMAL  : files that should be read as-is from the repository
240     * BUP_CHUNKED : files that were chunked and need to be reassembled
241
242     For more information on the name mangling algorithm, see mangle_name()
243     """
244     if name.endswith(b'.bupl'):
245         return (name[:-5], BUP_NORMAL)
246     elif name.endswith(b'.bup'):
247         return (name[:-4], BUP_CHUNKED)
248     elif name.endswith(b'.bupm'):
249         return (name[:-5],
250                 BUP_CHUNKED if stat.S_ISDIR(mode) else BUP_NORMAL)
251     else:
252         return (name, BUP_NORMAL)
253
254
255 def calc_hash(type, content):
256     """Calculate some content's hash in the Git fashion."""
257     header = b'%s %d\0' % (type, len(content))
258     sum = Sha1(header)
259     sum.update(content)
260     return sum.digest()
261
262
263 def shalist_item_sort_key(ent):
264     (mode, name, id) = ent
265     assert(mode+0 == mode)
266     if stat.S_ISDIR(mode):
267         return name + b'/'
268     else:
269         return name
270
271
272 def tree_encode(shalist):
273     """Generate a git tree object from (mode,name,hash) tuples."""
274     shalist = sorted(shalist, key = shalist_item_sort_key)
275     l = []
276     for (mode,name,bin) in shalist:
277         assert(mode)
278         assert(mode+0 == mode)
279         assert(name)
280         assert(len(bin) == 20)
281         s = b'%o %s\0%s' % (mode,name,bin)
282         assert s[0] != b'0'  # 0-padded octal is not acceptable in a git tree
283         l.append(s)
284     return b''.join(l)
285
286
287 def tree_decode(buf):
288     """Generate a list of (mode,name,hash) from the git tree object in buf."""
289     ofs = 0
290     while ofs < len(buf):
291         z = buf.find(b'\0', ofs)
292         assert(z > ofs)
293         spl = buf[ofs:z].split(b' ', 1)
294         assert(len(spl) == 2)
295         mode,name = spl
296         sha = buf[z+1:z+1+20]
297         ofs = z+1+20
298         yield (int(mode, 8), name, sha)
299
300
301 def _encode_packobj(type, content, compression_level=1):
302     if compression_level not in (0, 1, 2, 3, 4, 5, 6, 7, 8, 9):
303         raise ValueError('invalid compression level %s' % compression_level)
304     szout = b''
305     sz = len(content)
306     szbits = (sz & 0x0f) | (_typemap[type]<<4)
307     sz >>= 4
308     while 1:
309         if sz: szbits |= 0x80
310         szout += bytes_from_uint(szbits)
311         if not sz:
312             break
313         szbits = sz & 0x7f
314         sz >>= 7
315     z = zlib.compressobj(compression_level)
316     yield szout
317     yield z.compress(content)
318     yield z.flush()
319
320
321 def _decode_packobj(buf):
322     assert(buf)
323     c = byte_int(buf[0])
324     type = _typermap[(c & 0x70) >> 4]
325     sz = c & 0x0f
326     shift = 4
327     i = 0
328     while c & 0x80:
329         i += 1
330         c = byte_int(buf[i])
331         sz |= (c & 0x7f) << shift
332         shift += 7
333         if not (c & 0x80):
334             break
335     return (type, zlib.decompress(buf[i+1:]))
336
337
338 class PackIdx:
339     def __init__(self):
340         assert(0)
341
342     def find_offset(self, hash):
343         """Get the offset of an object inside the index file."""
344         idx = self._idx_from_hash(hash)
345         if idx != None:
346             return self._ofs_from_idx(idx)
347         return None
348
349     def exists(self, hash, want_source=False):
350         """Return nonempty if the object exists in this index."""
351         if hash and (self._idx_from_hash(hash) != None):
352             return want_source and os.path.basename(self.name) or True
353         return None
354
355     def _idx_from_hash(self, hash):
356         global _total_searches, _total_steps
357         _total_searches += 1
358         assert(len(hash) == 20)
359         b1 = byte_int(hash[0])
360         start = self.fanout[b1-1] # range -1..254
361         end = self.fanout[b1] # range 0..255
362         want = hash
363         _total_steps += 1  # lookup table is a step
364         while start < end:
365             _total_steps += 1
366             mid = start + (end - start) // 2
367             v = self._idx_to_hash(mid)
368             if v < want:
369                 start = mid+1
370             elif v > want:
371                 end = mid
372             else: # got it!
373                 return mid
374         return None
375
376
377 class PackIdxV1(PackIdx):
378     """Object representation of a Git pack index (version 1) file."""
379     def __init__(self, filename, f):
380         self.name = filename
381         self.idxnames = [self.name]
382         self.map = mmap_read(f)
383         # Min size for 'L' is 4, which is sufficient for struct's '!I'
384         self.fanout = array('L', struct.unpack('!256I', self.map))
385         self.fanout.append(0)  # entry "-1"
386         self.nsha = self.fanout[255]
387         self.sha_ofs = 256 * 4
388         # Avoid slicing shatable for individual hashes (very high overhead)
389         self.shatable = buffer(self.map, self.sha_ofs, self.nsha * 24)
390
391     def __enter__(self):
392         return self
393
394     def __exit__(self, type, value, traceback):
395         self.close()
396
397     def __len__(self):
398         return int(self.nsha)  # int() from long for python 2
399
400     def _ofs_from_idx(self, idx):
401         if idx >= self.nsha or idx < 0:
402             raise IndexError('invalid pack index index %d' % idx)
403         ofs = self.sha_ofs + idx * 24
404         return struct.unpack_from('!I', self.map, offset=ofs)[0]
405
406     def _idx_to_hash(self, idx):
407         if idx >= self.nsha or idx < 0:
408             raise IndexError('invalid pack index index %d' % idx)
409         ofs = self.sha_ofs + idx * 24 + 4
410         return self.map[ofs : ofs + 20]
411
412     def __iter__(self):
413         start = self.sha_ofs + 4
414         for ofs in range(start, start + 24 * self.nsha, 24):
415             yield self.map[ofs : ofs + 20]
416
417     def close(self):
418         if self.map is not None:
419             self.shatable = None
420             self.map.close()
421             self.map = None
422
423
424 class PackIdxV2(PackIdx):
425     """Object representation of a Git pack index (version 2) file."""
426     def __init__(self, filename, f):
427         self.name = filename
428         self.idxnames = [self.name]
429         self.map = mmap_read(f)
430         assert self.map[0:8] == b'\377tOc\0\0\0\2'
431         # Min size for 'L' is 4, which is sufficient for struct's '!I'
432         self.fanout = array('L', struct.unpack_from('!256I', self.map, offset=8))
433         self.fanout.append(0)
434         self.nsha = self.fanout[255]
435         self.sha_ofs = 8 + 256*4
436         self.ofstable_ofs = self.sha_ofs + self.nsha * 20 + self.nsha * 4
437         self.ofs64table_ofs = self.ofstable_ofs + self.nsha * 4
438         # Avoid slicing this for individual hashes (very high overhead)
439         self.shatable = buffer(self.map, self.sha_ofs, self.nsha*20)
440
441     def __enter__(self):
442         return self
443
444     def __exit__(self, type, value, traceback):
445         self.close()
446
447     def __len__(self):
448         return int(self.nsha)  # int() from long for python 2
449
450     def _ofs_from_idx(self, idx):
451         if idx >= self.nsha or idx < 0:
452             raise IndexError('invalid pack index index %d' % idx)
453         ofs_ofs = self.ofstable_ofs + idx * 4
454         ofs = struct.unpack_from('!I', self.map, offset=ofs_ofs)[0]
455         if ofs & 0x80000000:
456             idx64 = ofs & 0x7fffffff
457             ofs64_ofs = self.ofs64table_ofs + idx64 * 8
458             ofs = struct.unpack_from('!Q', self.map, offset=ofs64_ofs)[0]
459         return ofs
460
461     def _idx_to_hash(self, idx):
462         if idx >= self.nsha or idx < 0:
463             raise IndexError('invalid pack index index %d' % idx)
464         ofs = self.sha_ofs + idx * 20
465         return self.map[ofs : ofs + 20]
466
467     def __iter__(self):
468         start = self.sha_ofs
469         for ofs in range(start, start + 20 * self.nsha, 20):
470             yield self.map[ofs : ofs + 20]
471
472     def close(self):
473         if self.map is not None:
474             self.shatable = None
475             self.map.close()
476             self.map = None
477
478
479 _mpi_count = 0
480 class PackIdxList:
481     def __init__(self, dir, ignore_midx=False):
482         global _mpi_count
483         assert(_mpi_count == 0) # these things suck tons of VM; don't waste it
484         _mpi_count += 1
485         self.dir = dir
486         self.also = set()
487         self.packs = []
488         self.do_bloom = False
489         self.bloom = None
490         self.ignore_midx = ignore_midx
491         self.refresh()
492
493     def __del__(self):
494         global _mpi_count
495         _mpi_count -= 1
496         assert(_mpi_count == 0)
497
498     def __iter__(self):
499         return iter(idxmerge(self.packs))
500
501     def __len__(self):
502         return sum(len(pack) for pack in self.packs)
503
504     def exists(self, hash, want_source=False):
505         """Return nonempty if the object exists in the index files."""
506         global _total_searches
507         _total_searches += 1
508         if hash in self.also:
509             return True
510         if self.do_bloom and self.bloom:
511             if self.bloom.exists(hash):
512                 self.do_bloom = False
513             else:
514                 _total_searches -= 1  # was counted by bloom
515                 return None
516         for i in range(len(self.packs)):
517             p = self.packs[i]
518             _total_searches -= 1  # will be incremented by sub-pack
519             ix = p.exists(hash, want_source=want_source)
520             if ix:
521                 # reorder so most recently used packs are searched first
522                 self.packs = [p] + self.packs[:i] + self.packs[i+1:]
523                 return ix
524         self.do_bloom = True
525         return None
526
527     def refresh(self, skip_midx = False):
528         """Refresh the index list.
529         This method verifies if .midx files were superseded (e.g. all of its
530         contents are in another, bigger .midx file) and removes the superseded
531         files.
532
533         If skip_midx is True, all work on .midx files will be skipped and .midx
534         files will be removed from the list.
535
536         The instance variable 'ignore_midx' can force this function to
537         always act as if skip_midx was True.
538         """
539         if self.bloom is not None:
540             self.bloom.close()
541         self.bloom = None # Always reopen the bloom as it may have been relaced
542         self.do_bloom = False
543         skip_midx = skip_midx or self.ignore_midx
544         d = dict((p.name, p) for p in self.packs
545                  if not skip_midx or not isinstance(p, midx.PackMidx))
546         if os.path.exists(self.dir):
547             if not skip_midx:
548                 midxl = []
549                 midxes = set(glob.glob(os.path.join(self.dir, b'*.midx')))
550                 # remove any *.midx files from our list that no longer exist
551                 for ix in list(d.values()):
552                     if not isinstance(ix, midx.PackMidx):
553                         continue
554                     if ix.name in midxes:
555                         continue
556                     # remove the midx
557                     del d[ix.name]
558                     ix.close()
559                     self.packs.remove(ix)
560                 for ix in self.packs:
561                     if isinstance(ix, midx.PackMidx):
562                         for name in ix.idxnames:
563                             d[os.path.join(self.dir, name)] = ix
564                 for full in midxes:
565                     if not d.get(full):
566                         mx = midx.PackMidx(full)
567                         (mxd, mxf) = os.path.split(mx.name)
568                         broken = False
569                         for n in mx.idxnames:
570                             if not os.path.exists(os.path.join(mxd, n)):
571                                 log(('warning: index %s missing\n'
572                                      '  used by %s\n')
573                                     % (path_msg(n), path_msg(mxf)))
574                                 broken = True
575                         if broken:
576                             mx.close()
577                             del mx
578                             unlink(full)
579                         else:
580                             midxl.append(mx)
581                 midxl.sort(key=lambda ix:
582                            (-len(ix), -xstat.stat(ix.name).st_mtime))
583                 for ix in midxl:
584                     any_needed = False
585                     for sub in ix.idxnames:
586                         found = d.get(os.path.join(self.dir, sub))
587                         if not found or isinstance(found, PackIdx):
588                             # doesn't exist, or exists but not in a midx
589                             any_needed = True
590                             break
591                     if any_needed:
592                         d[ix.name] = ix
593                         for name in ix.idxnames:
594                             d[os.path.join(self.dir, name)] = ix
595                     elif not ix.force_keep:
596                         debug1('midx: removing redundant: %s\n'
597                                % path_msg(os.path.basename(ix.name)))
598                         ix.close()
599                         unlink(ix.name)
600             for full in glob.glob(os.path.join(self.dir, b'*.idx')):
601                 if not d.get(full):
602                     try:
603                         ix = open_idx(full)
604                     except GitError as e:
605                         add_error(e)
606                         continue
607                     d[full] = ix
608             bfull = os.path.join(self.dir, b'bup.bloom')
609             if self.bloom is None and os.path.exists(bfull):
610                 self.bloom = bloom.ShaBloom(bfull)
611             self.packs = list(set(d.values()))
612             self.packs.sort(reverse=True, key=lambda x: len(x))
613             if self.bloom and self.bloom.valid() and len(self.bloom) >= len(self):
614                 self.do_bloom = True
615             else:
616                 self.bloom = None
617         debug1('PackIdxList: using %d index%s.\n'
618             % (len(self.packs), len(self.packs)!=1 and 'es' or ''))
619
620     def add(self, hash):
621         """Insert an additional object in the list."""
622         self.also.add(hash)
623
624
625 def open_idx(filename):
626     if filename.endswith(b'.idx'):
627         f = open(filename, 'rb')
628         header = f.read(8)
629         if header[0:4] == b'\377tOc':
630             version = struct.unpack('!I', header[4:8])[0]
631             if version == 2:
632                 return PackIdxV2(filename, f)
633             else:
634                 raise GitError('%s: expected idx file version 2, got %d'
635                                % (path_msg(filename), version))
636         elif len(header) == 8 and header[0:4] < b'\377tOc':
637             return PackIdxV1(filename, f)
638         else:
639             raise GitError('%s: unrecognized idx file header'
640                            % path_msg(filename))
641     elif filename.endswith(b'.midx'):
642         return midx.PackMidx(filename)
643     else:
644         raise GitError('idx filenames must end with .idx or .midx')
645
646
647 def idxmerge(idxlist, final_progress=True):
648     """Generate a list of all the objects reachable in a PackIdxList."""
649     def pfunc(count, total):
650         qprogress('Reading indexes: %.2f%% (%d/%d)\r'
651                   % (count*100.0/total, count, total))
652     def pfinal(count, total):
653         if final_progress:
654             progress('Reading indexes: %.2f%% (%d/%d), done.\n'
655                      % (100, total, total))
656     return merge_iter(idxlist, 10024, pfunc, pfinal)
657
658
659 def _make_objcache():
660     return PackIdxList(repo(b'objects/pack'))
661
662 # bup-gc assumes that it can disable all PackWriter activities
663 # (bloom/midx/cache) via the constructor and close() arguments.
664
665 class PackWriter:
666     """Writes Git objects inside a pack file."""
667     def __init__(self, objcache_maker=_make_objcache, compression_level=1,
668                  run_midx=True, on_pack_finish=None,
669                  max_pack_size=None, max_pack_objects=None, repo_dir=None):
670         self.repo_dir = repo_dir or repo()
671         self.file = None
672         self.parentfd = None
673         self.count = 0
674         self.outbytes = 0
675         self.filename = None
676         self.idx = None
677         self.objcache_maker = objcache_maker
678         self.objcache = None
679         self.compression_level = compression_level
680         self.run_midx=run_midx
681         self.on_pack_finish = on_pack_finish
682         if not max_pack_size:
683             max_pack_size = git_config_get(b'pack.packSizeLimit',
684                                            repo_dir=self.repo_dir)
685             if max_pack_size is not None:
686                 max_pack_size = parse_num(max_pack_size)
687             if not max_pack_size:
688                 # larger packs slow down pruning
689                 max_pack_size = 1000 * 1000 * 1000
690         self.max_pack_size = max_pack_size
691         # cache memory usage is about 83 bytes per object
692         self.max_pack_objects = max_pack_objects if max_pack_objects \
693                                 else max(1, self.max_pack_size // 5000)
694
695     def __del__(self):
696         self.close()
697
698     def __enter__(self):
699         return self
700
701     def __exit__(self, type, value, traceback):
702         self.close()
703
704     def _open(self):
705         if not self.file:
706             objdir = dir = os.path.join(self.repo_dir, b'objects')
707             fd, name = tempfile.mkstemp(suffix=b'.pack', dir=objdir)
708             try:
709                 self.file = os.fdopen(fd, 'w+b')
710             except:
711                 os.close(fd)
712                 raise
713             try:
714                 self.parentfd = os.open(objdir, os.O_RDONLY)
715             except:
716                 f = self.file
717                 self.file = None
718                 f.close()
719                 raise
720             assert name.endswith(b'.pack')
721             self.filename = name[:-5]
722             self.file.write(b'PACK\0\0\0\2\0\0\0\0')
723             self.idx = PackIdxV2Writer()
724
725     def _raw_write(self, datalist, sha):
726         self._open()
727         f = self.file
728         # in case we get interrupted (eg. KeyboardInterrupt), it's best if
729         # the file never has a *partial* blob.  So let's make sure it's
730         # all-or-nothing.  (The blob shouldn't be very big anyway, thanks
731         # to our hashsplit algorithm.)  f.write() does its own buffering,
732         # but that's okay because we'll flush it in _end().
733         oneblob = b''.join(datalist)
734         try:
735             f.write(oneblob)
736         except IOError as e:
737             reraise(GitError(e))
738         nw = len(oneblob)
739         crc = zlib.crc32(oneblob) & 0xffffffff
740         self._update_idx(sha, crc, nw)
741         self.outbytes += nw
742         self.count += 1
743         return nw, crc
744
745     def _update_idx(self, sha, crc, size):
746         assert(sha)
747         if self.idx:
748             self.idx.add(sha, crc, self.file.tell() - size)
749
750     def _write(self, sha, type, content):
751         if verbose:
752             log('>')
753         if not sha:
754             sha = calc_hash(type, content)
755         size, crc = self._raw_write(_encode_packobj(type, content,
756                                                     self.compression_level),
757                                     sha=sha)
758         if self.outbytes >= self.max_pack_size \
759            or self.count >= self.max_pack_objects:
760             self.breakpoint()
761         return sha
762
763     def breakpoint(self):
764         """Clear byte and object counts and return the last processed id."""
765         id = self._end(self.run_midx)
766         self.outbytes = self.count = 0
767         return id
768
769     def _require_objcache(self):
770         if self.objcache is None and self.objcache_maker:
771             self.objcache = self.objcache_maker()
772         if self.objcache is None:
773             raise GitError(
774                     "PackWriter not opened or can't check exists w/o objcache")
775
776     def exists(self, id, want_source=False):
777         """Return non-empty if an object is found in the object cache."""
778         self._require_objcache()
779         return self.objcache.exists(id, want_source=want_source)
780
781     def just_write(self, sha, type, content):
782         """Write an object to the pack file without checking for duplication."""
783         self._write(sha, type, content)
784         # If nothing else, gc doesn't have/want an objcache
785         if self.objcache is not None:
786             self.objcache.add(sha)
787
788     def maybe_write(self, type, content):
789         """Write an object to the pack file if not present and return its id."""
790         sha = calc_hash(type, content)
791         if not self.exists(sha):
792             self._require_objcache()
793             self.just_write(sha, type, content)
794         return sha
795
796     def new_blob(self, blob):
797         """Create a blob object in the pack with the supplied content."""
798         return self.maybe_write(b'blob', blob)
799
800     def new_tree(self, shalist):
801         """Create a tree object in the pack."""
802         content = tree_encode(shalist)
803         return self.maybe_write(b'tree', content)
804
805     def new_commit(self, tree, parent,
806                    author, adate_sec, adate_tz,
807                    committer, cdate_sec, cdate_tz,
808                    msg):
809         """Create a commit object in the pack.  The date_sec values must be
810         epoch-seconds, and if a tz is None, the local timezone is assumed."""
811         if adate_tz is not None:
812             adate_str = _git_date_str(adate_sec, adate_tz)
813         else:
814             adate_str = _local_git_date_str(adate_sec)
815         if cdate_tz is not None:
816             cdate_str = _git_date_str(cdate_sec, cdate_tz)
817         else:
818             cdate_str = _local_git_date_str(cdate_sec)
819         l = []
820         if tree: l.append(b'tree %s' % hexlify(tree))
821         if parent: l.append(b'parent %s' % hexlify(parent))
822         if author: l.append(b'author %s %s' % (author, adate_str))
823         if committer: l.append(b'committer %s %s' % (committer, cdate_str))
824         l.append(b'')
825         l.append(msg)
826         return self.maybe_write(b'commit', b'\n'.join(l))
827
828     def abort(self):
829         """Remove the pack file from disk."""
830         f = self.file
831         if f:
832             pfd = self.parentfd
833             self.file = None
834             self.parentfd = None
835             self.idx = None
836             try:
837                 try:
838                     os.unlink(self.filename + b'.pack')
839                 finally:
840                     f.close()
841             finally:
842                 if pfd is not None:
843                     os.close(pfd)
844
845     def _end(self, run_midx=True):
846         f = self.file
847         if not f: return None
848         self.file = None
849         try:
850             self.objcache = None
851             idx = self.idx
852             self.idx = None
853
854             # update object count
855             f.seek(8)
856             cp = struct.pack('!i', self.count)
857             assert(len(cp) == 4)
858             f.write(cp)
859
860             # calculate the pack sha1sum
861             f.seek(0)
862             sum = Sha1()
863             for b in chunkyreader(f):
864                 sum.update(b)
865             packbin = sum.digest()
866             f.write(packbin)
867             fdatasync(f.fileno())
868         finally:
869             f.close()
870
871         obj_list_sha = idx.write(self.filename + b'.idx', packbin)
872         nameprefix = os.path.join(self.repo_dir,
873                                   b'objects/pack/pack-' +  obj_list_sha)
874         if os.path.exists(self.filename + b'.map'):
875             os.unlink(self.filename + b'.map')
876         os.rename(self.filename + b'.pack', nameprefix + b'.pack')
877         os.rename(self.filename + b'.idx', nameprefix + b'.idx')
878         try:
879             os.fsync(self.parentfd)
880         finally:
881             os.close(self.parentfd)
882
883         if run_midx:
884             auto_midx(os.path.join(self.repo_dir, b'objects/pack'))
885
886         if self.on_pack_finish:
887             self.on_pack_finish(nameprefix)
888
889         return nameprefix
890
891     def close(self, run_midx=True):
892         """Close the pack file and move it to its definitive path."""
893         return self._end(run_midx=run_midx)
894
895
896 class PackIdxV2Writer:
897     def __init__(self):
898         self.idx = list(list() for i in range(256))
899         self.count = 0
900
901     def add(self, sha, crc, offs):
902         assert(sha)
903         self.count += 1
904         self.idx[byte_int(sha[0])].append((sha, crc, offs))
905
906     def write(self, filename, packbin):
907         ofs64_count = 0
908         for section in self.idx:
909             for entry in section:
910                 if entry[2] >= 2**31:
911                     ofs64_count += 1
912
913         # Length: header + fan-out + shas-and-crcs + overflow-offsets
914         index_len = 8 + (4 * 256) + (28 * self.count) + (8 * ofs64_count)
915         idx_map = None
916         idx_f = open(filename, 'w+b')
917         try:
918             idx_f.truncate(index_len)
919             fdatasync(idx_f.fileno())
920             idx_map = mmap_readwrite(idx_f, close=False)
921             try:
922                 count = _helpers.write_idx(filename, idx_map, self.idx,
923                                            self.count)
924                 assert(count == self.count)
925                 idx_map.flush()
926             finally:
927                 idx_map.close()
928         finally:
929             idx_f.close()
930
931         idx_f = open(filename, 'a+b')
932         try:
933             idx_f.write(packbin)
934             idx_f.seek(0)
935             idx_sum = Sha1()
936             b = idx_f.read(8 + 4*256)
937             idx_sum.update(b)
938
939             obj_list_sum = Sha1()
940             for b in chunkyreader(idx_f, 20 * self.count):
941                 idx_sum.update(b)
942                 obj_list_sum.update(b)
943             namebase = hexlify(obj_list_sum.digest())
944
945             for b in chunkyreader(idx_f):
946                 idx_sum.update(b)
947             idx_f.write(idx_sum.digest())
948             fdatasync(idx_f.fileno())
949             return namebase
950         finally:
951             idx_f.close()
952
953
954 def list_refs(patterns=None, repo_dir=None,
955               limit_to_heads=False, limit_to_tags=False):
956     """Yield (refname, hash) tuples for all repository refs unless
957     patterns are specified.  In that case, only include tuples for
958     refs matching those patterns (cf. git-show-ref(1)).  The limits
959     restrict the result items to refs/heads or refs/tags.  If both
960     limits are specified, items from both sources will be included.
961
962     """
963     argv = [b'git', b'show-ref']
964     if limit_to_heads:
965         argv.append(b'--heads')
966     if limit_to_tags:
967         argv.append(b'--tags')
968     argv.append(b'--')
969     if patterns:
970         argv.extend(patterns)
971     p = subprocess.Popen(argv, env=_gitenv(repo_dir), stdout=subprocess.PIPE,
972                          close_fds=True)
973     out = p.stdout.read().strip()
974     rv = p.wait()  # not fatal
975     if rv:
976         assert(not out)
977     if out:
978         for d in out.split(b'\n'):
979             sha, name = d.split(b' ', 1)
980             yield name, unhexlify(sha)
981
982
983 def read_ref(refname, repo_dir = None):
984     """Get the commit id of the most recent commit made on a given ref."""
985     refs = list_refs(patterns=[refname], repo_dir=repo_dir, limit_to_heads=True)
986     l = tuple(islice(refs, 2))
987     if l:
988         assert(len(l) == 1)
989         return l[0][1]
990     else:
991         return None
992
993
994 def rev_list_invocation(ref_or_refs, format=None):
995     if isinstance(ref_or_refs, bytes):
996         refs = (ref_or_refs,)
997     else:
998         refs = ref_or_refs
999     argv = [b'git', b'rev-list']
1000
1001     if format:
1002         argv.append(b'--pretty=format:' + format)
1003     for ref in refs:
1004         assert not ref.startswith(b'-')
1005         argv.append(ref)
1006     argv.append(b'--')
1007     return argv
1008
1009
1010 def rev_list(ref_or_refs, parse=None, format=None, repo_dir=None):
1011     """Yield information about commits as per "git rev-list".  If a format
1012     is not provided, yield one hex hash at a time.  If a format is
1013     provided, pass it to rev-list and call parse(git_stdout) for each
1014     commit with the stream positioned just after the rev-list "commit
1015     HASH" header line.  When a format is provided yield (oidx,
1016     parse(git_stdout)) for each commit.
1017
1018     """
1019     assert bool(parse) == bool(format)
1020     p = subprocess.Popen(rev_list_invocation(ref_or_refs,
1021                                              format=format),
1022                          env=_gitenv(repo_dir),
1023                          stdout = subprocess.PIPE,
1024                          close_fds=True)
1025     if not format:
1026         for line in p.stdout:
1027             yield line.strip()
1028     else:
1029         line = p.stdout.readline()
1030         while line:
1031             s = line.strip()
1032             if not s.startswith(b'commit '):
1033                 raise Exception('unexpected line ' + repr(s))
1034             s = s[7:]
1035             assert len(s) == 40
1036             yield s, parse(p.stdout)
1037             line = p.stdout.readline()
1038
1039     rv = p.wait()  # not fatal
1040     if rv:
1041         raise GitError('git rev-list returned error %d' % rv)
1042
1043
1044 def rev_parse(committish, repo_dir=None):
1045     """Resolve the full hash for 'committish', if it exists.
1046
1047     Should be roughly equivalent to 'git rev-parse'.
1048
1049     Returns the hex value of the hash if it is found, None if 'committish' does
1050     not correspond to anything.
1051     """
1052     head = read_ref(committish, repo_dir=repo_dir)
1053     if head:
1054         debug2("resolved from ref: commit = %s\n" % hexlify(head))
1055         return head
1056
1057     pL = PackIdxList(repo(b'objects/pack', repo_dir=repo_dir))
1058
1059     if len(committish) == 40:
1060         try:
1061             hash = unhexlify(committish)
1062         except TypeError:
1063             return None
1064
1065         if pL.exists(hash):
1066             return hash
1067
1068     return None
1069
1070
1071 def update_ref(refname, newval, oldval, repo_dir=None):
1072     """Update a repository reference."""
1073     if not oldval:
1074         oldval = b''
1075     assert refname.startswith(b'refs/heads/') \
1076         or refname.startswith(b'refs/tags/')
1077     p = subprocess.Popen([b'git', b'update-ref', refname,
1078                           hexlify(newval), hexlify(oldval)],
1079                          env=_gitenv(repo_dir),
1080                          close_fds=True)
1081     _git_wait(b'git update-ref', p)
1082
1083
1084 def delete_ref(refname, oldvalue=None):
1085     """Delete a repository reference (see git update-ref(1))."""
1086     assert refname.startswith(b'refs/')
1087     oldvalue = [] if not oldvalue else [oldvalue]
1088     p = subprocess.Popen([b'git', b'update-ref', b'-d', refname] + oldvalue,
1089                          env=_gitenv(),
1090                          close_fds=True)
1091     _git_wait('git update-ref', p)
1092
1093
1094 def guess_repo(path=None):
1095     """Set the path value in the global variable "repodir".
1096     This makes bup look for an existing bup repository, but not fail if a
1097     repository doesn't exist. Usually, if you are interacting with a bup
1098     repository, you would not be calling this function but using
1099     check_repo_or_die().
1100     """
1101     global repodir
1102     if path:
1103         repodir = path
1104     if not repodir:
1105         repodir = environ.get(b'BUP_DIR')
1106         if not repodir:
1107             repodir = os.path.expanduser(b'~/.bup')
1108
1109
1110 def init_repo(path=None):
1111     """Create the Git bare repository for bup in a given path."""
1112     guess_repo(path)
1113     d = repo()  # appends a / to the path
1114     parent = os.path.dirname(os.path.dirname(d))
1115     if parent and not os.path.exists(parent):
1116         raise GitError('parent directory "%s" does not exist\n'
1117                        % path_msg(parent))
1118     if os.path.exists(d) and not os.path.isdir(os.path.join(d, b'.')):
1119         raise GitError('"%s" exists but is not a directory\n' % path_msg(d))
1120     p = subprocess.Popen([b'git', b'--bare', b'init'], stdout=sys.stderr,
1121                          env=_gitenv(),
1122                          close_fds=True)
1123     _git_wait('git init', p)
1124     # Force the index version configuration in order to ensure bup works
1125     # regardless of the version of the installed Git binary.
1126     p = subprocess.Popen([b'git', b'config', b'pack.indexVersion', '2'],
1127                          stdout=sys.stderr, env=_gitenv(), close_fds=True)
1128     _git_wait('git config', p)
1129     # Enable the reflog
1130     p = subprocess.Popen([b'git', b'config', b'core.logAllRefUpdates', b'true'],
1131                          stdout=sys.stderr, env=_gitenv(), close_fds=True)
1132     _git_wait('git config', p)
1133
1134
1135 def check_repo_or_die(path=None):
1136     """Check to see if a bup repository probably exists, and abort if not."""
1137     guess_repo(path)
1138     top = repo()
1139     pst = stat_if_exists(top + b'/objects/pack')
1140     if pst and stat.S_ISDIR(pst.st_mode):
1141         return
1142     if not pst:
1143         top_st = stat_if_exists(top)
1144         if not top_st:
1145             log('error: repository %r does not exist (see "bup help init")\n'
1146                 % top)
1147             sys.exit(15)
1148     log('error: %s is not a repository\n' % path_msg(top))
1149     sys.exit(14)
1150
1151
1152 def is_suitable_git(ver_str):
1153     if not ver_str.startswith(b'git version '):
1154         return 'unrecognized'
1155     ver_str = ver_str[len(b'git version '):]
1156     if ver_str.startswith(b'0.'):
1157         return 'insufficient'
1158     if ver_str.startswith(b'1.'):
1159         if re.match(br'1\.[012345]rc', ver_str):
1160             return 'insufficient'
1161         if re.match(br'1\.[01234]\.', ver_str):
1162             return 'insufficient'
1163         if re.match(br'1\.5\.[012345]($|\.)', ver_str):
1164             return 'insufficient'
1165         if re.match(br'1\.5\.6-rc', ver_str):
1166             return 'insufficient'
1167         return 'suitable'
1168     if re.match(br'[0-9]+(\.|$)?', ver_str):
1169         return 'suitable'
1170     sys.exit(13)
1171
1172 _git_great = None
1173
1174 def require_suitable_git(ver_str=None):
1175     """Raise GitError if the version of git isn't suitable.
1176
1177     Rely on ver_str when provided, rather than invoking the git in the
1178     path.
1179
1180     """
1181     global _git_great
1182     if _git_great is not None:
1183         return
1184     if environ.get(b'BUP_GIT_VERSION_IS_FINE', b'').lower() \
1185        in (b'yes', b'true', b'1'):
1186         _git_great = True
1187         return
1188     if not ver_str:
1189         ver_str, _, _ = _git_exo([b'git', b'--version'])
1190     status = is_suitable_git(ver_str)
1191     if status == 'unrecognized':
1192         raise GitError('Unexpected git --version output: %r' % ver_str)
1193     if status == 'insufficient':
1194         log('error: git version must be at least 1.5.6\n')
1195         sys.exit(1)
1196     if status == 'suitable':
1197         _git_great = True
1198         return
1199     assert False
1200
1201
1202 class _AbortableIter:
1203     def __init__(self, it, onabort = None):
1204         self.it = it
1205         self.onabort = onabort
1206         self.done = None
1207
1208     def __iter__(self):
1209         return self
1210
1211     def __next__(self):
1212         try:
1213             return next(self.it)
1214         except StopIteration as e:
1215             self.done = True
1216             raise
1217         except:
1218             self.abort()
1219             raise
1220
1221     next = __next__
1222
1223     def abort(self):
1224         """Abort iteration and call the abortion callback, if needed."""
1225         if not self.done:
1226             self.done = True
1227             if self.onabort:
1228                 self.onabort()
1229
1230     def __del__(self):
1231         self.abort()
1232
1233
1234 class CatPipe:
1235     """Link to 'git cat-file' that is used to retrieve blob data."""
1236     def __init__(self, repo_dir = None):
1237         require_suitable_git()
1238         self.repo_dir = repo_dir
1239         self.p = self.inprogress = None
1240
1241     def close(self, wait=False):
1242         p = self.p
1243         if p:
1244             p.stdout.close()
1245             p.stdin.close()
1246         self.p = None
1247         self.inprogress = None
1248         if wait:
1249             p.wait()
1250             return p.returncode
1251
1252     def restart(self):
1253         self.close()
1254         self.p = subprocess.Popen([b'git', b'cat-file', b'--batch'],
1255                                   stdin=subprocess.PIPE,
1256                                   stdout=subprocess.PIPE,
1257                                   close_fds = True,
1258                                   bufsize = 4096,
1259                                   env=_gitenv(self.repo_dir))
1260
1261     def get(self, ref):
1262         """Yield (oidx, type, size), followed by the data referred to by ref.
1263         If ref does not exist, only yield (None, None, None).
1264
1265         """
1266         if not self.p or self.p.poll() != None:
1267             self.restart()
1268         assert(self.p)
1269         poll_result = self.p.poll()
1270         assert(poll_result == None)
1271         if self.inprogress:
1272             log('get: opening %r while %r is open\n' % (ref, self.inprogress))
1273         assert(not self.inprogress)
1274         assert ref.find(b'\n') < 0
1275         assert ref.find(b'\r') < 0
1276         assert not ref.startswith(b'-')
1277         self.inprogress = ref
1278         self.p.stdin.write(ref + b'\n')
1279         self.p.stdin.flush()
1280         hdr = self.p.stdout.readline()
1281         if hdr.endswith(b' missing\n'):
1282             self.inprogress = None
1283             yield None, None, None
1284             return
1285         info = hdr.split(b' ')
1286         if len(info) != 3 or len(info[0]) != 40:
1287             raise GitError('expected object (id, type, size), got %r' % info)
1288         oidx, typ, size = info
1289         size = int(size)
1290         it = _AbortableIter(chunkyreader(self.p.stdout, size),
1291                             onabort=self.close)
1292         try:
1293             yield oidx, typ, size
1294             for blob in it:
1295                 yield blob
1296             readline_result = self.p.stdout.readline()
1297             assert readline_result == b'\n'
1298             self.inprogress = None
1299         except Exception as e:
1300             it.abort()
1301             raise
1302
1303     def _join(self, it):
1304         _, typ, _ = next(it)
1305         if typ == b'blob':
1306             for blob in it:
1307                 yield blob
1308         elif typ == b'tree':
1309             treefile = b''.join(it)
1310             for (mode, name, sha) in tree_decode(treefile):
1311                 for blob in self.join(hexlify(sha)):
1312                     yield blob
1313         elif typ == b'commit':
1314             treeline = b''.join(it).split(b'\n')[0]
1315             assert treeline.startswith(b'tree ')
1316             for blob in self.join(treeline[5:]):
1317                 yield blob
1318         else:
1319             raise GitError('invalid object type %r: expected blob/tree/commit'
1320                            % typ)
1321
1322     def join(self, id):
1323         """Generate a list of the content of all blobs that can be reached
1324         from an object.  The hash given in 'id' must point to a blob, a tree
1325         or a commit. The content of all blobs that can be seen from trees or
1326         commits will be added to the list.
1327         """
1328         for d in self._join(self.get(id)):
1329             yield d
1330
1331
1332 _cp = {}
1333
1334 def cp(repo_dir=None):
1335     """Create a CatPipe object or reuse the already existing one."""
1336     global _cp, repodir
1337     if not repo_dir:
1338         repo_dir = repodir or repo()
1339     repo_dir = os.path.abspath(repo_dir)
1340     cp = _cp.get(repo_dir)
1341     if not cp:
1342         cp = CatPipe(repo_dir)
1343         _cp[repo_dir] = cp
1344     return cp
1345
1346
1347 def close_catpipes():
1348     # FIXME: chain exceptions
1349     while _cp:
1350         _, cp = _cp.popitem()
1351         cp.close(wait=True)
1352
1353
1354 def tags(repo_dir = None):
1355     """Return a dictionary of all tags in the form {hash: [tag_names, ...]}."""
1356     tags = {}
1357     for n, c in list_refs(repo_dir = repo_dir, limit_to_tags=True):
1358         assert n.startswith(b'refs/tags/')
1359         name = n[10:]
1360         if not c in tags:
1361             tags[c] = []
1362         tags[c].append(name)  # more than one tag can point at 'c'
1363     return tags
1364
1365
1366 class MissingObject(KeyError):
1367     def __init__(self, oid):
1368         self.oid = oid
1369         KeyError.__init__(self, 'object %r is missing' % hexlify(oid))
1370
1371
1372 WalkItem = namedtuple('WalkItem', ['oid', 'type', 'mode',
1373                                    'path', 'chunk_path', 'data'])
1374 # The path is the mangled path, and if an item represents a fragment
1375 # of a chunked file, the chunk_path will be the chunked subtree path
1376 # for the chunk, i.e. ['', '2d3115e', ...].  The top-level path for a
1377 # chunked file will have a chunk_path of [''].  So some chunk subtree
1378 # of the file '/foo/bar/baz' might look like this:
1379 #
1380 #   item.path = ['foo', 'bar', 'baz.bup']
1381 #   item.chunk_path = ['', '2d3115e', '016b097']
1382 #   item.type = 'tree'
1383 #   ...
1384
1385
1386 def walk_object(get_ref, oidx, stop_at=None, include_data=None):
1387     """Yield everything reachable from oidx via get_ref (which must behave
1388     like CatPipe get) as a WalkItem, stopping whenever stop_at(oidx)
1389     returns true.  Throw MissingObject if a hash encountered is
1390     missing from the repository, and don't read or return blob content
1391     in the data field unless include_data is set.
1392
1393     """
1394     # Maintain the pending stack on the heap to avoid stack overflow
1395     pending = [(oidx, [], [], None)]
1396     while len(pending):
1397         oidx, parent_path, chunk_path, mode = pending.pop()
1398         oid = unhexlify(oidx)
1399         if stop_at and stop_at(oidx):
1400             continue
1401
1402         if (not include_data) and mode and stat.S_ISREG(mode):
1403             # If the object is a "regular file", then it's a leaf in
1404             # the graph, so we can skip reading the data if the caller
1405             # hasn't requested it.
1406             yield WalkItem(oid=oid, type=b'blob',
1407                            chunk_path=chunk_path, path=parent_path,
1408                            mode=mode,
1409                            data=None)
1410             continue
1411
1412         item_it = get_ref(oidx)
1413         get_oidx, typ, _ = next(item_it)
1414         if not get_oidx:
1415             raise MissingObject(unhexlify(oidx))
1416         if typ not in (b'blob', b'commit', b'tree'):
1417             raise Exception('unexpected repository object type %r' % typ)
1418
1419         # FIXME: set the mode based on the type when the mode is None
1420         if typ == b'blob' and not include_data:
1421             # Dump data until we can ask cat_pipe not to fetch it
1422             for ignored in item_it:
1423                 pass
1424             data = None
1425         else:
1426             data = b''.join(item_it)
1427
1428         yield WalkItem(oid=oid, type=typ,
1429                        chunk_path=chunk_path, path=parent_path,
1430                        mode=mode,
1431                        data=(data if include_data else None))
1432
1433         if typ == b'commit':
1434             commit_items = parse_commit(data)
1435             for pid in commit_items.parents:
1436                 pending.append((pid, parent_path, chunk_path, mode))
1437             pending.append((commit_items.tree, parent_path, chunk_path,
1438                             hashsplit.GIT_MODE_TREE))
1439         elif typ == b'tree':
1440             for mode, name, ent_id in tree_decode(data):
1441                 demangled, bup_type = demangle_name(name, mode)
1442                 if chunk_path:
1443                     sub_path = parent_path
1444                     sub_chunk_path = chunk_path + [name]
1445                 else:
1446                     sub_path = parent_path + [name]
1447                     if bup_type == BUP_CHUNKED:
1448                         sub_chunk_path = [b'']
1449                     else:
1450                         sub_chunk_path = chunk_path
1451                 pending.append((hexlify(ent_id), sub_path, sub_chunk_path,
1452                                 mode))