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