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