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