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