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