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