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
43 _LOG = _logging.getLogger('nmbug')
44 _LOG.setLevel(_logging.WARNING)
45 _LOG.addHandler(_logging.StreamHandler())
47 NOTMUCH_GIT_DIR = None
50 _HEX_ESCAPE_REGEX = _re.compile('%[0-9A-F]{2}')
51 _TAG_DIRECTORY = 'tags/'
52 _TAG_FILE_REGEX = _re.compile(_TAG_DIRECTORY + '(?P<id>[^/]*)/(?P<tag>[^/]*)')
54 # magic hash for Git (git hash-object -t blob /dev/null)
55 _EMPTYBLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
57 def _hex_quote(string, safe='+@=:,'):
59 quote('abc def') -> 'abc%20def'.
61 Wrap urllib.parse.quote with additional safe characters (in
62 addition to letters, digits, and '_.-') and lowercase hex digits
63 (e.g. '%3a' instead of '%3A').
65 uppercase_escapes = _quote(string, safe)
66 return _HEX_ESCAPE_REGEX.sub(
67 lambda match: match.group(0).lower(),
70 def _xapian_quote(string):
72 Quote a string for Xapian's QueryParser.
74 Xapian uses double-quotes for quoting strings. You can escape
75 internal quotes by repeating them [1,2,3].
77 [1]: https://trac.xapian.org/ticket/128#comment:2
78 [2]: https://trac.xapian.org/ticket/128#comment:17
79 [3]: https://trac.xapian.org/changeset/13823/svn
81 return '"{0}"'.format(string.replace('"', '""'))
84 def _xapian_unquote(string):
86 Unquote a Xapian-quoted string.
88 if string.startswith('"') and string.endswith('"'):
89 return string[1:-1].replace('""', '"')
95 from time import perf_counter
97 def inner(*args, **kwargs):
98 start_time = perf_counter()
99 rval = fn(*args, **kwargs)
100 end_time = perf_counter()
101 _LOG.info('{0}: {1:.8f}s elapsed'.format(fn.__name__, end_time - start_time))
107 class SubprocessError(RuntimeError):
108 "A subprocess exited with a nonzero status"
109 def __init__(self, args, status, stdout=None, stderr=None):
113 msg = '{args} exited with {status}'.format(args=args, status=status)
115 msg = '{msg}: {stderr}'.format(msg=msg, stderr=stderr)
116 super(SubprocessError, self).__init__(msg)
119 class _SubprocessContextManager(object):
121 PEP 343 context manager for subprocesses.
123 'expect' holds a tuple of acceptable exit codes, otherwise we'll
124 raise a SubprocessError in __exit__.
126 def __init__(self, process, args, expect=(0,)):
127 self._process = process
129 self._expect = expect
134 def __exit__(self, type, value, traceback):
135 for name in ['stdin', 'stdout', 'stderr']:
136 stream = getattr(self._process, name)
139 setattr(self._process, name, None)
140 status = self._process.wait()
142 'collect {args} with status {status} (expected {expect})'.format(
143 args=self._args, status=status, expect=self._expect))
144 if status not in self._expect:
145 raise SubprocessError(args=self._args, status=status)
148 return self._process.wait()
151 def _spawn(args, input=None, additional_env=None, wait=False, stdin=None,
152 stdout=None, stderr=None, encoding=_locale.getpreferredencoding(),
153 expect=(0,), **kwargs):
154 """Spawn a subprocess, and optionally wait for it to finish.
156 This wrapper around subprocess.Popen has two modes, depending on
157 the truthiness of 'wait'. If 'wait' is true, we use p.communicate
158 internally to write 'input' to the subprocess's stdin and read
159 from it's stdout/stderr. If 'wait' is False, we return a
160 _SubprocessContextManager instance for fancier handling
161 (e.g. piping between processes).
163 For 'wait' calls when you want to write to the subprocess's stdin,
164 you only need to set 'input' to your content. When 'input' is not
165 None but 'stdin' is, we'll automatically set 'stdin' to PIPE
166 before calling Popen. This avoids having the subprocess
167 accidentally inherit the launching process's stdin.
169 _LOG.debug('spawn {args} (additional env. var.: {env})'.format(
170 args=args, env=additional_env))
171 if not stdin and input is not None:
172 stdin = _subprocess.PIPE
174 if not kwargs.get('env'):
175 kwargs['env'] = dict(_os.environ)
176 kwargs['env'].update(additional_env)
177 p = _subprocess.Popen(
178 args, stdin=stdin, stdout=stdout, stderr=stderr, **kwargs)
180 if hasattr(input, 'encode'):
181 input = input.encode(encoding)
182 (stdout, stderr) = p.communicate(input=input)
185 'collect {args} with status {status} (expected {expect})'.format(
186 args=args, status=status, expect=expect))
187 if stdout is not None:
188 stdout = stdout.decode(encoding)
189 if stderr is not None:
190 stderr = stderr.decode(encoding)
191 if status not in expect:
192 raise SubprocessError(
193 args=args, status=status, stdout=stdout, stderr=stderr)
194 return (status, stdout, stderr)
195 if p.stdin and not stdin:
199 p.stdin = _codecs.getwriter(encoding=encoding)(stream=p.stdin)
200 stream_reader = _codecs.getreader(encoding=encoding)
202 p.stdout = stream_reader(stream=p.stdout)
204 p.stderr = stream_reader(stream=p.stderr)
205 return _SubprocessContextManager(args=args, process=p, expect=expect)
208 def _git(args, **kwargs):
209 args = ['git', '--git-dir', NOTMUCH_GIT_DIR] + list(args)
210 return _spawn(args=args, **kwargs)
213 def _get_current_branch():
214 """Get the name of the current branch.
216 Return 'None' if we're not on a branch.
219 (status, branch, stderr) = _git(
220 args=['symbolic-ref', '--short', 'HEAD'],
221 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
222 except SubprocessError as e:
223 if 'not a symbolic ref' in e:
226 return branch.strip()
230 "Get the default remote for the current branch."
231 local_branch = _get_current_branch()
232 (status, remote, stderr) = _git(
233 args=['config', 'branch.{0}.remote'.format(local_branch)],
234 stdout=_subprocess.PIPE, wait=True)
235 return remote.strip()
237 def _tag_query(prefix=None):
240 return '(tag (starts-with "{:s}"))'.format(prefix.replace('"','\\\"'))
242 def get_tags(prefix=None):
243 "Get a list of tags with a given prefix."
244 (status, stdout, stderr) = _spawn(
245 args=['notmuch', 'search', '--query=sexp', '--output=tags', _tag_query(prefix)],
246 stdout=_subprocess.PIPE, wait=True)
247 return [tag for tag in stdout.splitlines()]
249 def archive(treeish='HEAD', args=()):
251 Dump a tar archive of the current nmbug tag set.
255 Each tag $tag for message with Message-Id $id is written to
258 tags/encode($id)/encode($tag)
260 The encoding preserves alphanumerics, and the characters
261 "+-_@=.:," (not the quotes). All other octets are replaced with
262 '%' followed by a two digit hex number.
264 _git(args=['archive', treeish] + list(args), wait=True)
267 def clone(repository):
269 Create a local nmbug repository from a remote source.
271 This wraps 'git clone', adding some options to avoid creating a
272 working tree while preserving remote-tracking branches and
275 with _tempfile.TemporaryDirectory(prefix='nmbug-clone.') as workdir:
278 'git', 'clone', '--no-checkout', '--separate-git-dir', NOTMUCH_GIT_DIR,
279 repository, workdir],
281 _git(args=['config', '--unset', 'core.worktree'], wait=True, expect=(0, 5))
282 _git(args=['config', 'core.bare', 'true'], wait=True)
283 (status, stdout, stderr) = _git(args=['show-ref', '--verify',
285 'refs/remotes/origin/config'],
289 _git(args=['branch', 'config', 'origin/config'], wait=True)
290 existing_tags = get_tags()
293 'Not checking out to avoid clobbering existing tags: {}'.format(
294 ', '.join(existing_tags)))
299 def _is_committed(status):
300 return len(status['added']) + len(status['deleted']) == 0
304 def __init__(self, repo, treeish):
305 self.cache_path = _os.path.join(repo, 'notmuch', 'index_cache.json')
306 self.index_path = _os.path.join(repo, 'index')
307 self.current_treeish = treeish
311 self.index_checksum = None
313 self._load_cache_file()
315 def _load_cache_file(self):
317 with open(self.cache_path) as f:
319 self.treeish = data['treeish']
320 self.hash = data['hash']
321 self.index_checksum = data['index_checksum']
322 except FileNotFoundError:
324 except _json.JSONDecodeError:
325 _LOG.error("Error decoding cache")
332 def __exit__(self, type, value, traceback):
333 checksum = _read_index_checksum(self.index_path)
335 args=['rev-parse', self.current_treeish],
336 stdout=_subprocess.PIPE,
339 with open(self.cache_path, "w") as f:
340 _json.dump({'treeish': self.current_treeish,
341 'hash': hash.rstrip(), 'index_checksum': checksum }, f)
345 current_checksum = _read_index_checksum(self.index_path)
347 args=['rev-parse', self.current_treeish],
348 stdout=_subprocess.PIPE,
350 current_hash = hash.rstrip()
352 if self.current_treeish == self.treeish and \
353 self.index_checksum and self.index_checksum == current_checksum and \
354 self.hash and self.hash == current_hash:
357 _git(args=['read-tree', self.current_treeish], wait=True)
360 def commit(treeish='HEAD', message=None):
362 Commit prefix-matching tags from the notmuch database to Git.
365 status = get_status()
367 if _is_committed(status=status):
368 _LOG.warning('Nothing to commit')
371 with CachedIndex(NOTMUCH_GIT_DIR, treeish) as index:
373 _update_index(status=status)
376 stdout=_subprocess.PIPE,
378 (_, parent, _) = _git(
379 args=['rev-parse', treeish],
380 stdout=_subprocess.PIPE,
382 (_, commit, _) = _git(
383 args=['commit-tree', tree.strip(), '-p', parent.strip()],
385 stdout=_subprocess.PIPE,
388 args=['update-ref', treeish, commit.strip()],
389 stdout=_subprocess.PIPE,
391 except Exception as e:
392 _git(args=['read-tree', '--empty'], wait=True)
393 _git(args=['read-tree', treeish], wait=True)
397 def _update_index(status):
399 args=['update-index', '--index-info'],
400 stdin=_subprocess.PIPE) as p:
401 for id, tags in status['deleted'].items():
402 for line in _index_tags_for_message(id=id, status='D', tags=tags):
404 for id, tags in status['added'].items():
405 for line in _index_tags_for_message(id=id, status='A', tags=tags):
409 def fetch(remote=None):
411 Fetch changes from the remote repository.
413 See 'merge' to bring those changes into notmuch.
418 _git(args=args, wait=True)
421 def init(remote=None):
423 Create an empty nmbug repository.
425 This wraps 'git init' with a few extra steps to support subsequent
426 status and commit commands.
428 _spawn(args=['git', '--git-dir', NOTMUCH_GIT_DIR, 'init',
429 '--initial-branch=master', '--quiet', '--bare'], wait=True)
430 _git(args=['config', 'core.logallrefupdates', 'true'], wait=True)
431 # create an empty blob (e69de29bb2d1d6434b8b29ae775ad8c2e48c5391)
432 _git(args=['hash-object', '-w', '--stdin'], input='', wait=True)
435 'commit', '--allow-empty', '-m', 'Start a new nmbug repository'
437 additional_env={'GIT_WORK_TREE': NOTMUCH_GIT_DIR},
443 Update the notmuch database from Git.
445 This is mainly useful to discard your changes in notmuch relative
448 status = get_status()
450 args=['notmuch', 'tag', '--batch'], stdin=_subprocess.PIPE) as p:
451 for id, tags in status['added'].items():
452 p.stdin.write(_batch_line(action='-', id=id, tags=tags))
453 for id, tags in status['deleted'].items():
454 p.stdin.write(_batch_line(action='+', id=id, tags=tags))
457 def _batch_line(action, id, tags):
459 'notmuch tag --batch' line for adding/removing tags.
461 Set 'action' to '-' to remove a tag or '+' to add the tags to a
464 tag_string = ' '.join(
465 '{action}{prefix}{tag}'.format(
466 action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))
468 line = '{tags} -- id:{id}\n'.format(
469 tags=tag_string, id=_xapian_quote(string=id))
473 def _insist_committed():
474 "Die if the the notmuch tags don't match the current HEAD."
475 status = get_status()
476 if not _is_committed(status=status):
477 _LOG.error('\n'.join([
478 'Uncommitted changes to {prefix}* tags in notmuch',
480 "For a summary of changes, run 'nmbug status'",
481 "To save your changes, run 'nmbug commit' before merging/pull",
482 "To discard your changes, run 'nmbug checkout'",
483 ]).format(prefix=TAG_PREFIX))
487 def pull(repository=None, refspecs=None):
489 Pull (merge) remote repository changes to notmuch.
491 'pull' is equivalent to 'fetch' followed by 'merge'. We use the
492 Git-configured repository for your current branch
493 (branch.<name>.repository, likely 'origin', and
494 branch.<name>.merge, likely 'master').
497 if refspecs and not repository:
498 repository = _get_remote()
501 args.append(repository)
503 args.extend(refspecs)
504 with _tempfile.TemporaryDirectory(prefix='nmbug-pull.') as workdir:
510 additional_env={'GIT_WORK_TREE': workdir},
515 def merge(reference='@{upstream}'):
517 Merge changes from 'reference' into HEAD and load the result into notmuch.
519 The default reference is '@{upstream}'.
522 with _tempfile.TemporaryDirectory(prefix='nmbug-merge.') as workdir:
525 ['merge', reference]]:
528 additional_env={'GIT_WORK_TREE': workdir},
535 A simple wrapper for 'git log'.
537 After running 'nmbug fetch', you can inspect the changes with
538 'nmbug log HEAD..@{upstream}'.
540 # we don't want output trapping here, because we want the pager.
541 args = ['log', '--name-status', '--no-renames'] + list(args)
542 with _git(args=args, expect=(0, 1, -13)) as p:
546 def push(repository=None, refspecs=None):
547 "Push the local nmbug Git state to a remote repository."
548 if refspecs and not repository:
549 repository = _get_remote()
552 args.append(repository)
554 args.extend(refspecs)
555 _git(args=args, wait=True)
560 Show pending updates in notmuch or git repo.
562 Prints lines of the form
566 where n is a single character representing notmuch database status
570 Tag is present in notmuch database, but not committed to nmbug
571 (equivalently, tag has been deleted in nmbug repo, e.g. by a
572 pull, but not restored to notmuch database).
576 Tag is present in nmbug repo, but not restored to notmuch
577 database (equivalently, tag has been deleted in notmuch).
581 Message is unknown (missing from local notmuch database).
583 The second character (if present) represents a difference between
584 local and upstream branches. Typically 'nmbug fetch' needs to be
589 Tag is present in upstream, but not in the local Git branch.
593 Tag is present in local Git branch, but not upstream.
595 status = get_status()
596 # 'output' is a nested defaultdict for message status:
597 # * The outer dict is keyed by message id.
598 # * The inner dict is keyed by tag name.
599 # * The inner dict values are status strings (' a', 'Dd', ...).
600 output = _collections.defaultdict(
601 lambda : _collections.defaultdict(lambda : ' '))
602 for id, tags in status['added'].items():
604 output[id][tag] = 'A'
605 for id, tags in status['deleted'].items():
607 output[id][tag] = 'D'
608 for id, tags in status['missing'].items():
610 output[id][tag] = 'U'
612 for id, tag in _diff_refs(filter='A'):
613 output[id][tag] += 'a'
614 for id, tag in _diff_refs(filter='D'):
615 output[id][tag] += 'd'
616 for id, tag_status in sorted(output.items()):
617 for tag, status in sorted(tag_status.items()):
618 print('{status}\t{id}\t{tag}'.format(
619 status=status, id=id, tag=tag))
622 def _is_unmerged(ref='@{upstream}'):
624 (status, fetch_head, stderr) = _git(
625 args=['rev-parse', ref],
626 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
627 except SubprocessError as e:
628 if 'No upstream configured' in e.stderr:
631 (status, base, stderr) = _git(
632 args=['merge-base', 'HEAD', ref],
633 stdout=_subprocess.PIPE, wait=True)
634 return base != fetch_head
643 with PrivateIndex(repo=NOTMUCH_GIT_DIR, prefix=TAG_PREFIX) as index:
644 maybe_deleted = index.diff(filter='D')
645 for id, tags in maybe_deleted.items():
646 (_, stdout, stderr) = _spawn(
647 args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)],
648 stdout=_subprocess.PIPE,
651 status['deleted'][id] = tags
653 status['missing'][id] = tags
654 status['added'] = index.diff(filter='A')
659 def __init__(self, repo, prefix):
661 _os.makedirs(_os.path.join(repo, 'notmuch'))
662 except FileExistsError:
665 file_name = 'notmuch/index'
666 self.index_path = _os.path.join(repo, file_name)
667 self.cache_path = _os.path.join(repo, 'notmuch', '{:s}.json'.format(_hex_quote(file_name)))
669 self.current_prefix = prefix
675 self._load_cache_file()
681 def __exit__(self, type, value, traceback):
682 checksum = _read_index_checksum(self.index_path)
683 (count, uuid, lastmod) = _read_database_lastmod()
684 with open(self.cache_path, "w") as f:
685 _json.dump({'prefix': self.current_prefix, 'uuid': uuid, 'lastmod': lastmod, 'checksum': checksum }, f)
687 def _load_cache_file(self):
689 with open(self.cache_path) as f:
691 self.prefix = data['prefix']
692 self.uuid = data['uuid']
693 self.lastmod = data['lastmod']
694 self.checksum = data['checksum']
695 except FileNotFoundError:
697 except _json.JSONDecodeError:
698 _LOG.error("Error decoding cache")
702 def _index_tags(self):
703 "Write notmuch tags to private git index."
704 prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)
705 current_checksum = _read_index_checksum(self.index_path)
706 if (self.prefix == None or self.prefix != self.current_prefix
707 or self.checksum == None or self.checksum != current_checksum):
709 args=['read-tree', '--empty'],
710 additional_env={'GIT_INDEX_FILE': self.index_path}, wait=True)
714 (count,uuid,lastmod) = _read_database_lastmod()
715 if self.prefix == self.current_prefix and self.uuid \
716 and self.uuid == uuid and self.checksum == current_checksum:
717 query = '(and (infix "lastmod:{:d}..")) {:s})'.format(self.lastmod+1, query)
720 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', query],
721 stdout=_subprocess.PIPE) as notmuch:
723 args=['update-index', '--index-info'],
724 stdin=_subprocess.PIPE,
725 additional_env={'GIT_INDEX_FILE': self.index_path}) as git:
726 for line in notmuch.stdout:
727 if line.strip().startswith('#'):
729 (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
731 _unquote(tag[len(prefix):])
732 for tag in tags_string.split()
733 if tag.startswith(prefix)]
734 id = _xapian_unquote(string=id)
736 for line in _clear_tags_for_message(index=self.index_path, id=id):
737 git.stdin.write(line)
738 for line in _index_tags_for_message(
739 id=id, status='A', tags=tags):
740 git.stdin.write(line)
743 def diff(self, filter):
745 Get an {id: {tag, ...}} dict for a given filter.
747 For example, use 'A' to find added tags, and 'D' to find deleted tags.
749 s = _collections.defaultdict(set)
752 'diff-index', '--cached', '--diff-filter', filter,
753 '--name-only', 'HEAD'],
754 additional_env={'GIT_INDEX_FILE': self.index_path},
755 stdout=_subprocess.PIPE) as p:
756 # Once we drop Python < 3.3, we can use 'yield from' here
757 for id, tag in _unpack_diff_lines(stream=p.stdout):
761 def _read_index_checksum (index_path):
762 """Read the index checksum, as defined by index-format.txt in the git source
763 WARNING: assumes SHA1 repo"""
766 with open(index_path, 'rb') as f:
767 size=_os.path.getsize(index_path)
769 return binascii.hexlify(f.read(20)).decode('ascii')
770 except FileNotFoundError:
774 def _clear_tags_for_message(index, id):
776 Clear any existing index entries for message 'id'
778 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
781 dir = 'tags/{id}'.format(id=_hex_quote(string=id))
784 args=['ls-files', dir],
785 additional_env={'GIT_INDEX_FILE': index},
786 stdout=_subprocess.PIPE) as git:
787 for file in git.stdout:
788 line = '0 0000000000000000000000000000000000000000\t{:s}\n'.format(file.strip())
791 def _read_database_lastmod():
793 args=['notmuch', 'count', '--lastmod', '*'],
794 stdout=_subprocess.PIPE) as notmuch:
795 (count,uuid,lastmod_str) = notmuch.stdout.readline().split()
796 return (count,uuid,int(lastmod_str))
798 def _index_tags_for_message(id, status, tags):
800 Update the Git index to either create or delete an empty file.
802 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
809 hash = '0000000000000000000000000000000000000000'
812 path = 'tags/{id}/{tag}'.format(
813 id=_hex_quote(string=id), tag=_hex_quote(string=tag))
814 yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)
817 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
819 args=['diff', '--diff-filter', filter, '--name-only', a, b],
820 stdout=_subprocess.PIPE) as p:
821 # Once we drop Python < 3.3, we can use 'yield from' here
822 for id, tag in _unpack_diff_lines(stream=p.stdout):
826 def _unpack_diff_lines(stream):
827 "Iterate through (id, tag) tuples in a diff stream."
829 match = _TAG_FILE_REGEX.match(line.strip())
831 message = 'non-tag line in diff: {!r}'.format(line.strip())
832 if line.startswith(_TAG_DIRECTORY):
833 raise ValueError(message)
836 id = _unquote(match.group('id'))
837 tag = _unquote(match.group('tag'))
841 def _help(parser, command=None):
843 Show help for an nmbug command.
845 Because some folks prefer:
851 $ nmbug COMMAND --help
854 parser.parse_args([command, '--help'])
856 parser.parse_args(['--help'])
858 def _notmuch_config_get(key):
859 (status, stdout, stderr) = _spawn(
860 args=['notmuch', 'config', 'get', key],
861 stdout=_subprocess.PIPE, wait=True)
863 _LOG.error("failed to run notmuch config")
865 return stdout.rstrip()
867 if __name__ == '__main__':
870 parser = argparse.ArgumentParser(
871 description=__doc__.strip(),
872 formatter_class=argparse.RawDescriptionHelpFormatter)
874 '-C', '--git-dir', metavar='REPO',
875 help='Git repository to operate on.')
877 '-p', '--tag-prefix', metavar='PREFIX',
878 default = _os.getenv('NOTMUCH_GIT_PREFIX', 'notmuch::'),
879 help='Prefix of tags to operate on.')
882 choices=['critical', 'error', 'warning', 'info', 'debug'],
883 help='Log verbosity. Defaults to {!r}.'.format(
884 _logging.getLevelName(_LOG.level).lower()))
886 help = _functools.partial(_help, parser=parser)
887 help.__doc__ = _help.__doc__
888 subparsers = parser.add_subparsers(
891 'For help on a particular command, run: '
892 "'%(prog)s ... <command> --help'."))
907 func = locals()[command]
908 doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
909 subparser = subparsers.add_parser(
911 help=doc.splitlines()[0],
913 formatter_class=argparse.RawDescriptionHelpFormatter)
914 subparser.set_defaults(func=func)
915 if command == 'archive':
916 subparser.add_argument(
917 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
919 'The tree or commit to produce an archive for. Defaults '
921 subparser.add_argument(
922 'args', metavar='ARG', nargs='*',
924 "Argument passed through to 'git archive'. Set anything "
925 'before <tree-ish>, see git-archive(1) for details.'))
926 elif command == 'clone':
927 subparser.add_argument(
930 'The (possibly remote) repository to clone from. See the '
931 'URLS section of git-clone(1) for more information on '
932 'specifying repositories.'))
933 elif command == 'commit':
934 subparser.add_argument(
935 'message', metavar='MESSAGE', default='', nargs='?',
936 help='Text for the commit message.')
937 elif command == 'fetch':
938 subparser.add_argument(
939 'remote', metavar='REMOTE', nargs='?',
941 'Override the default configured in branch.<name>.remote '
942 'to fetch from a particular remote repository (e.g. '
944 elif command == 'help':
945 subparser.add_argument(
946 'command', metavar='COMMAND', nargs='?',
947 help='The command to show help for.')
948 elif command == 'log':
949 subparser.add_argument(
950 'args', metavar='ARG', nargs='*',
951 help="Additional argument passed through to 'git log'.")
952 elif command == 'merge':
953 subparser.add_argument(
954 'reference', metavar='REFERENCE', default='@{upstream}',
957 'Reference, usually other branch heads, to merge into '
958 "our branch. Defaults to '@{upstream}'."))
959 elif command == 'pull':
960 subparser.add_argument(
961 'repository', metavar='REPOSITORY', default=None, nargs='?',
963 'The "remote" repository that is the source of the pull. '
964 'This parameter can be either a URL (see the section GIT '
965 'URLS in git-pull(1)) or the name of a remote (see the '
966 'section REMOTES in git-pull(1)).'))
967 subparser.add_argument(
968 'refspecs', metavar='REFSPEC', default=None, nargs='*',
970 'Refspec (usually a branch name) to fetch and merge. See '
971 'the <refspec> entry in the OPTIONS section of '
972 'git-pull(1) for other possibilities.'))
973 elif command == 'push':
974 subparser.add_argument(
975 'repository', metavar='REPOSITORY', default=None, nargs='?',
977 'The "remote" repository that is the destination of the '
978 'push. This parameter can be either a URL (see the '
979 'section GIT URLS in git-push(1)) or the name of a remote '
980 '(see the section REMOTES in git-push(1)).'))
981 subparser.add_argument(
982 'refspecs', metavar='REFSPEC', default=None, nargs='*',
984 'Refspec (usually a branch name) to push. See '
985 'the <refspec> entry in the OPTIONS section of '
986 'git-push(1) for other possibilities.'))
988 args = parser.parse_args()
991 NOTMUCH_GIT_DIR = args.git_dir
993 NOTMUCH_GIT_DIR = _os.path.expanduser(
994 _os.getenv('NOTMUCH_GIT_DIR', _os.path.join('~', '.nmbug')))
995 _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
996 if _os.path.isdir(_NOTMUCH_GIT_DIR):
997 NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
999 TAG_PREFIX = args.tag_prefix
1000 _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,') # quote ':'
1003 level = getattr(_logging, args.log_level.upper())
1004 _LOG.setLevel(level)
1007 for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
1008 _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
1010 if _notmuch_config_get('built_with.sexp_queries') != 'true':
1011 _LOG.error("notmuch git needs sexp query support")
1014 if not getattr(args, 'func', None):
1015 parser.print_usage()
1018 if args.func == help:
1019 arg_names = ['command']
1021 (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
1022 kwargs = {key: getattr(args, key) for key in arg_names if key in args}
1025 except SubprocessError as e:
1026 if _LOG.level == _logging.DEBUG:
1027 raise # don't mask the traceback