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