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