]> git.notmuchmail.org Git - notmuch/blob - notmuch-git.py
CLI/git: cache git indices
[notmuch] / notmuch-git.py
1 #!/usr/bin/env python3
2 #
3 # Copyright (c) 2011-2014 David Bremner <david@tethera.net>
4 #                         W. Trevor King <wking@tremily.us>
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program.  If not, see https://www.gnu.org/licenses/ .
18
19 """
20 Manage notmuch tags with Git
21 """
22
23 from __future__ import print_function
24 from __future__ import unicode_literals
25
26 import codecs as _codecs
27 import collections as _collections
28 import functools as _functools
29 import inspect as _inspect
30 import locale as _locale
31 import logging as _logging
32 import os as _os
33 import re as _re
34 import shutil as _shutil
35 import subprocess as _subprocess
36 import sys as _sys
37 import tempfile as _tempfile
38 import textwrap as _textwrap
39 from urllib.parse import quote as _quote
40 from urllib.parse import unquote as _unquote
41 import json as _json
42
43 _LOG = _logging.getLogger('nmbug')
44 _LOG.setLevel(_logging.WARNING)
45 _LOG.addHandler(_logging.StreamHandler())
46
47 NOTMUCH_GIT_DIR = None
48 TAG_PREFIX = None
49
50 _HEX_ESCAPE_REGEX = _re.compile('%[0-9A-F]{2}')
51 _TAG_DIRECTORY = 'tags/'
52 _TAG_FILE_REGEX = _re.compile(_TAG_DIRECTORY + '(?P<id>[^/]*)/(?P<tag>[^/]*)')
53
54 # magic hash for Git (git hash-object -t blob /dev/null)
55 _EMPTYBLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
56
57 def _hex_quote(string, safe='+@=:,'):
58     """
59     quote('abc def') -> 'abc%20def'.
60
61     Wrap urllib.parse.quote with additional safe characters (in
62     addition to letters, digits, and '_.-') and lowercase hex digits
63     (e.g. '%3a' instead of '%3A').
64     """
65     uppercase_escapes = _quote(string, safe)
66     return _HEX_ESCAPE_REGEX.sub(
67         lambda match: match.group(0).lower(),
68         uppercase_escapes)
69
70 def _xapian_quote(string):
71     """
72     Quote a string for Xapian's QueryParser.
73
74     Xapian uses double-quotes for quoting strings.  You can escape
75     internal quotes by repeating them [1,2,3].
76
77     [1]: https://trac.xapian.org/ticket/128#comment:2
78     [2]: https://trac.xapian.org/ticket/128#comment:17
79     [3]: https://trac.xapian.org/changeset/13823/svn
80     """
81     return '"{0}"'.format(string.replace('"', '""'))
82
83
84 def _xapian_unquote(string):
85     """
86     Unquote a Xapian-quoted string.
87     """
88     if string.startswith('"') and string.endswith('"'):
89         return string[1:-1].replace('""', '"')
90     return string
91
92
93 def timed(fn):
94     """Timer decorator"""
95     from time import perf_counter
96
97     def inner(*args, **kwargs):
98         start_time = perf_counter()
99         rval = fn(*args, **kwargs)
100         end_time = perf_counter()
101         _LOG.info('{0}: {1:.8f}s elapsed'.format(fn.__name__, end_time - start_time))
102         return rval
103
104     return inner
105
106
107 class SubprocessError(RuntimeError):
108     "A subprocess exited with a nonzero status"
109     def __init__(self, args, status, stdout=None, stderr=None):
110         self.status = status
111         self.stdout = stdout
112         self.stderr = stderr
113         msg = '{args} exited with {status}'.format(args=args, status=status)
114         if stderr:
115             msg = '{msg}: {stderr}'.format(msg=msg, stderr=stderr)
116         super(SubprocessError, self).__init__(msg)
117
118
119 class _SubprocessContextManager(object):
120     """
121     PEP 343 context manager for subprocesses.
122
123     'expect' holds a tuple of acceptable exit codes, otherwise we'll
124     raise a SubprocessError in __exit__.
125     """
126     def __init__(self, process, args, expect=(0,)):
127         self._process = process
128         self._args = args
129         self._expect = expect
130
131     def __enter__(self):
132         return self._process
133
134     def __exit__(self, type, value, traceback):
135         for name in ['stdin', 'stdout', 'stderr']:
136             stream = getattr(self._process, name)
137             if stream:
138                 stream.close()
139                 setattr(self._process, name, None)
140         status = self._process.wait()
141         _LOG.debug(
142             'collect {args} with status {status} (expected {expect})'.format(
143                 args=self._args, status=status, expect=self._expect))
144         if status not in self._expect:
145             raise SubprocessError(args=self._args, status=status)
146
147     def wait(self):
148         return self._process.wait()
149
150
151 def _spawn(args, input=None, additional_env=None, wait=False, stdin=None,
152            stdout=None, stderr=None, encoding=_locale.getpreferredencoding(),
153            expect=(0,), **kwargs):
154     """Spawn a subprocess, and optionally wait for it to finish.
155
156     This wrapper around subprocess.Popen has two modes, depending on
157     the truthiness of 'wait'.  If 'wait' is true, we use p.communicate
158     internally to write 'input' to the subprocess's stdin and read
159     from it's stdout/stderr.  If 'wait' is False, we return a
160     _SubprocessContextManager instance for fancier handling
161     (e.g. piping between processes).
162
163     For 'wait' calls when you want to write to the subprocess's stdin,
164     you only need to set 'input' to your content.  When 'input' is not
165     None but 'stdin' is, we'll automatically set 'stdin' to PIPE
166     before calling Popen.  This avoids having the subprocess
167     accidentally inherit the launching process's stdin.
168     """
169     _LOG.debug('spawn {args} (additional env. var.: {env})'.format(
170         args=args, env=additional_env))
171     if not stdin and input is not None:
172         stdin = _subprocess.PIPE
173     if additional_env:
174         if not kwargs.get('env'):
175             kwargs['env'] = dict(_os.environ)
176         kwargs['env'].update(additional_env)
177     p = _subprocess.Popen(
178         args, stdin=stdin, stdout=stdout, stderr=stderr, **kwargs)
179     if wait:
180         if hasattr(input, 'encode'):
181             input = input.encode(encoding)
182         (stdout, stderr) = p.communicate(input=input)
183         status = p.wait()
184         _LOG.debug(
185             'collect {args} with status {status} (expected {expect})'.format(
186                 args=args, status=status, expect=expect))
187         if stdout is not None:
188             stdout = stdout.decode(encoding)
189         if stderr is not None:
190             stderr = stderr.decode(encoding)
191         if status not in expect:
192             raise SubprocessError(
193                 args=args, status=status, stdout=stdout, stderr=stderr)
194         return (status, stdout, stderr)
195     if p.stdin and not stdin:
196         p.stdin.close()
197         p.stdin = None
198     if p.stdin:
199         p.stdin = _codecs.getwriter(encoding=encoding)(stream=p.stdin)
200     stream_reader = _codecs.getreader(encoding=encoding)
201     if p.stdout:
202         p.stdout = stream_reader(stream=p.stdout)
203     if p.stderr:
204         p.stderr = stream_reader(stream=p.stderr)
205     return _SubprocessContextManager(args=args, process=p, expect=expect)
206
207
208 def _git(args, **kwargs):
209     args = ['git', '--git-dir', NOTMUCH_GIT_DIR] + list(args)
210     return _spawn(args=args, **kwargs)
211
212
213 def _get_current_branch():
214     """Get the name of the current branch.
215
216     Return 'None' if we're not on a branch.
217     """
218     try:
219         (status, branch, stderr) = _git(
220             args=['symbolic-ref', '--short', 'HEAD'],
221             stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
222     except SubprocessError as e:
223         if 'not a symbolic ref' in e:
224             return None
225         raise
226     return branch.strip()
227
228
229 def _get_remote():
230     "Get the default remote for the current branch."
231     local_branch = _get_current_branch()
232     (status, remote, stderr) = _git(
233         args=['config', 'branch.{0}.remote'.format(local_branch)],
234         stdout=_subprocess.PIPE, wait=True)
235     return remote.strip()
236
237 def _tag_query(prefix=None):
238     if prefix is None:
239         prefix = TAG_PREFIX
240     return '(tag (starts-with "{:s}"))'.format(prefix.replace('"','\\\"'))
241
242 def get_tags(prefix=None):
243     "Get a list of tags with a given prefix."
244     (status, stdout, stderr) = _spawn(
245         args=['notmuch', 'search', '--query=sexp', '--output=tags', _tag_query(prefix)],
246         stdout=_subprocess.PIPE, wait=True)
247     return [tag for tag in stdout.splitlines()]
248
249 def archive(treeish='HEAD', args=()):
250     """
251     Dump a tar archive of the current nmbug tag set.
252
253     Using 'git archive'.
254
255     Each tag $tag for message with Message-Id $id is written to
256     an empty file
257
258       tags/encode($id)/encode($tag)
259
260     The encoding preserves alphanumerics, and the characters
261     "+-_@=.:," (not the quotes).  All other octets are replaced with
262     '%' followed by a two digit hex number.
263     """
264     _git(args=['archive', treeish] + list(args), wait=True)
265
266
267 def clone(repository):
268     """
269     Create a local nmbug repository from a remote source.
270
271     This wraps 'git clone', adding some options to avoid creating a
272     working tree while preserving remote-tracking branches and
273     upstreams.
274     """
275     with _tempfile.TemporaryDirectory(prefix='nmbug-clone.') as workdir:
276         _spawn(
277             args=[
278                 'git', 'clone', '--no-checkout', '--separate-git-dir', NOTMUCH_GIT_DIR,
279                 repository, workdir],
280             wait=True)
281     _git(args=['config', '--unset', 'core.worktree'], wait=True, expect=(0, 5))
282     _git(args=['config', 'core.bare', 'true'], wait=True)
283     (status, stdout, stderr) = _git(args=['show-ref', '--verify',
284                                           '--quiet',
285                                           'refs/remotes/origin/config'],
286                                     expect=(0,1),
287                                     wait=True)
288     if status == 0:
289         _git(args=['branch', 'config', 'origin/config'], wait=True)
290     existing_tags = get_tags()
291     if existing_tags:
292         _LOG.warning(
293             'Not checking out to avoid clobbering existing tags: {}'.format(
294             ', '.join(existing_tags)))
295     else:
296         checkout()
297
298
299 def _is_committed(status):
300     return len(status['added']) + len(status['deleted']) == 0
301
302
303 class CachedIndex:
304     def __init__(self, repo, treeish):
305         self.cache_path = _os.path.join(repo, 'notmuch', 'index_cache.json')
306         self.index_path = _os.path.join(repo, 'index')
307         self.current_treeish = treeish
308         # cached values
309         self.treeish = None
310         self.hash = None
311         self.index_checksum = None
312
313         self._load_cache_file()
314
315     def _load_cache_file(self):
316         try:
317             with open(self.cache_path) as f:
318                 data = _json.load(f)
319                 self.treeish = data['treeish']
320                 self.hash = data['hash']
321                 self.index_checksum = data['index_checksum']
322         except FileNotFoundError:
323             pass
324         except _json.JSONDecodeError:
325             _LOG.error("Error decoding cache")
326             _sys.exit(1)
327
328     def __enter__(self):
329         self.read_tree()
330         return self
331
332     def __exit__(self, type, value, traceback):
333         checksum = _read_index_checksum(self.index_path)
334         (_, hash, _) = _git(
335             args=['rev-parse', self.current_treeish],
336             stdout=_subprocess.PIPE,
337             wait=True)
338
339         with open(self.cache_path, "w") as f:
340             _json.dump({'treeish': self.current_treeish,
341                         'hash': hash.rstrip(),  'index_checksum': checksum }, f)
342
343     @timed
344     def read_tree(self):
345         current_checksum = _read_index_checksum(self.index_path)
346         (_, hash, _) = _git(
347             args=['rev-parse', self.current_treeish],
348             stdout=_subprocess.PIPE,
349             wait=True)
350         current_hash = hash.rstrip()
351
352         if self.current_treeish == self.treeish and \
353            self.index_checksum and self.index_checksum == current_checksum and \
354            self.hash and self.hash == current_hash:
355             return
356
357         _git(args=['read-tree', self.current_treeish], wait=True)
358
359
360 def commit(treeish='HEAD', message=None):
361     """
362     Commit prefix-matching tags from the notmuch database to Git.
363     """
364
365     status = get_status()
366
367     if _is_committed(status=status):
368         _LOG.warning('Nothing to commit')
369         return
370
371     with CachedIndex(NOTMUCH_GIT_DIR, treeish) as index:
372         try:
373             _update_index(status=status)
374             (_, tree, _) = _git(
375                 args=['write-tree'],
376                 stdout=_subprocess.PIPE,
377                 wait=True)
378             (_, parent, _) = _git(
379                 args=['rev-parse', treeish],
380                 stdout=_subprocess.PIPE,
381                 wait=True)
382             (_, commit, _) = _git(
383                 args=['commit-tree', tree.strip(), '-p', parent.strip()],
384                 input=message,
385                 stdout=_subprocess.PIPE,
386                 wait=True)
387             _git(
388                 args=['update-ref', treeish, commit.strip()],
389                 stdout=_subprocess.PIPE,
390                 wait=True)
391         except Exception as e:
392             _git(args=['read-tree', '--empty'], wait=True)
393             _git(args=['read-tree', treeish], wait=True)
394             raise
395
396 @timed
397 def _update_index(status):
398     with _git(
399             args=['update-index', '--index-info'],
400             stdin=_subprocess.PIPE) as p:
401         for id, tags in status['deleted'].items():
402             for line in _index_tags_for_message(id=id, status='D', tags=tags):
403                 p.stdin.write(line)
404         for id, tags in status['added'].items():
405             for line in _index_tags_for_message(id=id, status='A', tags=tags):
406                 p.stdin.write(line)
407
408
409 def fetch(remote=None):
410     """
411     Fetch changes from the remote repository.
412
413     See 'merge' to bring those changes into notmuch.
414     """
415     args = ['fetch']
416     if remote:
417         args.append(remote)
418     _git(args=args, wait=True)
419
420
421 def init(remote=None):
422     """
423     Create an empty nmbug repository.
424
425     This wraps 'git init' with a few extra steps to support subsequent
426     status and commit commands.
427     """
428     _spawn(args=['git', '--git-dir', NOTMUCH_GIT_DIR, 'init',
429                  '--initial-branch=master', '--quiet', '--bare'], wait=True)
430     _git(args=['config', 'core.logallrefupdates', 'true'], wait=True)
431     # create an empty blob (e69de29bb2d1d6434b8b29ae775ad8c2e48c5391)
432     _git(args=['hash-object', '-w', '--stdin'], input='', wait=True)
433     _git(
434         args=[
435             'commit', '--allow-empty', '-m', 'Start a new nmbug repository'
436         ],
437         additional_env={'GIT_WORK_TREE': NOTMUCH_GIT_DIR},
438         wait=True)
439
440
441 def checkout():
442     """
443     Update the notmuch database from Git.
444
445     This is mainly useful to discard your changes in notmuch relative
446     to Git.
447     """
448     status = get_status()
449     with _spawn(
450             args=['notmuch', 'tag', '--batch'], stdin=_subprocess.PIPE) as p:
451         for id, tags in status['added'].items():
452             p.stdin.write(_batch_line(action='-', id=id, tags=tags))
453         for id, tags in status['deleted'].items():
454             p.stdin.write(_batch_line(action='+', id=id, tags=tags))
455
456
457 def _batch_line(action, id, tags):
458     """
459     'notmuch tag --batch' line for adding/removing tags.
460
461     Set 'action' to '-' to remove a tag or '+' to add the tags to a
462     given message id.
463     """
464     tag_string = ' '.join(
465         '{action}{prefix}{tag}'.format(
466             action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))
467         for tag in tags)
468     line = '{tags} -- id:{id}\n'.format(
469         tags=tag_string, id=_xapian_quote(string=id))
470     return line
471
472
473 def _insist_committed():
474     "Die if the the notmuch tags don't match the current HEAD."
475     status = get_status()
476     if not _is_committed(status=status):
477         _LOG.error('\n'.join([
478             'Uncommitted changes to {prefix}* tags in notmuch',
479             '',
480             "For a summary of changes, run 'nmbug status'",
481             "To save your changes,     run 'nmbug commit' before merging/pull",
482             "To discard your changes,  run 'nmbug checkout'",
483             ]).format(prefix=TAG_PREFIX))
484         _sys.exit(1)
485
486
487 def pull(repository=None, refspecs=None):
488     """
489     Pull (merge) remote repository changes to notmuch.
490
491     'pull' is equivalent to 'fetch' followed by 'merge'.  We use the
492     Git-configured repository for your current branch
493     (branch.<name>.repository, likely 'origin', and
494     branch.<name>.merge, likely 'master').
495     """
496     _insist_committed()
497     if refspecs and not repository:
498         repository = _get_remote()
499     args = ['pull']
500     if repository:
501         args.append(repository)
502     if refspecs:
503         args.extend(refspecs)
504     with _tempfile.TemporaryDirectory(prefix='nmbug-pull.') as workdir:
505         for command in [
506                 ['reset', '--hard'],
507                 args]:
508             _git(
509                 args=command,
510                 additional_env={'GIT_WORK_TREE': workdir},
511                 wait=True)
512     checkout()
513
514
515 def merge(reference='@{upstream}'):
516     """
517     Merge changes from 'reference' into HEAD and load the result into notmuch.
518
519     The default reference is '@{upstream}'.
520     """
521     _insist_committed()
522     with _tempfile.TemporaryDirectory(prefix='nmbug-merge.') as workdir:
523         for command in [
524                 ['reset', '--hard'],
525                 ['merge', reference]]:
526             _git(
527                 args=command,
528                 additional_env={'GIT_WORK_TREE': workdir},
529                 wait=True)
530     checkout()
531
532
533 def log(args=()):
534     """
535     A simple wrapper for 'git log'.
536
537     After running 'nmbug fetch', you can inspect the changes with
538     'nmbug log HEAD..@{upstream}'.
539     """
540     # we don't want output trapping here, because we want the pager.
541     args = ['log', '--name-status', '--no-renames'] + list(args)
542     with _git(args=args, expect=(0, 1, -13)) as p:
543         p.wait()
544
545
546 def push(repository=None, refspecs=None):
547     "Push the local nmbug Git state to a remote repository."
548     if refspecs and not repository:
549         repository = _get_remote()
550     args = ['push']
551     if repository:
552         args.append(repository)
553     if refspecs:
554         args.extend(refspecs)
555     _git(args=args, wait=True)
556
557
558 def status():
559     """
560     Show pending updates in notmuch or git repo.
561
562     Prints lines of the form
563
564       ng Message-Id tag
565
566     where n is a single character representing notmuch database status
567
568     * A
569
570       Tag is present in notmuch database, but not committed to nmbug
571       (equivalently, tag has been deleted in nmbug repo, e.g. by a
572       pull, but not restored to notmuch database).
573
574     * D
575
576       Tag is present in nmbug repo, but not restored to notmuch
577       database (equivalently, tag has been deleted in notmuch).
578
579     * U
580
581       Message is unknown (missing from local notmuch database).
582
583     The second character (if present) represents a difference between
584     local and upstream branches. Typically 'nmbug fetch' needs to be
585     run to update this.
586
587     * a
588
589       Tag is present in upstream, but not in the local Git branch.
590
591     * d
592
593       Tag is present in local Git branch, but not upstream.
594     """
595     status = get_status()
596     # 'output' is a nested defaultdict for message status:
597     # * The outer dict is keyed by message id.
598     # * The inner dict is keyed by tag name.
599     # * The inner dict values are status strings (' a', 'Dd', ...).
600     output = _collections.defaultdict(
601         lambda : _collections.defaultdict(lambda : ' '))
602     for id, tags in status['added'].items():
603         for tag in tags:
604             output[id][tag] = 'A'
605     for id, tags in status['deleted'].items():
606         for tag in tags:
607             output[id][tag] = 'D'
608     for id, tags in status['missing'].items():
609         for tag in tags:
610             output[id][tag] = 'U'
611     if _is_unmerged():
612         for id, tag in _diff_refs(filter='A'):
613             output[id][tag] += 'a'
614         for id, tag in _diff_refs(filter='D'):
615             output[id][tag] += 'd'
616     for id, tag_status in sorted(output.items()):
617         for tag, status in sorted(tag_status.items()):
618             print('{status}\t{id}\t{tag}'.format(
619                 status=status, id=id, tag=tag))
620
621
622 def _is_unmerged(ref='@{upstream}'):
623     try:
624         (status, fetch_head, stderr) = _git(
625             args=['rev-parse', ref],
626             stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
627     except SubprocessError as e:
628         if 'No upstream configured' in e.stderr:
629             return
630         raise
631     (status, base, stderr) = _git(
632         args=['merge-base', 'HEAD', ref],
633         stdout=_subprocess.PIPE, wait=True)
634     return base != fetch_head
635
636
637 @timed
638 def get_status():
639     status = {
640         'deleted': {},
641         'missing': {},
642         }
643     with PrivateIndex(repo=NOTMUCH_GIT_DIR, prefix=TAG_PREFIX) as index:
644         maybe_deleted = index.diff(filter='D')
645         for id, tags in maybe_deleted.items():
646             (_, stdout, stderr) = _spawn(
647                 args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)],
648                 stdout=_subprocess.PIPE,
649                 wait=True)
650             if stdout:
651                 status['deleted'][id] = tags
652             else:
653                 status['missing'][id] = tags
654         status['added'] = index.diff(filter='A')
655
656     return status
657
658 class PrivateIndex:
659     def __init__(self, repo, prefix):
660         try:
661             _os.makedirs(_os.path.join(repo, 'notmuch'))
662         except FileExistsError:
663             pass
664
665         file_name = 'notmuch/index'
666         self.index_path = _os.path.join(repo, file_name)
667         self.cache_path = _os.path.join(repo, 'notmuch', '{:s}.json'.format(_hex_quote(file_name)))
668
669         self.current_prefix = prefix
670
671         self.prefix = None
672         self.uuid = None
673         self.lastmod = None
674         self.checksum = None
675         self._load_cache_file()
676         self._index_tags()
677
678     def __enter__(self):
679         return self
680
681     def __exit__(self, type, value, traceback):
682         checksum = _read_index_checksum(self.index_path)
683         (count, uuid, lastmod) = _read_database_lastmod()
684         with open(self.cache_path, "w") as f:
685             _json.dump({'prefix': self.current_prefix, 'uuid': uuid, 'lastmod': lastmod,  'checksum': checksum }, f)
686
687     def _load_cache_file(self):
688         try:
689             with open(self.cache_path) as f:
690                 data = _json.load(f)
691                 self.prefix = data['prefix']
692                 self.uuid = data['uuid']
693                 self.lastmod = data['lastmod']
694                 self.checksum = data['checksum']
695         except FileNotFoundError:
696             return None
697         except _json.JSONDecodeError:
698             _LOG.error("Error decoding cache")
699             _sys.exit(1)
700
701     @timed
702     def _index_tags(self):
703         "Write notmuch tags to private git index."
704         prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)
705         current_checksum = _read_index_checksum(self.index_path)
706         if (self.prefix == None or self.prefix != self.current_prefix
707             or self.checksum == None or self.checksum != current_checksum):
708             _git(
709                 args=['read-tree', '--empty'],
710                 additional_env={'GIT_INDEX_FILE': self.index_path}, wait=True)
711
712         query = _tag_query()
713         clear_tags = False
714         (count,uuid,lastmod) = _read_database_lastmod()
715         if self.prefix == self.current_prefix and self.uuid \
716            and self.uuid == uuid and self.checksum == current_checksum:
717             query = '(and (infix "lastmod:{:d}..")) {:s})'.format(self.lastmod+1, query)
718             clear_tags = True
719         with _spawn(
720                 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', query],
721                 stdout=_subprocess.PIPE) as notmuch:
722             with _git(
723                     args=['update-index', '--index-info'],
724                     stdin=_subprocess.PIPE,
725                     additional_env={'GIT_INDEX_FILE': self.index_path}) as git:
726                 for line in notmuch.stdout:
727                     if line.strip().startswith('#'):
728                         continue
729                     (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
730                     tags = [
731                         _unquote(tag[len(prefix):])
732                         for tag in tags_string.split()
733                         if tag.startswith(prefix)]
734                     id = _xapian_unquote(string=id)
735                     if clear_tags:
736                         for line in _clear_tags_for_message(index=self.index_path, id=id):
737                             git.stdin.write(line)
738                     for line in _index_tags_for_message(
739                             id=id, status='A', tags=tags):
740                         git.stdin.write(line)
741
742     @timed
743     def diff(self, filter):
744         """
745         Get an {id: {tag, ...}} dict for a given filter.
746
747         For example, use 'A' to find added tags, and 'D' to find deleted tags.
748         """
749         s = _collections.defaultdict(set)
750         with _git(
751                 args=[
752                     'diff-index', '--cached', '--diff-filter', filter,
753                     '--name-only', 'HEAD'],
754                 additional_env={'GIT_INDEX_FILE': self.index_path},
755                 stdout=_subprocess.PIPE) as p:
756             # Once we drop Python < 3.3, we can use 'yield from' here
757             for id, tag in _unpack_diff_lines(stream=p.stdout):
758                 s[id].add(tag)
759         return s
760
761 def _read_index_checksum (index_path):
762     """Read the index checksum, as defined by index-format.txt in the git source
763     WARNING: assumes SHA1 repo"""
764     import binascii
765     try:
766         with open(index_path, 'rb') as f:
767             size=_os.path.getsize(index_path)
768             f.seek(size-20);
769             return binascii.hexlify(f.read(20)).decode('ascii')
770     except FileNotFoundError:
771         return None
772
773
774 def _clear_tags_for_message(index, id):
775     """
776     Clear any existing index entries for message 'id'
777
778     Neither 'id' nor the tags in 'tags' should be encoded/escaped.
779     """
780
781     dir = 'tags/{id}'.format(id=_hex_quote(string=id))
782
783     with _git(
784             args=['ls-files', dir],
785             additional_env={'GIT_INDEX_FILE': index},
786             stdout=_subprocess.PIPE) as git:
787         for file in git.stdout:
788             line = '0 0000000000000000000000000000000000000000\t{:s}\n'.format(file.strip())
789             yield line
790
791 def _read_database_lastmod():
792     with _spawn(
793             args=['notmuch', 'count', '--lastmod', '*'],
794             stdout=_subprocess.PIPE) as notmuch:
795         (count,uuid,lastmod_str) = notmuch.stdout.readline().split()
796         return (count,uuid,int(lastmod_str))
797
798 def _index_tags_for_message(id, status, tags):
799     """
800     Update the Git index to either create or delete an empty file.
801
802     Neither 'id' nor the tags in 'tags' should be encoded/escaped.
803     """
804     mode = '100644'
805     hash = _EMPTYBLOB
806
807     if status == 'D':
808         mode = '0'
809         hash = '0000000000000000000000000000000000000000'
810
811     for tag in tags:
812         path = 'tags/{id}/{tag}'.format(
813             id=_hex_quote(string=id), tag=_hex_quote(string=tag))
814         yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)
815
816
817 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
818     with _git(
819             args=['diff', '--diff-filter', filter, '--name-only', a, b],
820             stdout=_subprocess.PIPE) as p:
821         # Once we drop Python < 3.3, we can use 'yield from' here
822         for id, tag in _unpack_diff_lines(stream=p.stdout):
823             yield id, tag
824
825
826 def _unpack_diff_lines(stream):
827     "Iterate through (id, tag) tuples in a diff stream."
828     for line in stream:
829         match = _TAG_FILE_REGEX.match(line.strip())
830         if not match:
831             message = 'non-tag line in diff: {!r}'.format(line.strip())
832             if line.startswith(_TAG_DIRECTORY):
833                 raise ValueError(message)
834             _LOG.info(message)
835             continue
836         id = _unquote(match.group('id'))
837         tag = _unquote(match.group('tag'))
838         yield (id, tag)
839
840
841 def _help(parser, command=None):
842     """
843     Show help for an nmbug command.
844
845     Because some folks prefer:
846
847       $ nmbug help COMMAND
848
849     to
850
851       $ nmbug COMMAND --help
852     """
853     if command:
854         parser.parse_args([command, '--help'])
855     else:
856         parser.parse_args(['--help'])
857
858 def _notmuch_config_get(key):
859     (status, stdout, stderr) = _spawn(
860         args=['notmuch', 'config', 'get', key],
861         stdout=_subprocess.PIPE, wait=True)
862     if status != 0:
863         _LOG.error("failed to run notmuch config")
864         sys.exit(1)
865     return stdout.rstrip()
866
867 if __name__ == '__main__':
868     import argparse
869
870     parser = argparse.ArgumentParser(
871         description=__doc__.strip(),
872         formatter_class=argparse.RawDescriptionHelpFormatter)
873     parser.add_argument(
874         '-C', '--git-dir', metavar='REPO',
875         help='Git repository to operate on.')
876     parser.add_argument(
877         '-p', '--tag-prefix', metavar='PREFIX',
878         default = _os.getenv('NOTMUCH_GIT_PREFIX', 'notmuch::'),
879         help='Prefix of tags to operate on.')
880     parser.add_argument(
881         '-l', '--log-level',
882         choices=['critical', 'error', 'warning', 'info', 'debug'],
883         help='Log verbosity.  Defaults to {!r}.'.format(
884             _logging.getLevelName(_LOG.level).lower()))
885
886     help = _functools.partial(_help, parser=parser)
887     help.__doc__ = _help.__doc__
888     subparsers = parser.add_subparsers(
889         title='commands',
890         description=(
891             'For help on a particular command, run: '
892             "'%(prog)s ... <command> --help'."))
893     for command in [
894             'archive',
895             'checkout',
896             'clone',
897             'commit',
898             'fetch',
899             'help',
900             'init',
901             'log',
902             'merge',
903             'pull',
904             'push',
905             'status',
906             ]:
907         func = locals()[command]
908         doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
909         subparser = subparsers.add_parser(
910             command,
911             help=doc.splitlines()[0],
912             description=doc,
913             formatter_class=argparse.RawDescriptionHelpFormatter)
914         subparser.set_defaults(func=func)
915         if command == 'archive':
916             subparser.add_argument(
917                 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
918                 help=(
919                     'The tree or commit to produce an archive for.  Defaults '
920                     "to 'HEAD'."))
921             subparser.add_argument(
922                 'args', metavar='ARG', nargs='*',
923                 help=(
924                     "Argument passed through to 'git archive'.  Set anything "
925                     'before <tree-ish>, see git-archive(1) for details.'))
926         elif command == 'clone':
927             subparser.add_argument(
928                 'repository',
929                 help=(
930                     'The (possibly remote) repository to clone from.  See the '
931                     'URLS section of git-clone(1) for more information on '
932                     'specifying repositories.'))
933         elif command == 'commit':
934             subparser.add_argument(
935                 'message', metavar='MESSAGE', default='', nargs='?',
936                 help='Text for the commit message.')
937         elif command == 'fetch':
938             subparser.add_argument(
939                 'remote', metavar='REMOTE', nargs='?',
940                 help=(
941                     'Override the default configured in branch.<name>.remote '
942                     'to fetch from a particular remote repository (e.g. '
943                     "'origin')."))
944         elif command == 'help':
945             subparser.add_argument(
946                 'command', metavar='COMMAND', nargs='?',
947                 help='The command to show help for.')
948         elif command == 'log':
949             subparser.add_argument(
950                 'args', metavar='ARG', nargs='*',
951                 help="Additional argument passed through to 'git log'.")
952         elif command == 'merge':
953             subparser.add_argument(
954                 'reference', metavar='REFERENCE', default='@{upstream}',
955                 nargs='?',
956                 help=(
957                     'Reference, usually other branch heads, to merge into '
958                     "our branch.  Defaults to '@{upstream}'."))
959         elif command == 'pull':
960             subparser.add_argument(
961                 'repository', metavar='REPOSITORY', default=None, nargs='?',
962                 help=(
963                     'The "remote" repository that is the source of the pull.  '
964                     'This parameter can be either a URL (see the section GIT '
965                     'URLS in git-pull(1)) or the name of a remote (see the '
966                     'section REMOTES in git-pull(1)).'))
967             subparser.add_argument(
968                 'refspecs', metavar='REFSPEC', default=None, nargs='*',
969                 help=(
970                     'Refspec (usually a branch name) to fetch and merge.  See '
971                     'the <refspec> entry in the OPTIONS section of '
972                     'git-pull(1) for other possibilities.'))
973         elif command == 'push':
974             subparser.add_argument(
975                'repository', metavar='REPOSITORY', default=None, nargs='?',
976                 help=(
977                     'The "remote" repository that is the destination of the '
978                     'push.  This parameter can be either a URL (see the '
979                     'section GIT URLS in git-push(1)) or the name of a remote '
980                     '(see the section REMOTES in git-push(1)).'))
981             subparser.add_argument(
982                 'refspecs', metavar='REFSPEC', default=None, nargs='*',
983                 help=(
984                     'Refspec (usually a branch name) to push.  See '
985                     'the <refspec> entry in the OPTIONS section of '
986                     'git-push(1) for other possibilities.'))
987
988     args = parser.parse_args()
989
990     if args.git_dir:
991         NOTMUCH_GIT_DIR = args.git_dir
992     else:
993         NOTMUCH_GIT_DIR = _os.path.expanduser(
994         _os.getenv('NOTMUCH_GIT_DIR', _os.path.join('~', '.nmbug')))
995         _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
996         if _os.path.isdir(_NOTMUCH_GIT_DIR):
997             NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
998
999     TAG_PREFIX = args.tag_prefix
1000     _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,')  # quote ':'
1001
1002     if args.log_level:
1003         level = getattr(_logging, args.log_level.upper())
1004         _LOG.setLevel(level)
1005
1006     # for test suite
1007     for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
1008         _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
1009
1010     if _notmuch_config_get('built_with.sexp_queries') != 'true':
1011         _LOG.error("notmuch git needs sexp query support")
1012         sys.exit(1)
1013
1014     if not getattr(args, 'func', None):
1015         parser.print_usage()
1016         _sys.exit(1)
1017
1018     if args.func == help:
1019         arg_names = ['command']
1020     else:
1021         (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
1022     kwargs = {key: getattr(args, key) for key in arg_names if key in args}
1023     try:
1024         args.func(**kwargs)
1025     except SubprocessError as e:
1026         if _LOG.level == _logging.DEBUG:
1027             raise  # don't mask the traceback
1028         _LOG.error(str(e))
1029         _sys.exit(1)