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