]> git.cworth.org Git - notmuch/blob - notmuch-git.py
CLI/git: support configuration for repo location / prefix
[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     from pathlib import Path
429     parent = Path(NOTMUCH_GIT_DIR).parent
430     try:
431         _os.makedirs(parent)
432     except FileExistsError:
433         pass
434
435     _spawn(args=['git', '--git-dir', NOTMUCH_GIT_DIR, 'init',
436                  '--initial-branch=master', '--quiet', '--bare'], wait=True)
437     _git(args=['config', 'core.logallrefupdates', 'true'], wait=True)
438     # create an empty blob (e69de29bb2d1d6434b8b29ae775ad8c2e48c5391)
439     _git(args=['hash-object', '-w', '--stdin'], input='', wait=True)
440     _git(
441         args=[
442             'commit', '--allow-empty', '-m', 'Start a new nmbug repository'
443         ],
444         additional_env={'GIT_WORK_TREE': NOTMUCH_GIT_DIR},
445         wait=True)
446
447
448 def checkout():
449     """
450     Update the notmuch database from Git.
451
452     This is mainly useful to discard your changes in notmuch relative
453     to Git.
454     """
455     status = get_status()
456     with _spawn(
457             args=['notmuch', 'tag', '--batch'], stdin=_subprocess.PIPE) as p:
458         for id, tags in status['added'].items():
459             p.stdin.write(_batch_line(action='-', id=id, tags=tags))
460         for id, tags in status['deleted'].items():
461             p.stdin.write(_batch_line(action='+', id=id, tags=tags))
462
463
464 def _batch_line(action, id, tags):
465     """
466     'notmuch tag --batch' line for adding/removing tags.
467
468     Set 'action' to '-' to remove a tag or '+' to add the tags to a
469     given message id.
470     """
471     tag_string = ' '.join(
472         '{action}{prefix}{tag}'.format(
473             action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))
474         for tag in tags)
475     line = '{tags} -- id:{id}\n'.format(
476         tags=tag_string, id=_xapian_quote(string=id))
477     return line
478
479
480 def _insist_committed():
481     "Die if the the notmuch tags don't match the current HEAD."
482     status = get_status()
483     if not _is_committed(status=status):
484         _LOG.error('\n'.join([
485             'Uncommitted changes to {prefix}* tags in notmuch',
486             '',
487             "For a summary of changes, run 'nmbug status'",
488             "To save your changes,     run 'nmbug commit' before merging/pull",
489             "To discard your changes,  run 'nmbug checkout'",
490             ]).format(prefix=TAG_PREFIX))
491         _sys.exit(1)
492
493
494 def pull(repository=None, refspecs=None):
495     """
496     Pull (merge) remote repository changes to notmuch.
497
498     'pull' is equivalent to 'fetch' followed by 'merge'.  We use the
499     Git-configured repository for your current branch
500     (branch.<name>.repository, likely 'origin', and
501     branch.<name>.merge, likely 'master').
502     """
503     _insist_committed()
504     if refspecs and not repository:
505         repository = _get_remote()
506     args = ['pull']
507     if repository:
508         args.append(repository)
509     if refspecs:
510         args.extend(refspecs)
511     with _tempfile.TemporaryDirectory(prefix='nmbug-pull.') as workdir:
512         for command in [
513                 ['reset', '--hard'],
514                 args]:
515             _git(
516                 args=command,
517                 additional_env={'GIT_WORK_TREE': workdir},
518                 wait=True)
519     checkout()
520
521
522 def merge(reference='@{upstream}'):
523     """
524     Merge changes from 'reference' into HEAD and load the result into notmuch.
525
526     The default reference is '@{upstream}'.
527     """
528     _insist_committed()
529     with _tempfile.TemporaryDirectory(prefix='nmbug-merge.') as workdir:
530         for command in [
531                 ['reset', '--hard'],
532                 ['merge', reference]]:
533             _git(
534                 args=command,
535                 additional_env={'GIT_WORK_TREE': workdir},
536                 wait=True)
537     checkout()
538
539
540 def log(args=()):
541     """
542     A simple wrapper for 'git log'.
543
544     After running 'nmbug fetch', you can inspect the changes with
545     'nmbug log HEAD..@{upstream}'.
546     """
547     # we don't want output trapping here, because we want the pager.
548     args = ['log', '--name-status', '--no-renames'] + list(args)
549     with _git(args=args, expect=(0, 1, -13)) as p:
550         p.wait()
551
552
553 def push(repository=None, refspecs=None):
554     "Push the local nmbug Git state to a remote repository."
555     if refspecs and not repository:
556         repository = _get_remote()
557     args = ['push']
558     if repository:
559         args.append(repository)
560     if refspecs:
561         args.extend(refspecs)
562     _git(args=args, wait=True)
563
564
565 def status():
566     """
567     Show pending updates in notmuch or git repo.
568
569     Prints lines of the form
570
571       ng Message-Id tag
572
573     where n is a single character representing notmuch database status
574
575     * A
576
577       Tag is present in notmuch database, but not committed to nmbug
578       (equivalently, tag has been deleted in nmbug repo, e.g. by a
579       pull, but not restored to notmuch database).
580
581     * D
582
583       Tag is present in nmbug repo, but not restored to notmuch
584       database (equivalently, tag has been deleted in notmuch).
585
586     * U
587
588       Message is unknown (missing from local notmuch database).
589
590     The second character (if present) represents a difference between
591     local and upstream branches. Typically 'nmbug fetch' needs to be
592     run to update this.
593
594     * a
595
596       Tag is present in upstream, but not in the local Git branch.
597
598     * d
599
600       Tag is present in local Git branch, but not upstream.
601     """
602     status = get_status()
603     # 'output' is a nested defaultdict for message status:
604     # * The outer dict is keyed by message id.
605     # * The inner dict is keyed by tag name.
606     # * The inner dict values are status strings (' a', 'Dd', ...).
607     output = _collections.defaultdict(
608         lambda : _collections.defaultdict(lambda : ' '))
609     for id, tags in status['added'].items():
610         for tag in tags:
611             output[id][tag] = 'A'
612     for id, tags in status['deleted'].items():
613         for tag in tags:
614             output[id][tag] = 'D'
615     for id, tags in status['missing'].items():
616         for tag in tags:
617             output[id][tag] = 'U'
618     if _is_unmerged():
619         for id, tag in _diff_refs(filter='A'):
620             output[id][tag] += 'a'
621         for id, tag in _diff_refs(filter='D'):
622             output[id][tag] += 'd'
623     for id, tag_status in sorted(output.items()):
624         for tag, status in sorted(tag_status.items()):
625             print('{status}\t{id}\t{tag}'.format(
626                 status=status, id=id, tag=tag))
627
628
629 def _is_unmerged(ref='@{upstream}'):
630     try:
631         (status, fetch_head, stderr) = _git(
632             args=['rev-parse', ref],
633             stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
634     except SubprocessError as e:
635         if 'No upstream configured' in e.stderr:
636             return
637         raise
638     (status, base, stderr) = _git(
639         args=['merge-base', 'HEAD', ref],
640         stdout=_subprocess.PIPE, wait=True)
641     return base != fetch_head
642
643
644 @timed
645 def get_status():
646     status = {
647         'deleted': {},
648         'missing': {},
649         }
650     with PrivateIndex(repo=NOTMUCH_GIT_DIR, prefix=TAG_PREFIX) as index:
651         maybe_deleted = index.diff(filter='D')
652         for id, tags in maybe_deleted.items():
653             (_, stdout, stderr) = _spawn(
654                 args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)],
655                 stdout=_subprocess.PIPE,
656                 wait=True)
657             if stdout:
658                 status['deleted'][id] = tags
659             else:
660                 status['missing'][id] = tags
661         status['added'] = index.diff(filter='A')
662
663     return status
664
665 class PrivateIndex:
666     def __init__(self, repo, prefix):
667         try:
668             _os.makedirs(_os.path.join(repo, 'notmuch'))
669         except FileExistsError:
670             pass
671
672         file_name = 'notmuch/index'
673         self.index_path = _os.path.join(repo, file_name)
674         self.cache_path = _os.path.join(repo, 'notmuch', '{:s}.json'.format(_hex_quote(file_name)))
675
676         self.current_prefix = prefix
677
678         self.prefix = None
679         self.uuid = None
680         self.lastmod = None
681         self.checksum = None
682         self._load_cache_file()
683         self._index_tags()
684
685     def __enter__(self):
686         return self
687
688     def __exit__(self, type, value, traceback):
689         checksum = _read_index_checksum(self.index_path)
690         (count, uuid, lastmod) = _read_database_lastmod()
691         with open(self.cache_path, "w") as f:
692             _json.dump({'prefix': self.current_prefix, 'uuid': uuid, 'lastmod': lastmod,  'checksum': checksum }, f)
693
694     def _load_cache_file(self):
695         try:
696             with open(self.cache_path) as f:
697                 data = _json.load(f)
698                 self.prefix = data['prefix']
699                 self.uuid = data['uuid']
700                 self.lastmod = data['lastmod']
701                 self.checksum = data['checksum']
702         except FileNotFoundError:
703             return None
704         except _json.JSONDecodeError:
705             _LOG.error("Error decoding cache")
706             _sys.exit(1)
707
708     @timed
709     def _index_tags(self):
710         "Write notmuch tags to private git index."
711         prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)
712         current_checksum = _read_index_checksum(self.index_path)
713         if (self.prefix == None or self.prefix != self.current_prefix
714             or self.checksum == None or self.checksum != current_checksum):
715             _git(
716                 args=['read-tree', '--empty'],
717                 additional_env={'GIT_INDEX_FILE': self.index_path}, wait=True)
718
719         query = _tag_query()
720         clear_tags = False
721         (count,uuid,lastmod) = _read_database_lastmod()
722         if self.prefix == self.current_prefix and self.uuid \
723            and self.uuid == uuid and self.checksum == current_checksum:
724             query = '(and (infix "lastmod:{:d}..")) {:s})'.format(self.lastmod+1, query)
725             clear_tags = True
726         with _spawn(
727                 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', query],
728                 stdout=_subprocess.PIPE) as notmuch:
729             with _git(
730                     args=['update-index', '--index-info'],
731                     stdin=_subprocess.PIPE,
732                     additional_env={'GIT_INDEX_FILE': self.index_path}) as git:
733                 for line in notmuch.stdout:
734                     if line.strip().startswith('#'):
735                         continue
736                     (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
737                     tags = [
738                         _unquote(tag[len(prefix):])
739                         for tag in tags_string.split()
740                         if tag.startswith(prefix)]
741                     id = _xapian_unquote(string=id)
742                     if clear_tags:
743                         for line in _clear_tags_for_message(index=self.index_path, id=id):
744                             git.stdin.write(line)
745                     for line in _index_tags_for_message(
746                             id=id, status='A', tags=tags):
747                         git.stdin.write(line)
748
749     @timed
750     def diff(self, filter):
751         """
752         Get an {id: {tag, ...}} dict for a given filter.
753
754         For example, use 'A' to find added tags, and 'D' to find deleted tags.
755         """
756         s = _collections.defaultdict(set)
757         with _git(
758                 args=[
759                     'diff-index', '--cached', '--diff-filter', filter,
760                     '--name-only', 'HEAD'],
761                 additional_env={'GIT_INDEX_FILE': self.index_path},
762                 stdout=_subprocess.PIPE) as p:
763             # Once we drop Python < 3.3, we can use 'yield from' here
764             for id, tag in _unpack_diff_lines(stream=p.stdout):
765                 s[id].add(tag)
766         return s
767
768 def _read_index_checksum (index_path):
769     """Read the index checksum, as defined by index-format.txt in the git source
770     WARNING: assumes SHA1 repo"""
771     import binascii
772     try:
773         with open(index_path, 'rb') as f:
774             size=_os.path.getsize(index_path)
775             f.seek(size-20);
776             return binascii.hexlify(f.read(20)).decode('ascii')
777     except FileNotFoundError:
778         return None
779
780
781 def _clear_tags_for_message(index, id):
782     """
783     Clear any existing index entries for message 'id'
784
785     Neither 'id' nor the tags in 'tags' should be encoded/escaped.
786     """
787
788     dir = 'tags/{id}'.format(id=_hex_quote(string=id))
789
790     with _git(
791             args=['ls-files', dir],
792             additional_env={'GIT_INDEX_FILE': index},
793             stdout=_subprocess.PIPE) as git:
794         for file in git.stdout:
795             line = '0 0000000000000000000000000000000000000000\t{:s}\n'.format(file.strip())
796             yield line
797
798 def _read_database_lastmod():
799     with _spawn(
800             args=['notmuch', 'count', '--lastmod', '*'],
801             stdout=_subprocess.PIPE) as notmuch:
802         (count,uuid,lastmod_str) = notmuch.stdout.readline().split()
803         return (count,uuid,int(lastmod_str))
804
805 def _index_tags_for_message(id, status, tags):
806     """
807     Update the Git index to either create or delete an empty file.
808
809     Neither 'id' nor the tags in 'tags' should be encoded/escaped.
810     """
811     mode = '100644'
812     hash = _EMPTYBLOB
813
814     if status == 'D':
815         mode = '0'
816         hash = '0000000000000000000000000000000000000000'
817
818     for tag in tags:
819         path = 'tags/{id}/{tag}'.format(
820             id=_hex_quote(string=id), tag=_hex_quote(string=tag))
821         yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)
822
823
824 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
825     with _git(
826             args=['diff', '--diff-filter', filter, '--name-only', a, b],
827             stdout=_subprocess.PIPE) as p:
828         # Once we drop Python < 3.3, we can use 'yield from' here
829         for id, tag in _unpack_diff_lines(stream=p.stdout):
830             yield id, tag
831
832
833 def _unpack_diff_lines(stream):
834     "Iterate through (id, tag) tuples in a diff stream."
835     for line in stream:
836         match = _TAG_FILE_REGEX.match(line.strip())
837         if not match:
838             message = 'non-tag line in diff: {!r}'.format(line.strip())
839             if line.startswith(_TAG_DIRECTORY):
840                 raise ValueError(message)
841             _LOG.info(message)
842             continue
843         id = _unquote(match.group('id'))
844         tag = _unquote(match.group('tag'))
845         yield (id, tag)
846
847
848 def _help(parser, command=None):
849     """
850     Show help for an nmbug command.
851
852     Because some folks prefer:
853
854       $ nmbug help COMMAND
855
856     to
857
858       $ nmbug COMMAND --help
859     """
860     if command:
861         parser.parse_args([command, '--help'])
862     else:
863         parser.parse_args(['--help'])
864
865 def _notmuch_config_get(key):
866     (status, stdout, stderr) = _spawn(
867         args=['notmuch', 'config', 'get', key],
868         stdout=_subprocess.PIPE, wait=True)
869     if status != 0:
870         _LOG.error("failed to run notmuch config")
871         _sys.exit(1)
872     return stdout.rstrip()
873
874 # based on BaseDirectory.save_data_path from pyxdg (LGPL2+)
875 def xdg_data_path(profile):
876     resource = _os.path.join('notmuch',profile,'git')
877     assert not resource.startswith('/')
878     _home = _os.path.expanduser('~')
879     xdg_data_home = _os.environ.get('XDG_DATA_HOME') or \
880         _os.path.join(_home, '.local', 'share')
881     path = _os.path.join(xdg_data_home, resource)
882     return path
883
884 if __name__ == '__main__':
885     import argparse
886
887     parser = argparse.ArgumentParser(
888         description=__doc__.strip(),
889         formatter_class=argparse.RawDescriptionHelpFormatter)
890     parser.add_argument(
891         '-C', '--git-dir', metavar='REPO',
892         help='Git repository to operate on.')
893     parser.add_argument(
894         '-p', '--tag-prefix', metavar='PREFIX',
895         default = None,
896         help='Prefix of tags to operate on.')
897     parser.add_argument(
898         '-N', '--nmbug', action='store_true',
899         help='Set defaults for --tag-prefix and --git-dir for the notmuch bug tracker')
900     parser.add_argument(
901         '-l', '--log-level',
902         choices=['critical', 'error', 'warning', 'info', 'debug'],
903         help='Log verbosity.  Defaults to {!r}.'.format(
904             _logging.getLevelName(_LOG.level).lower()))
905
906     help = _functools.partial(_help, parser=parser)
907     help.__doc__ = _help.__doc__
908     subparsers = parser.add_subparsers(
909         title='commands',
910         description=(
911             'For help on a particular command, run: '
912             "'%(prog)s ... <command> --help'."))
913     for command in [
914             'archive',
915             'checkout',
916             'clone',
917             'commit',
918             'fetch',
919             'help',
920             'init',
921             'log',
922             'merge',
923             'pull',
924             'push',
925             'status',
926             ]:
927         func = locals()[command]
928         doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
929         subparser = subparsers.add_parser(
930             command,
931             help=doc.splitlines()[0],
932             description=doc,
933             formatter_class=argparse.RawDescriptionHelpFormatter)
934         subparser.set_defaults(func=func)
935         if command == 'archive':
936             subparser.add_argument(
937                 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
938                 help=(
939                     'The tree or commit to produce an archive for.  Defaults '
940                     "to 'HEAD'."))
941             subparser.add_argument(
942                 'args', metavar='ARG', nargs='*',
943                 help=(
944                     "Argument passed through to 'git archive'.  Set anything "
945                     'before <tree-ish>, see git-archive(1) for details.'))
946         elif command == 'clone':
947             subparser.add_argument(
948                 'repository',
949                 help=(
950                     'The (possibly remote) repository to clone from.  See the '
951                     'URLS section of git-clone(1) for more information on '
952                     'specifying repositories.'))
953         elif command == 'commit':
954             subparser.add_argument(
955                 'message', metavar='MESSAGE', default='', nargs='?',
956                 help='Text for the commit message.')
957         elif command == 'fetch':
958             subparser.add_argument(
959                 'remote', metavar='REMOTE', nargs='?',
960                 help=(
961                     'Override the default configured in branch.<name>.remote '
962                     'to fetch from a particular remote repository (e.g. '
963                     "'origin')."))
964         elif command == 'help':
965             subparser.add_argument(
966                 'command', metavar='COMMAND', nargs='?',
967                 help='The command to show help for.')
968         elif command == 'log':
969             subparser.add_argument(
970                 'args', metavar='ARG', nargs='*',
971                 help="Additional argument passed through to 'git log'.")
972         elif command == 'merge':
973             subparser.add_argument(
974                 'reference', metavar='REFERENCE', default='@{upstream}',
975                 nargs='?',
976                 help=(
977                     'Reference, usually other branch heads, to merge into '
978                     "our branch.  Defaults to '@{upstream}'."))
979         elif command == 'pull':
980             subparser.add_argument(
981                 'repository', metavar='REPOSITORY', default=None, nargs='?',
982                 help=(
983                     'The "remote" repository that is the source of the pull.  '
984                     'This parameter can be either a URL (see the section GIT '
985                     'URLS in git-pull(1)) or the name of a remote (see the '
986                     'section REMOTES in git-pull(1)).'))
987             subparser.add_argument(
988                 'refspecs', metavar='REFSPEC', default=None, nargs='*',
989                 help=(
990                     'Refspec (usually a branch name) to fetch and merge.  See '
991                     'the <refspec> entry in the OPTIONS section of '
992                     'git-pull(1) for other possibilities.'))
993         elif command == 'push':
994             subparser.add_argument(
995                'repository', metavar='REPOSITORY', default=None, nargs='?',
996                 help=(
997                     'The "remote" repository that is the destination of the '
998                     'push.  This parameter can be either a URL (see the '
999                     'section GIT URLS in git-push(1)) or the name of a remote '
1000                     '(see the section REMOTES in git-push(1)).'))
1001             subparser.add_argument(
1002                 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1003                 help=(
1004                     'Refspec (usually a branch name) to push.  See '
1005                     'the <refspec> entry in the OPTIONS section of '
1006                     'git-push(1) for other possibilities.'))
1007
1008     args = parser.parse_args()
1009
1010     nmbug_mode = False
1011     notmuch_profile = _os.getenv('NOTMUCH_PROFILE','default')
1012
1013     if args.nmbug or _os.path.basename(__file__) == 'nmbug':
1014         nmbug_mode = True
1015
1016     if args.git_dir:
1017         NOTMUCH_GIT_DIR = args.git_dir
1018     else:
1019         if nmbug_mode:
1020             default = _os.path.join('~', '.nmbug')
1021         else:
1022             default = _notmuch_config_get ('git.path')
1023             if default == '':
1024                 default = xdg_data_path(notmuch_profile)
1025
1026         NOTMUCH_GIT_DIR = _os.path.expanduser(_os.getenv('NOTMUCH_GIT_DIR', default))
1027
1028     _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
1029     if _os.path.isdir(_NOTMUCH_GIT_DIR):
1030         NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
1031
1032     if args.tag_prefix:
1033         TAG_PREFIX = args.tag_prefix
1034     else:
1035         if nmbug_mode:
1036             prefix = 'notmuch::'
1037         else:
1038             prefix = _notmuch_config_get ('git.tag_prefix')
1039
1040         TAG_PREFIX =  _os.getenv('NOTMUCH_GIT_PREFIX', prefix)
1041
1042     _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,')  # quote ':'
1043
1044     if args.log_level:
1045         level = getattr(_logging, args.log_level.upper())
1046         _LOG.setLevel(level)
1047
1048     # for test suite
1049     for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
1050         _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
1051
1052     if _notmuch_config_get('built_with.sexp_queries') != 'true':
1053         _LOG.error("notmuch git needs sexp query support")
1054         _sys.exit(1)
1055
1056     if not getattr(args, 'func', None):
1057         parser.print_usage()
1058         _sys.exit(1)
1059
1060     # The following two lines are used by the test suite.
1061     _LOG.debug('prefix = {:s}'.format(TAG_PREFIX))
1062     _LOG.debug('repository = {:s}'.format(NOTMUCH_GIT_DIR))
1063
1064     if args.func == help:
1065         arg_names = ['command']
1066     else:
1067         (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
1068     kwargs = {key: getattr(args, key) for key in arg_names if key in args}
1069     try:
1070         args.func(**kwargs)
1071     except SubprocessError as e:
1072         if _LOG.level == _logging.DEBUG:
1073             raise  # don't mask the traceback
1074         _LOG.error(str(e))
1075         _sys.exit(1)