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('""', '"')
94 from time import perf_counter
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))
106 class SubprocessError(RuntimeError):
107 "A subprocess exited with a nonzero status"
108 def __init__(self, args, status, stdout=None, stderr=None):
112 msg = '{args} exited with {status}'.format(args=args, status=status)
114 msg = '{msg}: {stderr}'.format(msg=msg, stderr=stderr)
115 super(SubprocessError, self).__init__(msg)
118 class _SubprocessContextManager(object):
120 PEP 343 context manager for subprocesses.
122 'expect' holds a tuple of acceptable exit codes, otherwise we'll
123 raise a SubprocessError in __exit__.
125 def __init__(self, process, args, expect=(0,)):
126 self._process = process
128 self._expect = expect
133 def __exit__(self, type, value, traceback):
134 for name in ['stdin', 'stdout', 'stderr']:
135 stream = getattr(self._process, name)
138 setattr(self._process, name, None)
139 status = self._process.wait()
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)
147 return self._process.wait()
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.
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).
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.
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
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)
179 if hasattr(input, 'encode'):
180 input = input.encode(encoding)
181 (stdout, stderr) = p.communicate(input=input)
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:
198 p.stdin = _codecs.getwriter(encoding=encoding)(stream=p.stdin)
199 stream_reader = _codecs.getreader(encoding=encoding)
201 p.stdout = stream_reader(stream=p.stdout)
203 p.stderr = stream_reader(stream=p.stderr)
204 return _SubprocessContextManager(args=args, process=p, expect=expect)
207 def _git(args, **kwargs):
208 args = ['git', '--git-dir', NOTMUCH_GIT_DIR] + list(args)
209 return _spawn(args=args, **kwargs)
212 def _get_current_branch():
213 """Get the name of the current branch.
215 Return 'None' if we're not on a branch.
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:
225 return branch.strip()
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()
236 def _tag_query(prefix=None):
239 return '(tag (starts-with "{:s}"))'.format(prefix.replace('"','\\\"'))
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()]
248 def archive(treeish='HEAD', args=()):
250 Dump a tar archive of the current nmbug tag set.
254 Each tag $tag for message with Message-Id $id is written to
257 tags/encode($id)/encode($tag)
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.
263 _git(args=['archive', treeish] + list(args), wait=True)
266 def clone(repository):
268 Create a local nmbug repository from a remote source.
270 This wraps 'git clone', adding some options to avoid creating a
271 working tree while preserving remote-tracking branches and
274 with _tempfile.TemporaryDirectory(prefix='nmbug-clone.') as workdir:
277 'git', 'clone', '--no-checkout', '--separate-git-dir', NOTMUCH_GIT_DIR,
278 repository, workdir],
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',
284 'refs/remotes/origin/config'],
288 _git(args=['branch', 'config', 'origin/config'], wait=True)
289 existing_tags = get_tags()
292 'Not checking out to avoid clobbering existing tags: {}'.format(
293 ', '.join(existing_tags)))
298 def _is_committed(status):
299 return len(status['added']) + len(status['deleted']) == 0
302 def commit(treeish='HEAD', message=None):
304 Commit prefix-matching tags from the notmuch database to Git.
306 status = get_status()
308 if _is_committed(status=status):
309 _LOG.warning('Nothing to commit')
312 _git(args=['read-tree', '--empty'], wait=True)
313 _git(args=['read-tree', treeish], wait=True)
315 _update_index(status=status)
318 stdout=_subprocess.PIPE,
320 (_, parent, _) = _git(
321 args=['rev-parse', treeish],
322 stdout=_subprocess.PIPE,
324 (_, commit, _) = _git(
325 args=['commit-tree', tree.strip(), '-p', parent.strip()],
327 stdout=_subprocess.PIPE,
330 args=['update-ref', treeish, commit.strip()],
331 stdout=_subprocess.PIPE,
333 except Exception as e:
334 _git(args=['read-tree', '--empty'], wait=True)
335 _git(args=['read-tree', treeish], wait=True)
339 def _update_index(status):
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):
346 for id, tags in status['added'].items():
347 for line in _index_tags_for_message(id=id, status='A', tags=tags):
351 def fetch(remote=None):
353 Fetch changes from the remote repository.
355 See 'merge' to bring those changes into notmuch.
360 _git(args=args, wait=True)
363 def init(remote=None):
365 Create an empty nmbug repository.
367 This wraps 'git init' with a few extra steps to support subsequent
368 status and commit commands.
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)
377 'commit', '--allow-empty', '-m', 'Start a new nmbug repository'
379 additional_env={'GIT_WORK_TREE': NOTMUCH_GIT_DIR},
385 Update the notmuch database from Git.
387 This is mainly useful to discard your changes in notmuch relative
390 status = get_status()
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))
399 def _batch_line(action, id, tags):
401 'notmuch tag --batch' line for adding/removing tags.
403 Set 'action' to '-' to remove a tag or '+' to add the tags to a
406 tag_string = ' '.join(
407 '{action}{prefix}{tag}'.format(
408 action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))
410 line = '{tags} -- id:{id}\n'.format(
411 tags=tag_string, id=_xapian_quote(string=id))
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',
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))
429 def pull(repository=None, refspecs=None):
431 Pull (merge) remote repository changes to notmuch.
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').
439 if refspecs and not repository:
440 repository = _get_remote()
443 args.append(repository)
445 args.extend(refspecs)
446 with _tempfile.TemporaryDirectory(prefix='nmbug-pull.') as workdir:
452 additional_env={'GIT_WORK_TREE': workdir},
457 def merge(reference='@{upstream}'):
459 Merge changes from 'reference' into HEAD and load the result into notmuch.
461 The default reference is '@{upstream}'.
464 with _tempfile.TemporaryDirectory(prefix='nmbug-merge.') as workdir:
467 ['merge', reference]]:
470 additional_env={'GIT_WORK_TREE': workdir},
477 A simple wrapper for 'git log'.
479 After running 'nmbug fetch', you can inspect the changes with
480 'nmbug log HEAD..@{upstream}'.
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:
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()
494 args.append(repository)
496 args.extend(refspecs)
497 _git(args=args, wait=True)
502 Show pending updates in notmuch or git repo.
504 Prints lines of the form
508 where n is a single character representing notmuch database status
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).
518 Tag is present in nmbug repo, but not restored to notmuch
519 database (equivalently, tag has been deleted in notmuch).
523 Message is unknown (missing from local notmuch database).
525 The second character (if present) represents a difference between
526 local and upstream branches. Typically 'nmbug fetch' needs to be
531 Tag is present in upstream, but not in the local Git branch.
535 Tag is present in local Git branch, but not upstream.
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():
546 output[id][tag] = 'A'
547 for id, tags in status['deleted'].items():
549 output[id][tag] = 'D'
550 for id, tags in status['missing'].items():
552 output[id][tag] = 'U'
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))
564 def _is_unmerged(ref='@{upstream}'):
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:
573 (status, base, stderr) = _git(
574 args=['merge-base', 'HEAD', ref],
575 stdout=_subprocess.PIPE, wait=True)
576 return base != fetch_head
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,
593 status['deleted'][id] = tags
595 status['missing'][id] = tags
596 status['added'] = _diff_index(index=index, filter='A')
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)
606 args=['read-tree', '--empty'],
607 additional_env={'GIT_INDEX_FILE': path}, wait=True)
609 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', _tag_query()],
610 stdout=_subprocess.PIPE) as notmuch:
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('#'):
618 (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
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)
630 def _index_tags_for_message(id, status, tags):
632 Update the Git index to either create or delete an empty file.
634 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
641 hash = '0000000000000000000000000000000000000000'
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)
650 def _diff_index(index, filter):
652 Get an {id: {tag, ...}} dict for a given filter.
654 For example, use 'A' to find added tags, and 'D' to find deleted tags.
656 s = _collections.defaultdict(set)
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):
669 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
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):
678 def _unpack_diff_lines(stream):
679 "Iterate through (id, tag) tuples in a diff stream."
681 match = _TAG_FILE_REGEX.match(line.strip())
683 message = 'non-tag line in diff: {!r}'.format(line.strip())
684 if line.startswith(_TAG_DIRECTORY):
685 raise ValueError(message)
688 id = _unquote(match.group('id'))
689 tag = _unquote(match.group('tag'))
693 def _help(parser, command=None):
695 Show help for an nmbug command.
697 Because some folks prefer:
703 $ nmbug COMMAND --help
706 parser.parse_args([command, '--help'])
708 parser.parse_args(['--help'])
710 def _notmuch_config_get(key):
711 (status, stdout, stderr) = _spawn(
712 args=['notmuch', 'config', 'get', key],
713 stdout=_subprocess.PIPE, wait=True)
715 _LOG.error("failed to run notmuch config")
717 return stdout.rstrip()
719 if __name__ == '__main__':
722 parser = argparse.ArgumentParser(
723 description=__doc__.strip(),
724 formatter_class=argparse.RawDescriptionHelpFormatter)
726 '-C', '--git-dir', metavar='REPO',
727 help='Git repository to operate on.')
729 '-p', '--tag-prefix', metavar='PREFIX',
730 default = _os.getenv('NOTMUCH_GIT_PREFIX', 'notmuch::'),
731 help='Prefix of tags to operate on.')
734 choices=['critical', 'error', 'warning', 'info', 'debug'],
735 help='Log verbosity. Defaults to {!r}.'.format(
736 _logging.getLevelName(_LOG.level).lower()))
738 help = _functools.partial(_help, parser=parser)
739 help.__doc__ = _help.__doc__
740 subparsers = parser.add_subparsers(
743 'For help on a particular command, run: '
744 "'%(prog)s ... <command> --help'."))
759 func = locals()[command]
760 doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
761 subparser = subparsers.add_parser(
763 help=doc.splitlines()[0],
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',
771 'The tree or commit to produce an archive for. Defaults '
773 subparser.add_argument(
774 'args', metavar='ARG', nargs='*',
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(
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='?',
793 'Override the default configured in branch.<name>.remote '
794 'to fetch from a particular remote repository (e.g. '
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}',
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='?',
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='*',
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='?',
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='*',
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.'))
840 args = parser.parse_args()
843 NOTMUCH_GIT_DIR = args.git_dir
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
851 TAG_PREFIX = args.tag_prefix
852 _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,') # quote ':'
855 level = getattr(_logging, args.log_level.upper())
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%')))
862 if _notmuch_config_get('built_with.sexp_queries') != 'true':
863 _LOG.error("notmuch git needs sexp query support")
866 if not getattr(args, 'func', None):
870 if args.func == help:
871 arg_names = ['command']
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}
877 except SubprocessError as e:
878 if _LOG.level == _logging.DEBUG:
879 raise # don't mask the traceback