3 # Copyright (c) 2011-2014 David Bremner <david@tethera.net>
4 # W. Trevor King <wking@tremily.us>
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.
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.
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/ .
20 Manage notmuch tags with Git
23 from __future__ import print_function
24 from __future__ import unicode_literals
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
34 import shutil as _shutil
35 import subprocess as _subprocess
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
42 _LOG = _logging.getLogger('nmbug')
43 _LOG.setLevel(_logging.WARNING)
44 _LOG.addHandler(_logging.StreamHandler())
46 NOTMUCH_GIT_DIR = None
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>[^/]*)')
53 # magic hash for Git (git hash-object -t blob /dev/null)
54 _EMPTYBLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
56 def _hex_quote(string, safe='+@=:,'):
58 quote('abc def') -> 'abc%20def'.
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').
64 uppercase_escapes = _quote(string, safe)
65 return _HEX_ESCAPE_REGEX.sub(
66 lambda match: match.group(0).lower(),
69 def _xapian_quote(string):
71 Quote a string for Xapian's QueryParser.
73 Xapian uses double-quotes for quoting strings. You can escape
74 internal quotes by repeating them [1,2,3].
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
80 return '"{0}"'.format(string.replace('"', '""'))
83 def _xapian_unquote(string):
85 Unquote a Xapian-quoted string.
87 if string.startswith('"') and string.endswith('"'):
88 return string[1:-1].replace('""', '"')
92 class SubprocessError(RuntimeError):
93 "A subprocess exited with a nonzero status"
94 def __init__(self, args, status, stdout=None, stderr=None):
98 msg = '{args} exited with {status}'.format(args=args, status=status)
100 msg = '{msg}: {stderr}'.format(msg=msg, stderr=stderr)
101 super(SubprocessError, self).__init__(msg)
104 class _SubprocessContextManager(object):
106 PEP 343 context manager for subprocesses.
108 'expect' holds a tuple of acceptable exit codes, otherwise we'll
109 raise a SubprocessError in __exit__.
111 def __init__(self, process, args, expect=(0,)):
112 self._process = process
114 self._expect = expect
119 def __exit__(self, type, value, traceback):
120 for name in ['stdin', 'stdout', 'stderr']:
121 stream = getattr(self._process, name)
124 setattr(self._process, name, None)
125 status = self._process.wait()
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)
133 return self._process.wait()
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.
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).
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.
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
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)
165 if hasattr(input, 'encode'):
166 input = input.encode(encoding)
167 (stdout, stderr) = p.communicate(input=input)
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:
184 p.stdin = _codecs.getwriter(encoding=encoding)(stream=p.stdin)
185 stream_reader = _codecs.getreader(encoding=encoding)
187 p.stdout = stream_reader(stream=p.stdout)
189 p.stderr = stream_reader(stream=p.stderr)
190 return _SubprocessContextManager(args=args, process=p, expect=expect)
193 def _git(args, **kwargs):
194 args = ['git', '--git-dir', NOTMUCH_GIT_DIR] + list(args)
195 return _spawn(args=args, **kwargs)
198 def _get_current_branch():
199 """Get the name of the current branch.
201 Return 'None' if we're not on a branch.
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:
211 return branch.strip()
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()
222 def _tag_query(prefix=None):
225 return '(tag (starts-with "{:s}"))'.format(prefix.replace('"','\\\"'))
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()]
234 def archive(treeish='HEAD', args=()):
236 Dump a tar archive of the current nmbug tag set.
240 Each tag $tag for message with Message-Id $id is written to
243 tags/encode($id)/encode($tag)
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.
249 _git(args=['archive', treeish] + list(args), wait=True)
252 def clone(repository):
254 Create a local nmbug repository from a remote source.
256 This wraps 'git clone', adding some options to avoid creating a
257 working tree while preserving remote-tracking branches and
260 with _tempfile.TemporaryDirectory(prefix='nmbug-clone.') as workdir:
263 'git', 'clone', '--no-checkout', '--separate-git-dir', NOTMUCH_GIT_DIR,
264 repository, workdir],
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',
270 'refs/remotes/origin/config'],
274 _git(args=['branch', 'config', 'origin/config'], wait=True)
275 existing_tags = get_tags()
278 'Not checking out to avoid clobbering existing tags: {}'.format(
279 ', '.join(existing_tags)))
284 def _is_committed(status):
285 return len(status['added']) + len(status['deleted']) == 0
288 def commit(treeish='HEAD', message=None):
290 Commit prefix-matching tags from the notmuch database to Git.
292 status = get_status()
294 if _is_committed(status=status):
295 _LOG.warning('Nothing to commit')
298 _git(args=['read-tree', '--empty'], wait=True)
299 _git(args=['read-tree', treeish], wait=True)
301 _update_index(status=status)
304 stdout=_subprocess.PIPE,
306 (_, parent, _) = _git(
307 args=['rev-parse', treeish],
308 stdout=_subprocess.PIPE,
310 (_, commit, _) = _git(
311 args=['commit-tree', tree.strip(), '-p', parent.strip()],
313 stdout=_subprocess.PIPE,
316 args=['update-ref', treeish, commit.strip()],
317 stdout=_subprocess.PIPE,
319 except Exception as e:
320 _git(args=['read-tree', '--empty'], wait=True)
321 _git(args=['read-tree', treeish], wait=True)
324 def _update_index(status):
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):
331 for id, tags in status['added'].items():
332 for line in _index_tags_for_message(id=id, status='A', tags=tags):
336 def fetch(remote=None):
338 Fetch changes from the remote repository.
340 See 'merge' to bring those changes into notmuch.
345 _git(args=args, wait=True)
348 def init(remote=None):
350 Create an empty nmbug repository.
352 This wraps 'git init' with a few extra steps to support subsequent
353 status and commit commands.
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)
362 'commit', '--allow-empty', '-m', 'Start a new nmbug repository'
364 additional_env={'GIT_WORK_TREE': NOTMUCH_GIT_DIR},
370 Update the notmuch database from Git.
372 This is mainly useful to discard your changes in notmuch relative
375 status = get_status()
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))
384 def _batch_line(action, id, tags):
386 'notmuch tag --batch' line for adding/removing tags.
388 Set 'action' to '-' to remove a tag or '+' to add the tags to a
391 tag_string = ' '.join(
392 '{action}{prefix}{tag}'.format(
393 action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))
395 line = '{tags} -- id:{id}\n'.format(
396 tags=tag_string, id=_xapian_quote(string=id))
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',
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))
414 def pull(repository=None, refspecs=None):
416 Pull (merge) remote repository changes to notmuch.
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').
424 if refspecs and not repository:
425 repository = _get_remote()
428 args.append(repository)
430 args.extend(refspecs)
431 with _tempfile.TemporaryDirectory(prefix='nmbug-pull.') as workdir:
437 additional_env={'GIT_WORK_TREE': workdir},
442 def merge(reference='@{upstream}'):
444 Merge changes from 'reference' into HEAD and load the result into notmuch.
446 The default reference is '@{upstream}'.
449 with _tempfile.TemporaryDirectory(prefix='nmbug-merge.') as workdir:
452 ['merge', reference]]:
455 additional_env={'GIT_WORK_TREE': workdir},
462 A simple wrapper for 'git log'.
464 After running 'nmbug fetch', you can inspect the changes with
465 'nmbug log HEAD..@{upstream}'.
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:
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()
479 args.append(repository)
481 args.extend(refspecs)
482 _git(args=args, wait=True)
487 Show pending updates in notmuch or git repo.
489 Prints lines of the form
493 where n is a single character representing notmuch database status
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).
503 Tag is present in nmbug repo, but not restored to notmuch
504 database (equivalently, tag has been deleted in notmuch).
508 Message is unknown (missing from local notmuch database).
510 The second character (if present) represents a difference between
511 local and upstream branches. Typically 'nmbug fetch' needs to be
516 Tag is present in upstream, but not in the local Git branch.
520 Tag is present in local Git branch, but not upstream.
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():
531 output[id][tag] = 'A'
532 for id, tags in status['deleted'].items():
534 output[id][tag] = 'D'
535 for id, tags in status['missing'].items():
537 output[id][tag] = 'U'
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))
549 def _is_unmerged(ref='@{upstream}'):
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:
558 (status, base, stderr) = _git(
559 args=['merge-base', 'HEAD', ref],
560 stdout=_subprocess.PIPE, wait=True)
561 return base != fetch_head
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,
577 status['deleted'][id] = tags
579 status['missing'][id] = tags
580 status['added'] = _diff_index(index=index, filter='A')
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)
590 args=['read-tree', '--empty'],
591 additional_env={'GIT_INDEX_FILE': path}, wait=True)
593 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', _tag_query()],
594 stdout=_subprocess.PIPE) as notmuch:
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('#'):
602 (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
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)
614 def _index_tags_for_message(id, status, tags):
616 Update the Git index to either create or delete an empty file.
618 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
625 hash = '0000000000000000000000000000000000000000'
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)
633 def _diff_index(index, filter):
635 Get an {id: {tag, ...}} dict for a given filter.
637 For example, use 'A' to find added tags, and 'D' to find deleted tags.
639 s = _collections.defaultdict(set)
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):
652 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
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):
661 def _unpack_diff_lines(stream):
662 "Iterate through (id, tag) tuples in a diff stream."
664 match = _TAG_FILE_REGEX.match(line.strip())
666 message = 'non-tag line in diff: {!r}'.format(line.strip())
667 if line.startswith(_TAG_DIRECTORY):
668 raise ValueError(message)
671 id = _unquote(match.group('id'))
672 tag = _unquote(match.group('tag'))
676 def _help(parser, command=None):
678 Show help for an nmbug command.
680 Because some folks prefer:
686 $ nmbug COMMAND --help
689 parser.parse_args([command, '--help'])
691 parser.parse_args(['--help'])
693 def _notmuch_config_get(key):
694 (status, stdout, stderr) = _spawn(
695 args=['notmuch', 'config', 'get', key],
696 stdout=_subprocess.PIPE, wait=True)
698 _LOG.error("failed to run notmuch config")
700 return stdout.rstrip()
702 if __name__ == '__main__':
705 parser = argparse.ArgumentParser(
706 description=__doc__.strip(),
707 formatter_class=argparse.RawDescriptionHelpFormatter)
709 '-C', '--git-dir', metavar='REPO',
710 help='Git repository to operate on.')
712 '-p', '--tag-prefix', metavar='PREFIX',
713 default = _os.getenv('NOTMUCH_GIT_PREFIX', 'notmuch::'),
714 help='Prefix of tags to operate on.')
717 choices=['critical', 'error', 'warning', 'info', 'debug'],
718 help='Log verbosity. Defaults to {!r}.'.format(
719 _logging.getLevelName(_LOG.level).lower()))
721 help = _functools.partial(_help, parser=parser)
722 help.__doc__ = _help.__doc__
723 subparsers = parser.add_subparsers(
726 'For help on a particular command, run: '
727 "'%(prog)s ... <command> --help'."))
742 func = locals()[command]
743 doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
744 subparser = subparsers.add_parser(
746 help=doc.splitlines()[0],
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',
754 'The tree or commit to produce an archive for. Defaults '
756 subparser.add_argument(
757 'args', metavar='ARG', nargs='*',
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(
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='?',
776 'Override the default configured in branch.<name>.remote '
777 'to fetch from a particular remote repository (e.g. '
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}',
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='?',
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='*',
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='?',
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='*',
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.'))
823 args = parser.parse_args()
826 NOTMUCH_GIT_DIR = args.git_dir
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
834 TAG_PREFIX = args.tag_prefix
835 _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,') # quote ':'
838 level = getattr(_logging, args.log_level.upper())
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%')))
845 if _notmuch_config_get('built_with.sexp_queries') != 'true':
846 _LOG.error("notmuch git needs sexp query support")
849 if not getattr(args, 'func', None):
853 if args.func == help:
854 arg_names = ['command']
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}
860 except SubprocessError as e:
861 if _LOG.level == _logging.DEBUG:
862 raise # don't mask the traceback