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