]> git.notmuchmail.org Git - notmuch/blob - notmuch-git.py
CLI/git: suppress warnings about initial branch name
[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
223 def get_tags(prefix=None):
224     "Get a list of tags with a given prefix."
225     if prefix is None:
226         prefix = TAG_PREFIX
227     (status, stdout, stderr) = _spawn(
228         args=['notmuch', 'search', '--output=tags', '*'],
229         stdout=_subprocess.PIPE, wait=True)
230     return [tag for tag in stdout.splitlines() if tag.startswith(prefix)]
231
232
233 def archive(treeish='HEAD', args=()):
234     """
235     Dump a tar archive of the current nmbug tag set.
236
237     Using 'git archive'.
238
239     Each tag $tag for message with Message-Id $id is written to
240     an empty file
241
242       tags/encode($id)/encode($tag)
243
244     The encoding preserves alphanumerics, and the characters
245     "+-_@=.:," (not the quotes).  All other octets are replaced with
246     '%' followed by a two digit hex number.
247     """
248     _git(args=['archive', treeish] + list(args), wait=True)
249
250
251 def clone(repository):
252     """
253     Create a local nmbug repository from a remote source.
254
255     This wraps 'git clone', adding some options to avoid creating a
256     working tree while preserving remote-tracking branches and
257     upstreams.
258     """
259     with _tempfile.TemporaryDirectory(prefix='nmbug-clone.') as workdir:
260         _spawn(
261             args=[
262                 'git', 'clone', '--no-checkout', '--separate-git-dir', NOTMUCH_GIT_DIR,
263                 repository, workdir],
264             wait=True)
265     _git(args=['config', '--unset', 'core.worktree'], wait=True, expect=(0, 5))
266     _git(args=['config', 'core.bare', 'true'], wait=True)
267     (status, stdout, stderr) = _git(args=['show-ref', '--verify',
268                                           '--quiet',
269                                           'refs/remotes/origin/config'],
270                                     expect=(0,1),
271                                     wait=True)
272     if status == 0:
273         _git(args=['branch', 'config', 'origin/config'], wait=True)
274     existing_tags = get_tags()
275     if existing_tags:
276         _LOG.warning(
277             'Not checking out to avoid clobbering existing tags: {}'.format(
278             ', '.join(existing_tags)))
279     else:
280         checkout()
281
282
283 def _is_committed(status):
284     return len(status['added']) + len(status['deleted']) == 0
285
286
287 def commit(treeish='HEAD', message=None):
288     """
289     Commit prefix-matching tags from the notmuch database to Git.
290     """
291     status = get_status()
292
293     if _is_committed(status=status):
294         _LOG.warning('Nothing to commit')
295         return
296
297     _git(args=['read-tree', '--empty'], wait=True)
298     _git(args=['read-tree', treeish], wait=True)
299     try:
300         _update_index(status=status)
301         (_, tree, _) = _git(
302             args=['write-tree'],
303             stdout=_subprocess.PIPE,
304             wait=True)
305         (_, parent, _) = _git(
306             args=['rev-parse', treeish],
307             stdout=_subprocess.PIPE,
308             wait=True)
309         (_, commit, _) = _git(
310             args=['commit-tree', tree.strip(), '-p', parent.strip()],
311             input=message,
312             stdout=_subprocess.PIPE,
313             wait=True)
314         _git(
315             args=['update-ref', treeish, commit.strip()],
316             stdout=_subprocess.PIPE,
317             wait=True)
318     except Exception as e:
319         _git(args=['read-tree', '--empty'], wait=True)
320         _git(args=['read-tree', treeish], wait=True)
321         raise
322
323 def _update_index(status):
324     with _git(
325             args=['update-index', '--index-info'],
326             stdin=_subprocess.PIPE) as p:
327         for id, tags in status['deleted'].items():
328             for line in _index_tags_for_message(id=id, status='D', tags=tags):
329                 p.stdin.write(line)
330         for id, tags in status['added'].items():
331             for line in _index_tags_for_message(id=id, status='A', tags=tags):
332                 p.stdin.write(line)
333
334
335 def fetch(remote=None):
336     """
337     Fetch changes from the remote repository.
338
339     See 'merge' to bring those changes into notmuch.
340     """
341     args = ['fetch']
342     if remote:
343         args.append(remote)
344     _git(args=args, wait=True)
345
346
347 def init(remote=None):
348     """
349     Create an empty nmbug repository.
350
351     This wraps 'git init' with a few extra steps to support subsequent
352     status and commit commands.
353     """
354     _spawn(args=['git', '--git-dir', NOTMUCH_GIT_DIR, 'init',
355                  '--initial-branch=master', '--quiet', '--bare'], wait=True)
356     _git(args=['config', 'core.logallrefupdates', 'true'], wait=True)
357     # create an empty blob (e69de29bb2d1d6434b8b29ae775ad8c2e48c5391)
358     _git(args=['hash-object', '-w', '--stdin'], input='', wait=True)
359     _git(
360         args=[
361             'commit', '--allow-empty', '-m', 'Start a new nmbug repository'
362         ],
363         additional_env={'GIT_WORK_TREE': NOTMUCH_GIT_DIR},
364         wait=True)
365
366
367 def checkout():
368     """
369     Update the notmuch database from Git.
370
371     This is mainly useful to discard your changes in notmuch relative
372     to Git.
373     """
374     status = get_status()
375     with _spawn(
376             args=['notmuch', 'tag', '--batch'], stdin=_subprocess.PIPE) as p:
377         for id, tags in status['added'].items():
378             p.stdin.write(_batch_line(action='-', id=id, tags=tags))
379         for id, tags in status['deleted'].items():
380             p.stdin.write(_batch_line(action='+', id=id, tags=tags))
381
382
383 def _batch_line(action, id, tags):
384     """
385     'notmuch tag --batch' line for adding/removing tags.
386
387     Set 'action' to '-' to remove a tag or '+' to add the tags to a
388     given message id.
389     """
390     tag_string = ' '.join(
391         '{action}{prefix}{tag}'.format(
392             action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))
393         for tag in tags)
394     line = '{tags} -- id:{id}\n'.format(
395         tags=tag_string, id=_xapian_quote(string=id))
396     return line
397
398
399 def _insist_committed():
400     "Die if the the notmuch tags don't match the current HEAD."
401     status = get_status()
402     if not _is_committed(status=status):
403         _LOG.error('\n'.join([
404             'Uncommitted changes to {prefix}* tags in notmuch',
405             '',
406             "For a summary of changes, run 'nmbug status'",
407             "To save your changes,     run 'nmbug commit' before merging/pull",
408             "To discard your changes,  run 'nmbug checkout'",
409             ]).format(prefix=TAG_PREFIX))
410         _sys.exit(1)
411
412
413 def pull(repository=None, refspecs=None):
414     """
415     Pull (merge) remote repository changes to notmuch.
416
417     'pull' is equivalent to 'fetch' followed by 'merge'.  We use the
418     Git-configured repository for your current branch
419     (branch.<name>.repository, likely 'origin', and
420     branch.<name>.merge, likely 'master').
421     """
422     _insist_committed()
423     if refspecs and not repository:
424         repository = _get_remote()
425     args = ['pull']
426     if repository:
427         args.append(repository)
428     if refspecs:
429         args.extend(refspecs)
430     with _tempfile.TemporaryDirectory(prefix='nmbug-pull.') as workdir:
431         for command in [
432                 ['reset', '--hard'],
433                 args]:
434             _git(
435                 args=command,
436                 additional_env={'GIT_WORK_TREE': workdir},
437                 wait=True)
438     checkout()
439
440
441 def merge(reference='@{upstream}'):
442     """
443     Merge changes from 'reference' into HEAD and load the result into notmuch.
444
445     The default reference is '@{upstream}'.
446     """
447     _insist_committed()
448     with _tempfile.TemporaryDirectory(prefix='nmbug-merge.') as workdir:
449         for command in [
450                 ['reset', '--hard'],
451                 ['merge', reference]]:
452             _git(
453                 args=command,
454                 additional_env={'GIT_WORK_TREE': workdir},
455                 wait=True)
456     checkout()
457
458
459 def log(args=()):
460     """
461     A simple wrapper for 'git log'.
462
463     After running 'nmbug fetch', you can inspect the changes with
464     'nmbug log HEAD..@{upstream}'.
465     """
466     # we don't want output trapping here, because we want the pager.
467     args = ['log', '--name-status', '--no-renames'] + list(args)
468     with _git(args=args, expect=(0, 1, -13)) as p:
469         p.wait()
470
471
472 def push(repository=None, refspecs=None):
473     "Push the local nmbug Git state to a remote repository."
474     if refspecs and not repository:
475         repository = _get_remote()
476     args = ['push']
477     if repository:
478         args.append(repository)
479     if refspecs:
480         args.extend(refspecs)
481     _git(args=args, wait=True)
482
483
484 def status():
485     """
486     Show pending updates in notmuch or git repo.
487
488     Prints lines of the form
489
490       ng Message-Id tag
491
492     where n is a single character representing notmuch database status
493
494     * A
495
496       Tag is present in notmuch database, but not committed to nmbug
497       (equivalently, tag has been deleted in nmbug repo, e.g. by a
498       pull, but not restored to notmuch database).
499
500     * D
501
502       Tag is present in nmbug repo, but not restored to notmuch
503       database (equivalently, tag has been deleted in notmuch).
504
505     * U
506
507       Message is unknown (missing from local notmuch database).
508
509     The second character (if present) represents a difference between
510     local and upstream branches. Typically 'nmbug fetch' needs to be
511     run to update this.
512
513     * a
514
515       Tag is present in upstream, but not in the local Git branch.
516
517     * d
518
519       Tag is present in local Git branch, but not upstream.
520     """
521     status = get_status()
522     # 'output' is a nested defaultdict for message status:
523     # * The outer dict is keyed by message id.
524     # * The inner dict is keyed by tag name.
525     # * The inner dict values are status strings (' a', 'Dd', ...).
526     output = _collections.defaultdict(
527         lambda : _collections.defaultdict(lambda : ' '))
528     for id, tags in status['added'].items():
529         for tag in tags:
530             output[id][tag] = 'A'
531     for id, tags in status['deleted'].items():
532         for tag in tags:
533             output[id][tag] = 'D'
534     for id, tags in status['missing'].items():
535         for tag in tags:
536             output[id][tag] = 'U'
537     if _is_unmerged():
538         for id, tag in _diff_refs(filter='A'):
539             output[id][tag] += 'a'
540         for id, tag in _diff_refs(filter='D'):
541             output[id][tag] += 'd'
542     for id, tag_status in sorted(output.items()):
543         for tag, status in sorted(tag_status.items()):
544             print('{status}\t{id}\t{tag}'.format(
545                 status=status, id=id, tag=tag))
546
547
548 def _is_unmerged(ref='@{upstream}'):
549     try:
550         (status, fetch_head, stderr) = _git(
551             args=['rev-parse', ref],
552             stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
553     except SubprocessError as e:
554         if 'No upstream configured' in e.stderr:
555             return
556         raise
557     (status, base, stderr) = _git(
558         args=['merge-base', 'HEAD', ref],
559         stdout=_subprocess.PIPE, wait=True)
560     return base != fetch_head
561
562
563 def get_status():
564     status = {
565         'deleted': {},
566         'missing': {},
567         }
568     index = _index_tags()
569     maybe_deleted = _diff_index(index=index, filter='D')
570     for id, tags in maybe_deleted.items():
571         (_, stdout, stderr) = _spawn(
572             args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)],
573             stdout=_subprocess.PIPE,
574             wait=True)
575         if stdout:
576             status['deleted'][id] = tags
577         else:
578             status['missing'][id] = tags
579     status['added'] = _diff_index(index=index, filter='A')
580     _os.remove(index)
581     return status
582
583
584 def _index_tags():
585     "Write notmuch tags to the nmbug.index."
586     path = _os.path.join(NOTMUCH_GIT_DIR, 'nmbug.index')
587     query = ' '.join('tag:"{tag}"'.format(tag=tag) for tag in get_tags())
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],
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
694 if __name__ == '__main__':
695     import argparse
696
697     parser = argparse.ArgumentParser(
698         description=__doc__.strip(),
699         formatter_class=argparse.RawDescriptionHelpFormatter)
700     parser.add_argument(
701         '-C', '--git-dir', metavar='REPO',
702         help='Git repository to operate on.')
703     parser.add_argument(
704         '-p', '--tag-prefix', metavar='PREFIX',
705         default = _os.getenv('NOTMUCH_GIT_PREFIX', 'notmuch::'),
706         help='Prefix of tags to operate on.')
707     parser.add_argument(
708         '-l', '--log-level',
709         choices=['critical', 'error', 'warning', 'info', 'debug'],
710         help='Log verbosity.  Defaults to {!r}.'.format(
711             _logging.getLevelName(_LOG.level).lower()))
712
713     help = _functools.partial(_help, parser=parser)
714     help.__doc__ = _help.__doc__
715     subparsers = parser.add_subparsers(
716         title='commands',
717         description=(
718             'For help on a particular command, run: '
719             "'%(prog)s ... <command> --help'."))
720     for command in [
721             'archive',
722             'checkout',
723             'clone',
724             'commit',
725             'fetch',
726             'help',
727             'init',
728             'log',
729             'merge',
730             'pull',
731             'push',
732             'status',
733             ]:
734         func = locals()[command]
735         doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
736         subparser = subparsers.add_parser(
737             command,
738             help=doc.splitlines()[0],
739             description=doc,
740             formatter_class=argparse.RawDescriptionHelpFormatter)
741         subparser.set_defaults(func=func)
742         if command == 'archive':
743             subparser.add_argument(
744                 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
745                 help=(
746                     'The tree or commit to produce an archive for.  Defaults '
747                     "to 'HEAD'."))
748             subparser.add_argument(
749                 'args', metavar='ARG', nargs='*',
750                 help=(
751                     "Argument passed through to 'git archive'.  Set anything "
752                     'before <tree-ish>, see git-archive(1) for details.'))
753         elif command == 'clone':
754             subparser.add_argument(
755                 'repository',
756                 help=(
757                     'The (possibly remote) repository to clone from.  See the '
758                     'URLS section of git-clone(1) for more information on '
759                     'specifying repositories.'))
760         elif command == 'commit':
761             subparser.add_argument(
762                 'message', metavar='MESSAGE', default='', nargs='?',
763                 help='Text for the commit message.')
764         elif command == 'fetch':
765             subparser.add_argument(
766                 'remote', metavar='REMOTE', nargs='?',
767                 help=(
768                     'Override the default configured in branch.<name>.remote '
769                     'to fetch from a particular remote repository (e.g. '
770                     "'origin')."))
771         elif command == 'help':
772             subparser.add_argument(
773                 'command', metavar='COMMAND', nargs='?',
774                 help='The command to show help for.')
775         elif command == 'log':
776             subparser.add_argument(
777                 'args', metavar='ARG', nargs='*',
778                 help="Additional argument passed through to 'git log'.")
779         elif command == 'merge':
780             subparser.add_argument(
781                 'reference', metavar='REFERENCE', default='@{upstream}',
782                 nargs='?',
783                 help=(
784                     'Reference, usually other branch heads, to merge into '
785                     "our branch.  Defaults to '@{upstream}'."))
786         elif command == 'pull':
787             subparser.add_argument(
788                 'repository', metavar='REPOSITORY', default=None, nargs='?',
789                 help=(
790                     'The "remote" repository that is the source of the pull.  '
791                     'This parameter can be either a URL (see the section GIT '
792                     'URLS in git-pull(1)) or the name of a remote (see the '
793                     'section REMOTES in git-pull(1)).'))
794             subparser.add_argument(
795                 'refspecs', metavar='REFSPEC', default=None, nargs='*',
796                 help=(
797                     'Refspec (usually a branch name) to fetch and merge.  See '
798                     'the <refspec> entry in the OPTIONS section of '
799                     'git-pull(1) for other possibilities.'))
800         elif command == 'push':
801             subparser.add_argument(
802                'repository', metavar='REPOSITORY', default=None, nargs='?',
803                 help=(
804                     'The "remote" repository that is the destination of the '
805                     'push.  This parameter can be either a URL (see the '
806                     'section GIT URLS in git-push(1)) or the name of a remote '
807                     '(see the section REMOTES in git-push(1)).'))
808             subparser.add_argument(
809                 'refspecs', metavar='REFSPEC', default=None, nargs='*',
810                 help=(
811                     'Refspec (usually a branch name) to push.  See '
812                     'the <refspec> entry in the OPTIONS section of '
813                     'git-push(1) for other possibilities.'))
814
815     args = parser.parse_args()
816
817     if args.git_dir:
818         NOTMUCH_GIT_DIR = args.git_dir
819     else:
820         NOTMUCH_GIT_DIR = _os.path.expanduser(
821         _os.getenv('NOTMUCH_GIT_DIR', _os.path.join('~', '.nmbug')))
822         _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
823         if _os.path.isdir(_NOTMUCH_GIT_DIR):
824             NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
825
826     TAG_PREFIX = args.tag_prefix
827     _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,')  # quote ':'
828
829     if args.log_level:
830         level = getattr(_logging, args.log_level.upper())
831         _LOG.setLevel(level)
832
833     # for test suite
834     for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
835         _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
836
837     if not getattr(args, 'func', None):
838         parser.print_usage()
839         _sys.exit(1)
840
841     if args.func == help:
842         arg_names = ['command']
843     else:
844         (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
845     kwargs = {key: getattr(args, key) for key in arg_names if key in args}
846     try:
847         args.func(**kwargs)
848     except SubprocessError as e:
849         if _LOG.level == _logging.DEBUG:
850             raise  # don't mask the traceback
851         _LOG.error(str(e))
852         _sys.exit(1)