]> arthur.barton.de Git - bup.git/blob - lib/bup/git.py
git.py: flush idx_map before close so FILE* operations will see changes.
[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 import os, sys, zlib, time, subprocess, struct, stat, re, tempfile, glob
6 from bup.helpers import *
7 from bup import _helpers, path, midx, bloom, xstat
8
9 max_pack_size = 1000*1000*1000  # larger packs will slow down pruning
10 max_pack_objects = 200*1000  # cache memory usage is about 83 bytes per object
11
12 verbose = 0
13 ignore_midx = 0
14 home_repodir = os.path.expanduser('~/.bup')
15 repodir = None
16
17 _typemap =  { 'blob':3, 'tree':2, 'commit':1, 'tag':4 }
18 _typermap = { 3:'blob', 2:'tree', 1:'commit', 4:'tag' }
19
20 _total_searches = 0
21 _total_steps = 0
22
23
24 class GitError(Exception):
25     pass
26
27
28 def repo(sub = ''):
29     """Get the path to the git repository or one of its subdirectories."""
30     global repodir
31     if not repodir:
32         raise GitError('You should call check_repo_or_die()')
33
34     # If there's a .git subdirectory, then the actual repo is in there.
35     gd = os.path.join(repodir, '.git')
36     if os.path.exists(gd):
37         repodir = gd
38
39     return os.path.join(repodir, sub)
40
41
42 def shorten_hash(s):
43     return re.sub(r'([^0-9a-z]|\b)([0-9a-z]{7})[0-9a-z]{33}([^0-9a-z]|\b)',
44                   r'\1\2*\3', s)
45
46
47 def repo_rel(path):
48     full = os.path.abspath(path)
49     fullrepo = os.path.abspath(repo(''))
50     if not fullrepo.endswith('/'):
51         fullrepo += '/'
52     if full.startswith(fullrepo):
53         path = full[len(fullrepo):]
54     if path.startswith('index-cache/'):
55         path = path[len('index-cache/'):]
56     return shorten_hash(path)
57
58
59 def all_packdirs():
60     paths = [repo('objects/pack')]
61     paths += glob.glob(repo('index-cache/*/.'))
62     return paths
63
64
65 def auto_midx(objdir):
66     args = [path.exe(), 'midx', '--auto', '--dir', objdir]
67     try:
68         rv = subprocess.call(args, stdout=open('/dev/null', 'w'))
69     except OSError, e:
70         # make sure 'args' gets printed to help with debugging
71         add_error('%r: exception: %s' % (args, e))
72         raise
73     if rv:
74         add_error('%r: returned %d' % (args, rv))
75
76     args = [path.exe(), 'bloom', '--dir', objdir]
77     try:
78         rv = subprocess.call(args, stdout=open('/dev/null', 'w'))
79     except OSError, e:
80         # make sure 'args' gets printed to help with debugging
81         add_error('%r: exception: %s' % (args, e))
82         raise
83     if rv:
84         add_error('%r: returned %d' % (args, rv))
85
86
87 def mangle_name(name, mode, gitmode):
88     """Mangle a file name to present an abstract name for segmented files.
89     Mangled file names will have the ".bup" extension added to them. If a
90     file's name already ends with ".bup", a ".bupl" extension is added to
91     disambiguate normal files from semgmented ones.
92     """
93     if stat.S_ISREG(mode) and not stat.S_ISREG(gitmode):
94         return name + '.bup'
95     elif name.endswith('.bup') or name[:-1].endswith('.bup'):
96         return name + '.bupl'
97     else:
98         return name
99
100
101 (BUP_NORMAL, BUP_CHUNKED) = (0,1)
102 def demangle_name(name):
103     """Remove name mangling from a file name, if necessary.
104
105     The return value is a tuple (demangled_filename,mode), where mode is one of
106     the following:
107
108     * BUP_NORMAL  : files that should be read as-is from the repository
109     * BUP_CHUNKED : files that were chunked and need to be assembled
110
111     For more information on the name mangling algorythm, see mangle_name()
112     """
113     if name.endswith('.bupl'):
114         return (name[:-5], BUP_NORMAL)
115     elif name.endswith('.bup'):
116         return (name[:-4], BUP_CHUNKED)
117     else:
118         return (name, BUP_NORMAL)
119
120
121 def calc_hash(type, content):
122     """Calculate some content's hash in the Git fashion."""
123     header = '%s %d\0' % (type, len(content))
124     sum = Sha1(header)
125     sum.update(content)
126     return sum.digest()
127
128
129 def shalist_item_sort_key(ent):
130     (mode, name, id) = ent
131     assert(mode+0 == mode)
132     if stat.S_ISDIR(mode):
133         return name + '/'
134     else:
135         return name
136
137
138 def tree_encode(shalist):
139     """Generate a git tree object from (mode,name,hash) tuples."""
140     shalist = sorted(shalist, key = shalist_item_sort_key)
141     l = []
142     for (mode,name,bin) in shalist:
143         assert(mode)
144         assert(mode+0 == mode)
145         assert(name)
146         assert(len(bin) == 20)
147         s = '%o %s\0%s' % (mode,name,bin)
148         assert(s[0] != '0')  # 0-padded octal is not acceptable in a git tree
149         l.append(s)
150     return ''.join(l)
151
152
153 def tree_decode(buf):
154     """Generate a list of (mode,name,hash) from the git tree object in buf."""
155     ofs = 0
156     while ofs < len(buf):
157         z = buf.find('\0', ofs)
158         assert(z > ofs)
159         spl = buf[ofs:z].split(' ', 1)
160         assert(len(spl) == 2)
161         mode,name = spl
162         sha = buf[z+1:z+1+20]
163         ofs = z+1+20
164         yield (int(mode, 8), name, sha)
165
166
167 def _encode_packobj(type, content, compression_level=1):
168     szout = ''
169     sz = len(content)
170     szbits = (sz & 0x0f) | (_typemap[type]<<4)
171     sz >>= 4
172     while 1:
173         if sz: szbits |= 0x80
174         szout += chr(szbits)
175         if not sz:
176             break
177         szbits = sz & 0x7f
178         sz >>= 7
179     if compression_level > 9:
180         compression_level = 9
181     elif compression_level < 0:
182         compression_level = 0
183     z = zlib.compressobj(compression_level)
184     yield szout
185     yield z.compress(content)
186     yield z.flush()
187
188
189 def _encode_looseobj(type, content, compression_level=1):
190     z = zlib.compressobj(compression_level)
191     yield z.compress('%s %d\0' % (type, len(content)))
192     yield z.compress(content)
193     yield z.flush()
194
195
196 def _decode_looseobj(buf):
197     assert(buf);
198     s = zlib.decompress(buf)
199     i = s.find('\0')
200     assert(i > 0)
201     l = s[:i].split(' ')
202     type = l[0]
203     sz = int(l[1])
204     content = s[i+1:]
205     assert(type in _typemap)
206     assert(sz == len(content))
207     return (type, content)
208
209
210 def _decode_packobj(buf):
211     assert(buf)
212     c = ord(buf[0])
213     type = _typermap[(c & 0x70) >> 4]
214     sz = c & 0x0f
215     shift = 4
216     i = 0
217     while c & 0x80:
218         i += 1
219         c = ord(buf[i])
220         sz |= (c & 0x7f) << shift
221         shift += 7
222         if not (c & 0x80):
223             break
224     return (type, zlib.decompress(buf[i+1:]))
225
226
227 class PackIdx:
228     def __init__(self):
229         assert(0)
230
231     def find_offset(self, hash):
232         """Get the offset of an object inside the index file."""
233         idx = self._idx_from_hash(hash)
234         if idx != None:
235             return self._ofs_from_idx(idx)
236         return None
237
238     def exists(self, hash, want_source=False):
239         """Return nonempty if the object exists in this index."""
240         if hash and (self._idx_from_hash(hash) != None):
241             return want_source and os.path.basename(self.name) or True
242         return None
243
244     def __len__(self):
245         return int(self.fanout[255])
246
247     def _idx_from_hash(self, hash):
248         global _total_searches, _total_steps
249         _total_searches += 1
250         assert(len(hash) == 20)
251         b1 = ord(hash[0])
252         start = self.fanout[b1-1] # range -1..254
253         end = self.fanout[b1] # range 0..255
254         want = str(hash)
255         _total_steps += 1  # lookup table is a step
256         while start < end:
257             _total_steps += 1
258             mid = start + (end-start)/2
259             v = self._idx_to_hash(mid)
260             if v < want:
261                 start = mid+1
262             elif v > want:
263                 end = mid
264             else: # got it!
265                 return mid
266         return None
267
268
269 class PackIdxV1(PackIdx):
270     """Object representation of a Git pack index (version 1) file."""
271     def __init__(self, filename, f):
272         self.name = filename
273         self.idxnames = [self.name]
274         self.map = mmap_read(f)
275         self.fanout = list(struct.unpack('!256I',
276                                          str(buffer(self.map, 0, 256*4))))
277         self.fanout.append(0)  # entry "-1"
278         nsha = self.fanout[255]
279         self.sha_ofs = 256*4
280         self.shatable = buffer(self.map, self.sha_ofs, nsha*24)
281
282     def _ofs_from_idx(self, idx):
283         return struct.unpack('!I', str(self.shatable[idx*24 : idx*24+4]))[0]
284
285     def _idx_to_hash(self, idx):
286         return str(self.shatable[idx*24+4 : idx*24+24])
287
288     def __iter__(self):
289         for i in xrange(self.fanout[255]):
290             yield buffer(self.map, 256*4 + 24*i + 4, 20)
291
292
293 class PackIdxV2(PackIdx):
294     """Object representation of a Git pack index (version 2) file."""
295     def __init__(self, filename, f):
296         self.name = filename
297         self.idxnames = [self.name]
298         self.map = mmap_read(f)
299         assert(str(self.map[0:8]) == '\377tOc\0\0\0\2')
300         self.fanout = list(struct.unpack('!256I',
301                                          str(buffer(self.map, 8, 256*4))))
302         self.fanout.append(0)  # entry "-1"
303         nsha = self.fanout[255]
304         self.sha_ofs = 8 + 256*4
305         self.shatable = buffer(self.map, self.sha_ofs, nsha*20)
306         self.ofstable = buffer(self.map,
307                                self.sha_ofs + nsha*20 + nsha*4,
308                                nsha*4)
309         self.ofs64table = buffer(self.map,
310                                  8 + 256*4 + nsha*20 + nsha*4 + nsha*4)
311
312     def _ofs_from_idx(self, idx):
313         ofs = struct.unpack('!I', str(buffer(self.ofstable, idx*4, 4)))[0]
314         if ofs & 0x80000000:
315             idx64 = ofs & 0x7fffffff
316             ofs = struct.unpack('!Q',
317                                 str(buffer(self.ofs64table, idx64*8, 8)))[0]
318         return ofs
319
320     def _idx_to_hash(self, idx):
321         return str(self.shatable[idx*20:(idx+1)*20])
322
323     def __iter__(self):
324         for i in xrange(self.fanout[255]):
325             yield buffer(self.map, 8 + 256*4 + 20*i, 20)
326
327
328 _mpi_count = 0
329 class PackIdxList:
330     def __init__(self, dir):
331         global _mpi_count
332         assert(_mpi_count == 0) # these things suck tons of VM; don't waste it
333         _mpi_count += 1
334         self.dir = dir
335         self.also = set()
336         self.packs = []
337         self.do_bloom = False
338         self.bloom = None
339         self.refresh()
340
341     def __del__(self):
342         global _mpi_count
343         _mpi_count -= 1
344         assert(_mpi_count == 0)
345
346     def __iter__(self):
347         return iter(idxmerge(self.packs))
348
349     def __len__(self):
350         return sum(len(pack) for pack in self.packs)
351
352     def exists(self, hash, want_source=False):
353         """Return nonempty if the object exists in the index files."""
354         global _total_searches
355         _total_searches += 1
356         if hash in self.also:
357             return True
358         if self.do_bloom and self.bloom:
359             if self.bloom.exists(hash):
360                 self.do_bloom = False
361             else:
362                 _total_searches -= 1  # was counted by bloom
363                 return None
364         for i in xrange(len(self.packs)):
365             p = self.packs[i]
366             _total_searches -= 1  # will be incremented by sub-pack
367             ix = p.exists(hash, want_source=want_source)
368             if ix:
369                 # reorder so most recently used packs are searched first
370                 self.packs = [p] + self.packs[:i] + self.packs[i+1:]
371                 return ix
372         self.do_bloom = True
373         return None
374
375     def refresh(self, skip_midx = False):
376         """Refresh the index list.
377         This method verifies if .midx files were superseded (e.g. all of its
378         contents are in another, bigger .midx file) and removes the superseded
379         files.
380
381         If skip_midx is True, all work on .midx files will be skipped and .midx
382         files will be removed from the list.
383
384         The module-global variable 'ignore_midx' can force this function to
385         always act as if skip_midx was True.
386         """
387         self.bloom = None # Always reopen the bloom as it may have been relaced
388         self.do_bloom = False
389         skip_midx = skip_midx or ignore_midx
390         d = dict((p.name, p) for p in self.packs
391                  if not skip_midx or not isinstance(p, midx.PackMidx))
392         if os.path.exists(self.dir):
393             if not skip_midx:
394                 midxl = []
395                 for ix in self.packs:
396                     if isinstance(ix, midx.PackMidx):
397                         for name in ix.idxnames:
398                             d[os.path.join(self.dir, name)] = ix
399                 for full in glob.glob(os.path.join(self.dir,'*.midx')):
400                     if not d.get(full):
401                         mx = midx.PackMidx(full)
402                         (mxd, mxf) = os.path.split(mx.name)
403                         broken = False
404                         for n in mx.idxnames:
405                             if not os.path.exists(os.path.join(mxd, n)):
406                                 log(('warning: index %s missing\n' +
407                                     '  used by %s\n') % (n, mxf))
408                                 broken = True
409                         if broken:
410                             del mx
411                             unlink(full)
412                         else:
413                             midxl.append(mx)
414                 midxl.sort(key=lambda ix:
415                            (-len(ix), -xstat.stat(ix.name).st_mtime))
416                 for ix in midxl:
417                     any_needed = False
418                     for sub in ix.idxnames:
419                         found = d.get(os.path.join(self.dir, sub))
420                         if not found or isinstance(found, PackIdx):
421                             # doesn't exist, or exists but not in a midx
422                             any_needed = True
423                             break
424                     if any_needed:
425                         d[ix.name] = ix
426                         for name in ix.idxnames:
427                             d[os.path.join(self.dir, name)] = ix
428                     elif not ix.force_keep:
429                         debug1('midx: removing redundant: %s\n'
430                                % os.path.basename(ix.name))
431                         unlink(ix.name)
432             for full in glob.glob(os.path.join(self.dir,'*.idx')):
433                 if not d.get(full):
434                     try:
435                         ix = open_idx(full)
436                     except GitError, e:
437                         add_error(e)
438                         continue
439                     d[full] = ix
440             bfull = os.path.join(self.dir, 'bup.bloom')
441             if self.bloom is None and os.path.exists(bfull):
442                 self.bloom = bloom.ShaBloom(bfull)
443             self.packs = list(set(d.values()))
444             self.packs.sort(lambda x,y: -cmp(len(x),len(y)))
445             if self.bloom and self.bloom.valid() and len(self.bloom) >= len(self):
446                 self.do_bloom = True
447             else:
448                 self.bloom = None
449         debug1('PackIdxList: using %d index%s.\n'
450             % (len(self.packs), len(self.packs)!=1 and 'es' or ''))
451
452     def add(self, hash):
453         """Insert an additional object in the list."""
454         self.also.add(hash)
455
456
457 def open_idx(filename):
458     if filename.endswith('.idx'):
459         f = open(filename, 'rb')
460         header = f.read(8)
461         if header[0:4] == '\377tOc':
462             version = struct.unpack('!I', header[4:8])[0]
463             if version == 2:
464                 return PackIdxV2(filename, f)
465             else:
466                 raise GitError('%s: expected idx file version 2, got %d'
467                                % (filename, version))
468         elif len(header) == 8 and header[0:4] < '\377tOc':
469             return PackIdxV1(filename, f)
470         else:
471             raise GitError('%s: unrecognized idx file header' % filename)
472     elif filename.endswith('.midx'):
473         return midx.PackMidx(filename)
474     else:
475         raise GitError('idx filenames must end with .idx or .midx')
476
477
478 def idxmerge(idxlist, final_progress=True):
479     """Generate a list of all the objects reachable in a PackIdxList."""
480     def pfunc(count, total):
481         qprogress('Reading indexes: %.2f%% (%d/%d)\r'
482                   % (count*100.0/total, count, total))
483     def pfinal(count, total):
484         if final_progress:
485             progress('Reading indexes: %.2f%% (%d/%d), done.\n'
486                      % (100, total, total))
487     return merge_iter(idxlist, 10024, pfunc, pfinal)
488
489
490 def _make_objcache():
491     return PackIdxList(repo('objects/pack'))
492
493 class PackWriter:
494     """Writes Git objects inside a pack file."""
495     def __init__(self, objcache_maker=_make_objcache, compression_level=1):
496         self.count = 0
497         self.outbytes = 0
498         self.filename = None
499         self.file = None
500         self.idx = None
501         self.objcache_maker = objcache_maker
502         self.objcache = None
503         self.compression_level = compression_level
504
505     def __del__(self):
506         self.close()
507
508     def _open(self):
509         if not self.file:
510             (fd,name) = tempfile.mkstemp(suffix='.pack', dir=repo('objects'))
511             self.file = os.fdopen(fd, 'w+b')
512             assert(name.endswith('.pack'))
513             self.filename = name[:-5]
514             self.file.write('PACK\0\0\0\2\0\0\0\0')
515             self.idx = list(list() for i in xrange(256))
516
517     def _raw_write(self, datalist, sha):
518         self._open()
519         f = self.file
520         # in case we get interrupted (eg. KeyboardInterrupt), it's best if
521         # the file never has a *partial* blob.  So let's make sure it's
522         # all-or-nothing.  (The blob shouldn't be very big anyway, thanks
523         # to our hashsplit algorithm.)  f.write() does its own buffering,
524         # but that's okay because we'll flush it in _end().
525         oneblob = ''.join(datalist)
526         try:
527             f.write(oneblob)
528         except IOError, e:
529             raise GitError, e, sys.exc_info()[2]
530         nw = len(oneblob)
531         crc = zlib.crc32(oneblob) & 0xffffffff
532         self._update_idx(sha, crc, nw)
533         self.outbytes += nw
534         self.count += 1
535         return nw, crc
536
537     def _update_idx(self, sha, crc, size):
538         assert(sha)
539         if self.idx:
540             self.idx[ord(sha[0])].append((sha, crc, self.file.tell() - size))
541
542     def _write(self, sha, type, content):
543         if verbose:
544             log('>')
545         if not sha:
546             sha = calc_hash(type, content)
547         size, crc = self._raw_write(_encode_packobj(type, content,
548                                                     self.compression_level),
549                                     sha=sha)
550         if self.outbytes >= max_pack_size or self.count >= max_pack_objects:
551             self.breakpoint()
552         return sha
553
554     def breakpoint(self):
555         """Clear byte and object counts and return the last processed id."""
556         id = self._end()
557         self.outbytes = self.count = 0
558         return id
559
560     def _require_objcache(self):
561         if self.objcache is None and self.objcache_maker:
562             self.objcache = self.objcache_maker()
563         if self.objcache is None:
564             raise GitError(
565                     "PackWriter not opened or can't check exists w/o objcache")
566
567     def exists(self, id, want_source=False):
568         """Return non-empty if an object is found in the object cache."""
569         self._require_objcache()
570         return self.objcache.exists(id, want_source=want_source)
571
572     def maybe_write(self, type, content):
573         """Write an object to the pack file if not present and return its id."""
574         sha = calc_hash(type, content)
575         if not self.exists(sha):
576             self._write(sha, type, content)
577             self._require_objcache()
578             self.objcache.add(sha)
579         return sha
580
581     def new_blob(self, blob):
582         """Create a blob object in the pack with the supplied content."""
583         return self.maybe_write('blob', blob)
584
585     def new_tree(self, shalist):
586         """Create a tree object in the pack."""
587         content = tree_encode(shalist)
588         return self.maybe_write('tree', content)
589
590     def _new_commit(self, tree, parent, author, adate, committer, cdate, msg):
591         l = []
592         if tree: l.append('tree %s' % tree.encode('hex'))
593         if parent: l.append('parent %s' % parent.encode('hex'))
594         if author: l.append('author %s %s' % (author, _git_date(adate)))
595         if committer: l.append('committer %s %s' % (committer, _git_date(cdate)))
596         l.append('')
597         l.append(msg)
598         return self.maybe_write('commit', '\n'.join(l))
599
600     def new_commit(self, parent, tree, date, msg):
601         """Create a commit object in the pack."""
602         userline = '%s <%s@%s>' % (userfullname(), username(), hostname())
603         commit = self._new_commit(tree, parent,
604                                   userline, date, userline, date,
605                                   msg)
606         return commit
607
608     def abort(self):
609         """Remove the pack file from disk."""
610         f = self.file
611         if f:
612             self.idx = None
613             self.file = None
614             f.close()
615             os.unlink(self.filename + '.pack')
616
617     def _end(self, run_midx=True):
618         f = self.file
619         if not f: return None
620         self.file = None
621         self.objcache = None
622         idx = self.idx
623         self.idx = None
624
625         # update object count
626         f.seek(8)
627         cp = struct.pack('!i', self.count)
628         assert(len(cp) == 4)
629         f.write(cp)
630
631         # calculate the pack sha1sum
632         f.seek(0)
633         sum = Sha1()
634         for b in chunkyreader(f):
635             sum.update(b)
636         packbin = sum.digest()
637         f.write(packbin)
638         f.close()
639
640         obj_list_sha = self._write_pack_idx_v2(self.filename + '.idx', idx, packbin)
641
642         nameprefix = repo('objects/pack/pack-%s' % obj_list_sha)
643         if os.path.exists(self.filename + '.map'):
644             os.unlink(self.filename + '.map')
645         os.rename(self.filename + '.pack', nameprefix + '.pack')
646         os.rename(self.filename + '.idx', nameprefix + '.idx')
647
648         if run_midx:
649             auto_midx(repo('objects/pack'))
650         return nameprefix
651
652     def close(self, run_midx=True):
653         """Close the pack file and move it to its definitive path."""
654         return self._end(run_midx=run_midx)
655
656     def _write_pack_idx_v2(self, filename, idx, packbin):
657         idx_f = open(filename, 'w+b')
658         idx_f.write('\377tOc\0\0\0\2')
659
660         ofs64_ofs = 8 + 4*256 + 28*self.count
661         idx_f.truncate(ofs64_ofs)
662         idx_f.seek(0)
663         idx_map = mmap_readwrite(idx_f, close=False)
664         idx_f.seek(0, os.SEEK_END)
665         count = _helpers.write_idx(idx_f, idx_map, idx, self.count)
666         assert(count == self.count)
667         # Sync, since it doesn't look like POSIX guarantees that a
668         # matching FILE* (i.e. idx_f) will see the parallel changes if
669         # we don't.
670         idx_map.flush()
671         idx_map.close()
672         idx_f.write(packbin)
673
674         idx_f.seek(0)
675         idx_sum = Sha1()
676         b = idx_f.read(8 + 4*256)
677         idx_sum.update(b)
678
679         obj_list_sum = Sha1()
680         for b in chunkyreader(idx_f, 20*self.count):
681             idx_sum.update(b)
682             obj_list_sum.update(b)
683         namebase = obj_list_sum.hexdigest()
684
685         for b in chunkyreader(idx_f):
686             idx_sum.update(b)
687         idx_f.write(idx_sum.digest())
688         idx_f.close()
689
690         return namebase
691
692
693 def _git_date(date):
694     return '%d %s' % (date, time.strftime('%z', time.localtime(date)))
695
696
697 def _gitenv():
698     os.environ['GIT_DIR'] = os.path.abspath(repo())
699
700
701 def list_refs(refname = None):
702     """Generate a list of tuples in the form (refname,hash).
703     If a ref name is specified, list only this particular ref.
704     """
705     argv = ['git', 'show-ref', '--']
706     if refname:
707         argv += [refname]
708     p = subprocess.Popen(argv, preexec_fn = _gitenv, stdout = subprocess.PIPE)
709     out = p.stdout.read().strip()
710     rv = p.wait()  # not fatal
711     if rv:
712         assert(not out)
713     if out:
714         for d in out.split('\n'):
715             (sha, name) = d.split(' ', 1)
716             yield (name, sha.decode('hex'))
717
718
719 def read_ref(refname):
720     """Get the commit id of the most recent commit made on a given ref."""
721     l = list(list_refs(refname))
722     if l:
723         assert(len(l) == 1)
724         return l[0][1]
725     else:
726         return None
727
728
729 def rev_list(ref, count=None):
730     """Generate a list of reachable commits in reverse chronological order.
731
732     This generator walks through commits, from child to parent, that are
733     reachable via the specified ref and yields a series of tuples of the form
734     (date,hash).
735
736     If count is a non-zero integer, limit the number of commits to "count"
737     objects.
738     """
739     assert(not ref.startswith('-'))
740     opts = []
741     if count:
742         opts += ['-n', str(atoi(count))]
743     argv = ['git', 'rev-list', '--pretty=format:%ct'] + opts + [ref, '--']
744     p = subprocess.Popen(argv, preexec_fn = _gitenv, stdout = subprocess.PIPE)
745     commit = None
746     for row in p.stdout:
747         s = row.strip()
748         if s.startswith('commit '):
749             commit = s[7:].decode('hex')
750         else:
751             date = int(s)
752             yield (date, commit)
753     rv = p.wait()  # not fatal
754     if rv:
755         raise GitError, 'git rev-list returned error %d' % rv
756
757
758 def rev_get_date(ref):
759     """Get the date of the latest commit on the specified ref."""
760     for (date, commit) in rev_list(ref, count=1):
761         return date
762     raise GitError, 'no such commit %r' % ref
763
764
765 def rev_parse(committish):
766     """Resolve the full hash for 'committish', if it exists.
767
768     Should be roughly equivalent to 'git rev-parse'.
769
770     Returns the hex value of the hash if it is found, None if 'committish' does
771     not correspond to anything.
772     """
773     head = read_ref(committish)
774     if head:
775         debug2("resolved from ref: commit = %s\n" % head.encode('hex'))
776         return head
777
778     pL = PackIdxList(repo('objects/pack'))
779
780     if len(committish) == 40:
781         try:
782             hash = committish.decode('hex')
783         except TypeError:
784             return None
785
786         if pL.exists(hash):
787             return hash
788
789     return None
790
791
792 def update_ref(refname, newval, oldval):
793     """Change the commit pointed to by a branch."""
794     if not oldval:
795         oldval = ''
796     assert(refname.startswith('refs/heads/'))
797     p = subprocess.Popen(['git', 'update-ref', refname,
798                           newval.encode('hex'), oldval.encode('hex')],
799                          preexec_fn = _gitenv)
800     _git_wait('git update-ref', p)
801
802
803 def guess_repo(path=None):
804     """Set the path value in the global variable "repodir".
805     This makes bup look for an existing bup repository, but not fail if a
806     repository doesn't exist. Usually, if you are interacting with a bup
807     repository, you would not be calling this function but using
808     check_repo_or_die().
809     """
810     global repodir
811     if path:
812         repodir = path
813     if not repodir:
814         repodir = os.environ.get('BUP_DIR')
815         if not repodir:
816             repodir = os.path.expanduser('~/.bup')
817
818
819 def init_repo(path=None):
820     """Create the Git bare repository for bup in a given path."""
821     guess_repo(path)
822     d = repo()  # appends a / to the path
823     parent = os.path.dirname(os.path.dirname(d))
824     if parent and not os.path.exists(parent):
825         raise GitError('parent directory "%s" does not exist\n' % parent)
826     if os.path.exists(d) and not os.path.isdir(os.path.join(d, '.')):
827         raise GitError('"%s" exists but is not a directory\n' % d)
828     p = subprocess.Popen(['git', '--bare', 'init'], stdout=sys.stderr,
829                          preexec_fn = _gitenv)
830     _git_wait('git init', p)
831     # Force the index version configuration in order to ensure bup works
832     # regardless of the version of the installed Git binary.
833     p = subprocess.Popen(['git', 'config', 'pack.indexVersion', '2'],
834                          stdout=sys.stderr, preexec_fn = _gitenv)
835     _git_wait('git config', p)
836     # Enable the reflog
837     p = subprocess.Popen(['git', 'config', 'core.logAllRefUpdates', 'true'],
838                          stdout=sys.stderr, preexec_fn = _gitenv)
839     _git_wait('git config', p)
840
841
842 def check_repo_or_die(path=None):
843     """Make sure a bup repository exists, and abort if not.
844     If the path to a particular repository was not specified, this function
845     initializes the default repository automatically.
846     """
847     guess_repo(path)
848     try:
849         os.stat(repo('objects/pack/.'))
850     except OSError, e:
851         if e.errno == errno.ENOENT:
852             if repodir != home_repodir:
853                 log('error: %r is not a bup repository; run "bup init"\n'
854                     % repo())
855                 sys.exit(15)
856             else:
857                 init_repo()
858         else:
859             log('error: %s\n' % e)
860             sys.exit(14)
861
862
863 _ver = None
864 def ver():
865     """Get Git's version and ensure a usable version is installed.
866
867     The returned version is formatted as an ordered tuple with each position
868     representing a digit in the version tag. For example, the following tuple
869     would represent version 1.6.6.9:
870
871         ('1', '6', '6', '9')
872     """
873     global _ver
874     if not _ver:
875         p = subprocess.Popen(['git', '--version'],
876                              stdout=subprocess.PIPE)
877         gvs = p.stdout.read()
878         _git_wait('git --version', p)
879         m = re.match(r'git version (\S+.\S+)', gvs)
880         if not m:
881             raise GitError('git --version weird output: %r' % gvs)
882         _ver = tuple(m.group(1).split('.'))
883     needed = ('1','5', '3', '1')
884     if _ver < needed:
885         raise GitError('git version %s or higher is required; you have %s'
886                        % ('.'.join(needed), '.'.join(_ver)))
887     return _ver
888
889
890 def _git_wait(cmd, p):
891     rv = p.wait()
892     if rv != 0:
893         raise GitError('%s returned %d' % (cmd, rv))
894
895
896 def _git_capture(argv):
897     p = subprocess.Popen(argv, stdout=subprocess.PIPE, preexec_fn = _gitenv)
898     r = p.stdout.read()
899     _git_wait(repr(argv), p)
900     return r
901
902
903 class _AbortableIter:
904     def __init__(self, it, onabort = None):
905         self.it = it
906         self.onabort = onabort
907         self.done = None
908
909     def __iter__(self):
910         return self
911
912     def next(self):
913         try:
914             return self.it.next()
915         except StopIteration, e:
916             self.done = True
917             raise
918         except:
919             self.abort()
920             raise
921
922     def abort(self):
923         """Abort iteration and call the abortion callback, if needed."""
924         if not self.done:
925             self.done = True
926             if self.onabort:
927                 self.onabort()
928
929     def __del__(self):
930         self.abort()
931
932
933 _ver_warned = 0
934 class CatPipe:
935     """Link to 'git cat-file' that is used to retrieve blob data."""
936     def __init__(self):
937         global _ver_warned
938         wanted = ('1','5','6')
939         if ver() < wanted:
940             if not _ver_warned:
941                 log('warning: git version < %s; bup will be slow.\n'
942                     % '.'.join(wanted))
943                 _ver_warned = 1
944             self.get = self._slow_get
945         else:
946             self.p = self.inprogress = None
947             self.get = self._fast_get
948
949     def _abort(self):
950         if self.p:
951             self.p.stdout.close()
952             self.p.stdin.close()
953         self.p = None
954         self.inprogress = None
955
956     def _restart(self):
957         self._abort()
958         self.p = subprocess.Popen(['git', 'cat-file', '--batch'],
959                                   stdin=subprocess.PIPE,
960                                   stdout=subprocess.PIPE,
961                                   close_fds = True,
962                                   bufsize = 4096,
963                                   preexec_fn = _gitenv)
964
965     def _fast_get(self, id):
966         if not self.p or self.p.poll() != None:
967             self._restart()
968         assert(self.p)
969         poll_result = self.p.poll()
970         assert(poll_result == None)
971         if self.inprogress:
972             log('_fast_get: opening %r while %r is open\n'
973                 % (id, self.inprogress))
974         assert(not self.inprogress)
975         assert(id.find('\n') < 0)
976         assert(id.find('\r') < 0)
977         assert(not id.startswith('-'))
978         self.inprogress = id
979         self.p.stdin.write('%s\n' % id)
980         self.p.stdin.flush()
981         hdr = self.p.stdout.readline()
982         if hdr.endswith(' missing\n'):
983             self.inprogress = None
984             raise KeyError('blob %r is missing' % id)
985         spl = hdr.split(' ')
986         if len(spl) != 3 or len(spl[0]) != 40:
987             raise GitError('expected blob, got %r' % spl)
988         (hex, type, size) = spl
989
990         it = _AbortableIter(chunkyreader(self.p.stdout, int(spl[2])),
991                            onabort = self._abort)
992         try:
993             yield type
994             for blob in it:
995                 yield blob
996             readline_result = self.p.stdout.readline()
997             assert(readline_result == '\n')
998             self.inprogress = None
999         except Exception, e:
1000             it.abort()
1001             raise
1002
1003     def _slow_get(self, id):
1004         assert(id.find('\n') < 0)
1005         assert(id.find('\r') < 0)
1006         assert(id[0] != '-')
1007         type = _git_capture(['git', 'cat-file', '-t', id]).strip()
1008         yield type
1009
1010         p = subprocess.Popen(['git', 'cat-file', type, id],
1011                              stdout=subprocess.PIPE,
1012                              preexec_fn = _gitenv)
1013         for blob in chunkyreader(p.stdout):
1014             yield blob
1015         _git_wait('git cat-file', p)
1016
1017     def _join(self, it):
1018         type = it.next()
1019         if type == 'blob':
1020             for blob in it:
1021                 yield blob
1022         elif type == 'tree':
1023             treefile = ''.join(it)
1024             for (mode, name, sha) in tree_decode(treefile):
1025                 for blob in self.join(sha.encode('hex')):
1026                     yield blob
1027         elif type == 'commit':
1028             treeline = ''.join(it).split('\n')[0]
1029             assert(treeline.startswith('tree '))
1030             for blob in self.join(treeline[5:]):
1031                 yield blob
1032         else:
1033             raise GitError('invalid object type %r: expected blob/tree/commit'
1034                            % type)
1035
1036     def join(self, id):
1037         """Generate a list of the content of all blobs that can be reached
1038         from an object.  The hash given in 'id' must point to a blob, a tree
1039         or a commit. The content of all blobs that can be seen from trees or
1040         commits will be added to the list.
1041         """
1042         try:
1043             for d in self._join(self.get(id)):
1044                 yield d
1045         except StopIteration:
1046             log('booger!\n')
1047
1048 def tags():
1049     """Return a dictionary of all tags in the form {hash: [tag_names, ...]}."""
1050     tags = {}
1051     for (n,c) in list_refs():
1052         if n.startswith('refs/tags/'):
1053             name = n[10:]
1054             if not c in tags:
1055                 tags[c] = []
1056
1057             tags[c].append(name)  # more than one tag can point at 'c'
1058
1059     return tags