]> git.cworth.org Git - notmuch-old/blob - notmuch-git.py
doc: mark `--output=summary` as default
[notmuch-old] / 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 class DatabaseCache:
702     def __init__(self):
703         try:
704             from notmuch2 import Database
705             self._notmuch = Database()
706         except ImportError:
707             self._notmuch = None
708         self._known = {}
709
710     def known(self,id):
711         if id in self._known:
712             return self._known[id];
713
714         if self._notmuch:
715             try:
716                 _ = self._notmuch.find(id)
717                 self._known[id] = True
718             except LookupError:
719                 self._known[id] = False
720         else:
721             (_, stdout, stderr) = _spawn(
722                 args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)],
723                 stdout=_subprocess.PIPE,
724                 wait=True)
725             self._known[id] = stdout != None
726         return self._known[id]
727
728 @timed
729 def get_status():
730     status = {
731         'deleted': {},
732         'missing': {},
733         }
734     db = DatabaseCache()
735     with PrivateIndex(repo=NOTMUCH_GIT_DIR, prefix=TAG_PREFIX) as index:
736         maybe_deleted = index.diff(filter='D')
737         for id, tags in maybe_deleted.items():
738             if db.known(id):
739                 status['deleted'][id] = tags
740             else:
741                 status['missing'][id] = tags
742         status['added'] = index.diff(filter='A')
743
744     return status
745
746 class PrivateIndex:
747     def __init__(self, repo, prefix):
748         try:
749             _os.makedirs(_os.path.join(repo, 'notmuch'))
750         except FileExistsError:
751             pass
752
753         file_name = 'notmuch/index'
754         self.index_path = _os.path.join(repo, file_name)
755         self.cache_path = _os.path.join(repo, 'notmuch', '{:s}.json'.format(_hex_quote(file_name)))
756
757         self.current_prefix = prefix
758
759         self.prefix = None
760         self.uuid = None
761         self.lastmod = None
762         self.checksum = None
763         self._load_cache_file()
764         self.file_tree = None
765         self._index_tags()
766
767     def __enter__(self):
768         return self
769
770     def __exit__(self, type, value, traceback):
771         checksum = _read_index_checksum(self.index_path)
772         (count, uuid, lastmod) = _read_database_lastmod()
773         with open(self.cache_path, "w") as f:
774             _json.dump({'prefix': self.current_prefix, 'uuid': uuid, 'lastmod': lastmod,  'checksum': checksum }, f)
775
776     def _load_cache_file(self):
777         try:
778             with open(self.cache_path) as f:
779                 data = _json.load(f)
780                 self.prefix = data['prefix']
781                 self.uuid = data['uuid']
782                 self.lastmod = data['lastmod']
783                 self.checksum = data['checksum']
784         except FileNotFoundError:
785             return None
786         except _json.JSONDecodeError:
787             _LOG.error("Error decoding cache")
788             _sys.exit(1)
789
790     @timed
791     def _read_file_tree(self):
792         self.file_tree = {}
793
794         with _git(
795                 args=['ls-files', 'tags'],
796                 additional_env={'GIT_INDEX_FILE': self.index_path},
797                 stdout=_subprocess.PIPE) as git:
798             for file in git.stdout:
799                 dir=_os.path.dirname(file)
800                 tag=_os.path.basename(file).rstrip()
801                 if dir not in self.file_tree:
802                     self.file_tree[dir]=[tag]
803                 else:
804                     self.file_tree[dir].append(tag)
805
806
807     def _clear_tags_for_message(self, id):
808         """
809         Clear any existing index entries for message 'id'
810
811         Neither 'id' nor the tags in 'tags' should be encoded/escaped.
812         """
813
814         if self.file_tree == None:
815             self._read_file_tree()
816
817         dir = _id_path(id)
818
819         if dir not in self.file_tree:
820             return
821
822         for file in self.file_tree[dir]:
823             line = '0 0000000000000000000000000000000000000000\t{:s}/{:s}\n'.format(dir,file)
824             yield line
825
826
827     @timed
828     def _index_tags(self):
829         "Write notmuch tags to private git index."
830         prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)
831         current_checksum = _read_index_checksum(self.index_path)
832         if (self.prefix == None or self.prefix != self.current_prefix
833             or self.checksum == None or self.checksum != current_checksum):
834             _git(
835                 args=['read-tree', '--empty'],
836                 additional_env={'GIT_INDEX_FILE': self.index_path}, wait=True)
837
838         query = _tag_query()
839         clear_tags = False
840         (count,uuid,lastmod) = _read_database_lastmod()
841         if self.prefix == self.current_prefix and self.uuid \
842            and self.uuid == uuid and self.checksum == current_checksum:
843             query = '(and (infix "lastmod:{:d}..")) {:s})'.format(self.lastmod+1, query)
844             clear_tags = True
845         with _spawn(
846                 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', query],
847                 stdout=_subprocess.PIPE) as notmuch:
848             with _git(
849                     args=['update-index', '--index-info'],
850                     stdin=_subprocess.PIPE,
851                     additional_env={'GIT_INDEX_FILE': self.index_path}) as git:
852                 for line in notmuch.stdout:
853                     if line.strip().startswith('#'):
854                         continue
855                     (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
856                     tags = [
857                         _unquote(tag[len(prefix):])
858                         for tag in tags_string.split()
859                         if tag.startswith(prefix)]
860                     id = _xapian_unquote(string=id)
861                     if clear_tags:
862                         for line in self._clear_tags_for_message(id=id):
863                             git.stdin.write(line)
864                     for line in _index_tags_for_message(
865                             id=id, status='A', tags=tags):
866                         git.stdin.write(line)
867
868     @timed
869     def diff(self, filter):
870         """
871         Get an {id: {tag, ...}} dict for a given filter.
872
873         For example, use 'A' to find added tags, and 'D' to find deleted tags.
874         """
875         s = _collections.defaultdict(set)
876         with _git(
877                 args=[
878                     'diff-index', '--cached', '--diff-filter', filter,
879                     '--name-only', 'HEAD'],
880                 additional_env={'GIT_INDEX_FILE': self.index_path},
881                 stdout=_subprocess.PIPE) as p:
882             # Once we drop Python < 3.3, we can use 'yield from' here
883             for id, tag in _unpack_diff_lines(stream=p.stdout):
884                 s[id].add(tag)
885         return s
886
887 def _read_index_checksum (index_path):
888     """Read the index checksum, as defined by index-format.txt in the git source
889     WARNING: assumes SHA1 repo"""
890     import binascii
891     try:
892         with open(index_path, 'rb') as f:
893             size=_os.path.getsize(index_path)
894             f.seek(size-20);
895             return binascii.hexlify(f.read(20)).decode('ascii')
896     except FileNotFoundError:
897         return None
898
899 def _read_database_lastmod():
900     with _spawn(
901             args=['notmuch', 'count', '--lastmod', '*'],
902             stdout=_subprocess.PIPE) as notmuch:
903         (count,uuid,lastmod_str) = notmuch.stdout.readline().split()
904         return (count,uuid,int(lastmod_str))
905
906 def _id_path(id):
907     hid=_hex_quote(string=id)
908     from hashlib import blake2b
909
910     if FORMAT_VERSION==0:
911         return 'tags/{hid}'.format(hid=hid)
912     elif FORMAT_VERSION==1:
913         idhash = blake2b(hid.encode('utf8'), digest_size=2).hexdigest()
914         return 'tags/{dir1}/{dir2}/{hid}'.format(
915             hid=hid,
916             dir1=idhash[0:2],dir2=idhash[2:])
917     else:
918         _LOG.error("Unknown format version",FORMAT_VERSION)
919         _sys.exit(1)
920
921 def _index_tags_for_message(id, status, tags):
922     """
923     Update the Git index to either create or delete an empty file.
924
925     Neither 'id' nor the tags in 'tags' should be encoded/escaped.
926     """
927     mode = '100644'
928     hash = _EMPTYBLOB
929
930     if status == 'D':
931         mode = '0'
932         hash = '0000000000000000000000000000000000000000'
933
934     for tag in tags:
935         path = '{ipath}/{tag}'.format(ipath=_id_path(id),tag=_hex_quote(string=tag))
936         yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)
937
938
939 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
940     with _git(
941             args=['diff', '--diff-filter', filter, '--name-only', a, b],
942             stdout=_subprocess.PIPE) as p:
943         # Once we drop Python < 3.3, we can use 'yield from' here
944         for id, tag in _unpack_diff_lines(stream=p.stdout):
945             yield id, tag
946
947
948 def _unpack_diff_lines(stream):
949     "Iterate through (id, tag) tuples in a diff stream."
950     for line in stream:
951         match = _TAG_FILE_REGEX[FORMAT_VERSION].match(line.strip())
952         if not match:
953             message = 'non-tag line in diff: {!r}'.format(line.strip())
954             if line.startswith(_TAG_DIRECTORY):
955                 raise ValueError(message)
956             _LOG.info(message)
957             continue
958         id = _unquote(match.group('id'))
959         tag = _unquote(match.group('tag'))
960         yield (id, tag)
961
962
963 def _help(parser, command=None):
964     """
965     Show help for an notmuch-git command.
966
967     Because some folks prefer:
968
969       $ notmuch-git help COMMAND
970
971     to
972
973       $ notmuch-git COMMAND --help
974     """
975     if command:
976         parser.parse_args([command, '--help'])
977     else:
978         parser.parse_args(['--help'])
979
980 def _notmuch_config_get(key):
981     (status, stdout, stderr) = _spawn(
982         args=['notmuch', 'config', 'get', key],
983         stdout=_subprocess.PIPE, wait=True)
984     if status != 0:
985         _LOG.error("failed to run notmuch config")
986         _sys.exit(1)
987     return stdout.rstrip()
988
989 def read_format_version():
990     try:
991         (status, stdout, stderr) = _git(
992             args=['cat-file', 'blob', 'master:FORMAT'],
993             stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
994     except SubprocessError as e:
995         _LOG.debug("failed to read FORMAT file from git, assuming format version 0")
996         return 0
997
998     return int(stdout)
999
1000 # based on BaseDirectory.save_data_path from pyxdg (LGPL2+)
1001 def xdg_data_path(profile):
1002     resource = _os.path.join('notmuch',profile,'git')
1003     assert not resource.startswith('/')
1004     _home = _os.path.expanduser('~')
1005     xdg_data_home = _os.environ.get('XDG_DATA_HOME') or \
1006         _os.path.join(_home, '.local', 'share')
1007     path = _os.path.join(xdg_data_home, resource)
1008     return path
1009
1010 if __name__ == '__main__':
1011     import argparse
1012
1013     parser = argparse.ArgumentParser(
1014         description=__doc__.strip(),
1015         formatter_class=argparse.RawDescriptionHelpFormatter)
1016     parser.add_argument(
1017         '-C', '--git-dir', metavar='REPO',
1018         help='Git repository to operate on.')
1019     parser.add_argument(
1020         '-p', '--tag-prefix', metavar='PREFIX',
1021         default = None,
1022         help='Prefix of tags to operate on.')
1023     parser.add_argument(
1024         '-N', '--nmbug', action='store_true',
1025         help='Set defaults for --tag-prefix and --git-dir for the notmuch bug tracker')
1026     parser.add_argument(
1027         '-l', '--log-level',
1028         choices=['critical', 'error', 'warning', 'info', 'debug'],
1029         help='Log verbosity.  Defaults to {!r}.'.format(
1030             _logging.getLevelName(_LOG.level).lower()))
1031
1032     help = _functools.partial(_help, parser=parser)
1033     help.__doc__ = _help.__doc__
1034     subparsers = parser.add_subparsers(
1035         title='commands',
1036         description=(
1037             'For help on a particular command, run: '
1038             "'%(prog)s ... <command> --help'."))
1039     for command in [
1040             'archive',
1041             'checkout',
1042             'clone',
1043             'commit',
1044             'fetch',
1045             'help',
1046             'init',
1047             'log',
1048             'merge',
1049             'pull',
1050             'push',
1051             'status',
1052             ]:
1053         func = locals()[command]
1054         doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
1055         subparser = subparsers.add_parser(
1056             command,
1057             help=doc.splitlines()[0],
1058             description=doc,
1059             formatter_class=argparse.RawDescriptionHelpFormatter)
1060         subparser.set_defaults(func=func)
1061         if command == 'archive':
1062             subparser.add_argument(
1063                 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
1064                 help=(
1065                     'The tree or commit to produce an archive for.  Defaults '
1066                     "to 'HEAD'."))
1067             subparser.add_argument(
1068                 'args', metavar='ARG', nargs='*',
1069                 help=(
1070                     "Argument passed through to 'git archive'.  Set anything "
1071                     'before <tree-ish>, see git-archive(1) for details.'))
1072         elif command == 'checkout':
1073             subparser.add_argument(
1074                 '-f', '--force', action='store_true',
1075                 help='checkout a large fraction of tags.')
1076         elif command == 'clone':
1077             subparser.add_argument(
1078                 'repository',
1079                 help=(
1080                     'The (possibly remote) repository to clone from.  See the '
1081                     'URLS section of git-clone(1) for more information on '
1082                     'specifying repositories.'))
1083         elif command == 'commit':
1084             subparser.add_argument(
1085                 '-f', '--force', action='store_true',
1086                 help='commit a large fraction of tags.')
1087             subparser.add_argument(
1088                 'message', metavar='MESSAGE', default='', nargs='?',
1089                 help='Text for the commit message.')
1090         elif command == 'fetch':
1091             subparser.add_argument(
1092                 'remote', metavar='REMOTE', nargs='?',
1093                 help=(
1094                     'Override the default configured in branch.<name>.remote '
1095                     'to fetch from a particular remote repository (e.g. '
1096                     "'origin')."))
1097         elif command == 'help':
1098             subparser.add_argument(
1099                 'command', metavar='COMMAND', nargs='?',
1100                 help='The command to show help for.')
1101         elif command == 'init':
1102             subparser.add_argument(
1103                 '--format-version', metavar='VERSION',
1104                 default = None,
1105                 help='create format VERSION repository.')
1106         elif command == 'log':
1107             subparser.add_argument(
1108                 'args', metavar='ARG', nargs='*',
1109                 help="Additional argument passed through to 'git log'.")
1110         elif command == 'merge':
1111             subparser.add_argument(
1112                 'reference', metavar='REFERENCE', default='@{upstream}',
1113                 nargs='?',
1114                 help=(
1115                     'Reference, usually other branch heads, to merge into '
1116                     "our branch.  Defaults to '@{upstream}'."))
1117         elif command == 'pull':
1118             subparser.add_argument(
1119                 'repository', metavar='REPOSITORY', default=None, nargs='?',
1120                 help=(
1121                     'The "remote" repository that is the source of the pull.  '
1122                     'This parameter can be either a URL (see the section GIT '
1123                     'URLS in git-pull(1)) or the name of a remote (see the '
1124                     'section REMOTES in git-pull(1)).'))
1125             subparser.add_argument(
1126                 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1127                 help=(
1128                     'Refspec (usually a branch name) to fetch and merge.  See '
1129                     'the <refspec> entry in the OPTIONS section of '
1130                     'git-pull(1) for other possibilities.'))
1131         elif command == 'push':
1132             subparser.add_argument(
1133                'repository', metavar='REPOSITORY', default=None, nargs='?',
1134                 help=(
1135                     'The "remote" repository that is the destination of the '
1136                     'push.  This parameter can be either a URL (see the '
1137                     'section GIT URLS in git-push(1)) or the name of a remote '
1138                     '(see the section REMOTES in git-push(1)).'))
1139             subparser.add_argument(
1140                 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1141                 help=(
1142                     'Refspec (usually a branch name) to push.  See '
1143                     'the <refspec> entry in the OPTIONS section of '
1144                     'git-push(1) for other possibilities.'))
1145
1146     args = parser.parse_args()
1147
1148     nmbug_mode = False
1149     notmuch_profile = _os.getenv('NOTMUCH_PROFILE','default')
1150
1151     if args.nmbug or _os.path.basename(__file__) == 'nmbug':
1152         nmbug_mode = True
1153
1154     if args.git_dir:
1155         NOTMUCH_GIT_DIR = args.git_dir
1156     else:
1157         if nmbug_mode:
1158             default = _os.path.join('~', '.nmbug')
1159         else:
1160             default = _notmuch_config_get ('git.path')
1161             if default == '':
1162                 default = xdg_data_path(notmuch_profile)
1163
1164         NOTMUCH_GIT_DIR = _os.path.expanduser(_os.getenv('NOTMUCH_GIT_DIR', default))
1165
1166     _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
1167     if _os.path.isdir(_NOTMUCH_GIT_DIR):
1168         NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
1169
1170     if args.tag_prefix:
1171         TAG_PREFIX = args.tag_prefix
1172     else:
1173         if nmbug_mode:
1174             prefix = 'notmuch::'
1175         else:
1176             prefix = _notmuch_config_get ('git.tag_prefix')
1177
1178         TAG_PREFIX =  _os.getenv('NOTMUCH_GIT_PREFIX', prefix)
1179
1180     _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,')  # quote ':'
1181
1182     if args.log_level:
1183         level = getattr(_logging, args.log_level.upper())
1184         _LOG.setLevel(level)
1185
1186     # for test suite
1187     for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
1188         _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
1189
1190     if _notmuch_config_get('built_with.sexp_queries') != 'true':
1191         _LOG.error("notmuch git needs sexp query support")
1192         _sys.exit(1)
1193
1194     if not getattr(args, 'func', None):
1195         parser.print_usage()
1196         _sys.exit(1)
1197
1198     # The following two lines are used by the test suite.
1199     _LOG.debug('prefix = {:s}'.format(TAG_PREFIX))
1200     _LOG.debug('repository = {:s}'.format(NOTMUCH_GIT_DIR))
1201
1202     if args.func != init:
1203         FORMAT_VERSION = read_format_version()
1204
1205     _LOG.debug('FORMAT_VERSION={:d}'.format(FORMAT_VERSION))
1206
1207     if args.func == help:
1208         arg_names = ['command']
1209     else:
1210         (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
1211     kwargs = {key: getattr(args, key) for key in arg_names if key in args}
1212     try:
1213         args.func(**kwargs)
1214     except SubprocessError as e:
1215         if _LOG.level == _logging.DEBUG:
1216             raise  # don't mask the traceback
1217         _LOG.error(str(e))
1218         _sys.exit(1)