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