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