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 from pathlib import Path
429 parent = Path(NOTMUCH_GIT_DIR).parent
432 except FileExistsError:
435 _spawn(args=['git', '--git-dir', NOTMUCH_GIT_DIR, 'init',
436 '--initial-branch=master', '--quiet', '--bare'], wait=True)
437 _git(args=['config', 'core.logallrefupdates', 'true'], wait=True)
438 # create an empty blob (e69de29bb2d1d6434b8b29ae775ad8c2e48c5391)
439 _git(args=['hash-object', '-w', '--stdin'], input='', wait=True)
442 'commit', '--allow-empty', '-m', 'Start a new nmbug repository'
444 additional_env={'GIT_WORK_TREE': NOTMUCH_GIT_DIR},
450 Update the notmuch database from Git.
452 This is mainly useful to discard your changes in notmuch relative
455 status = get_status()
457 args=['notmuch', 'tag', '--batch'], stdin=_subprocess.PIPE) as p:
458 for id, tags in status['added'].items():
459 p.stdin.write(_batch_line(action='-', id=id, tags=tags))
460 for id, tags in status['deleted'].items():
461 p.stdin.write(_batch_line(action='+', id=id, tags=tags))
464 def _batch_line(action, id, tags):
466 'notmuch tag --batch' line for adding/removing tags.
468 Set 'action' to '-' to remove a tag or '+' to add the tags to a
471 tag_string = ' '.join(
472 '{action}{prefix}{tag}'.format(
473 action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))
475 line = '{tags} -- id:{id}\n'.format(
476 tags=tag_string, id=_xapian_quote(string=id))
480 def _insist_committed():
481 "Die if the the notmuch tags don't match the current HEAD."
482 status = get_status()
483 if not _is_committed(status=status):
484 _LOG.error('\n'.join([
485 'Uncommitted changes to {prefix}* tags in notmuch',
487 "For a summary of changes, run 'nmbug status'",
488 "To save your changes, run 'nmbug commit' before merging/pull",
489 "To discard your changes, run 'nmbug checkout'",
490 ]).format(prefix=TAG_PREFIX))
494 def pull(repository=None, refspecs=None):
496 Pull (merge) remote repository changes to notmuch.
498 'pull' is equivalent to 'fetch' followed by 'merge'. We use the
499 Git-configured repository for your current branch
500 (branch.<name>.repository, likely 'origin', and
501 branch.<name>.merge, likely 'master').
504 if refspecs and not repository:
505 repository = _get_remote()
508 args.append(repository)
510 args.extend(refspecs)
511 with _tempfile.TemporaryDirectory(prefix='nmbug-pull.') as workdir:
517 additional_env={'GIT_WORK_TREE': workdir},
522 def merge(reference='@{upstream}'):
524 Merge changes from 'reference' into HEAD and load the result into notmuch.
526 The default reference is '@{upstream}'.
529 with _tempfile.TemporaryDirectory(prefix='nmbug-merge.') as workdir:
532 ['merge', reference]]:
535 additional_env={'GIT_WORK_TREE': workdir},
542 A simple wrapper for 'git log'.
544 After running 'nmbug fetch', you can inspect the changes with
545 'nmbug log HEAD..@{upstream}'.
547 # we don't want output trapping here, because we want the pager.
548 args = ['log', '--name-status', '--no-renames'] + list(args)
549 with _git(args=args, expect=(0, 1, -13)) as p:
553 def push(repository=None, refspecs=None):
554 "Push the local nmbug Git state to a remote repository."
555 if refspecs and not repository:
556 repository = _get_remote()
559 args.append(repository)
561 args.extend(refspecs)
562 _git(args=args, wait=True)
567 Show pending updates in notmuch or git repo.
569 Prints lines of the form
573 where n is a single character representing notmuch database status
577 Tag is present in notmuch database, but not committed to nmbug
578 (equivalently, tag has been deleted in nmbug repo, e.g. by a
579 pull, but not restored to notmuch database).
583 Tag is present in nmbug repo, but not restored to notmuch
584 database (equivalently, tag has been deleted in notmuch).
588 Message is unknown (missing from local notmuch database).
590 The second character (if present) represents a difference between
591 local and upstream branches. Typically 'nmbug fetch' needs to be
596 Tag is present in upstream, but not in the local Git branch.
600 Tag is present in local Git branch, but not upstream.
602 status = get_status()
603 # 'output' is a nested defaultdict for message status:
604 # * The outer dict is keyed by message id.
605 # * The inner dict is keyed by tag name.
606 # * The inner dict values are status strings (' a', 'Dd', ...).
607 output = _collections.defaultdict(
608 lambda : _collections.defaultdict(lambda : ' '))
609 for id, tags in status['added'].items():
611 output[id][tag] = 'A'
612 for id, tags in status['deleted'].items():
614 output[id][tag] = 'D'
615 for id, tags in status['missing'].items():
617 output[id][tag] = 'U'
619 for id, tag in _diff_refs(filter='A'):
620 output[id][tag] += 'a'
621 for id, tag in _diff_refs(filter='D'):
622 output[id][tag] += 'd'
623 for id, tag_status in sorted(output.items()):
624 for tag, status in sorted(tag_status.items()):
625 print('{status}\t{id}\t{tag}'.format(
626 status=status, id=id, tag=tag))
629 def _is_unmerged(ref='@{upstream}'):
631 (status, fetch_head, stderr) = _git(
632 args=['rev-parse', ref],
633 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
634 except SubprocessError as e:
635 if 'No upstream configured' in e.stderr:
638 (status, base, stderr) = _git(
639 args=['merge-base', 'HEAD', ref],
640 stdout=_subprocess.PIPE, wait=True)
641 return base != fetch_head
650 with PrivateIndex(repo=NOTMUCH_GIT_DIR, prefix=TAG_PREFIX) as index:
651 maybe_deleted = index.diff(filter='D')
652 for id, tags in maybe_deleted.items():
653 (_, stdout, stderr) = _spawn(
654 args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)],
655 stdout=_subprocess.PIPE,
658 status['deleted'][id] = tags
660 status['missing'][id] = tags
661 status['added'] = index.diff(filter='A')
666 def __init__(self, repo, prefix):
668 _os.makedirs(_os.path.join(repo, 'notmuch'))
669 except FileExistsError:
672 file_name = 'notmuch/index'
673 self.index_path = _os.path.join(repo, file_name)
674 self.cache_path = _os.path.join(repo, 'notmuch', '{:s}.json'.format(_hex_quote(file_name)))
676 self.current_prefix = prefix
682 self._load_cache_file()
688 def __exit__(self, type, value, traceback):
689 checksum = _read_index_checksum(self.index_path)
690 (count, uuid, lastmod) = _read_database_lastmod()
691 with open(self.cache_path, "w") as f:
692 _json.dump({'prefix': self.current_prefix, 'uuid': uuid, 'lastmod': lastmod, 'checksum': checksum }, f)
694 def _load_cache_file(self):
696 with open(self.cache_path) as f:
698 self.prefix = data['prefix']
699 self.uuid = data['uuid']
700 self.lastmod = data['lastmod']
701 self.checksum = data['checksum']
702 except FileNotFoundError:
704 except _json.JSONDecodeError:
705 _LOG.error("Error decoding cache")
709 def _index_tags(self):
710 "Write notmuch tags to private git index."
711 prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)
712 current_checksum = _read_index_checksum(self.index_path)
713 if (self.prefix == None or self.prefix != self.current_prefix
714 or self.checksum == None or self.checksum != current_checksum):
716 args=['read-tree', '--empty'],
717 additional_env={'GIT_INDEX_FILE': self.index_path}, wait=True)
721 (count,uuid,lastmod) = _read_database_lastmod()
722 if self.prefix == self.current_prefix and self.uuid \
723 and self.uuid == uuid and self.checksum == current_checksum:
724 query = '(and (infix "lastmod:{:d}..")) {:s})'.format(self.lastmod+1, query)
727 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', query],
728 stdout=_subprocess.PIPE) as notmuch:
730 args=['update-index', '--index-info'],
731 stdin=_subprocess.PIPE,
732 additional_env={'GIT_INDEX_FILE': self.index_path}) as git:
733 for line in notmuch.stdout:
734 if line.strip().startswith('#'):
736 (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
738 _unquote(tag[len(prefix):])
739 for tag in tags_string.split()
740 if tag.startswith(prefix)]
741 id = _xapian_unquote(string=id)
743 for line in _clear_tags_for_message(index=self.index_path, id=id):
744 git.stdin.write(line)
745 for line in _index_tags_for_message(
746 id=id, status='A', tags=tags):
747 git.stdin.write(line)
750 def diff(self, filter):
752 Get an {id: {tag, ...}} dict for a given filter.
754 For example, use 'A' to find added tags, and 'D' to find deleted tags.
756 s = _collections.defaultdict(set)
759 'diff-index', '--cached', '--diff-filter', filter,
760 '--name-only', 'HEAD'],
761 additional_env={'GIT_INDEX_FILE': self.index_path},
762 stdout=_subprocess.PIPE) as p:
763 # Once we drop Python < 3.3, we can use 'yield from' here
764 for id, tag in _unpack_diff_lines(stream=p.stdout):
768 def _read_index_checksum (index_path):
769 """Read the index checksum, as defined by index-format.txt in the git source
770 WARNING: assumes SHA1 repo"""
773 with open(index_path, 'rb') as f:
774 size=_os.path.getsize(index_path)
776 return binascii.hexlify(f.read(20)).decode('ascii')
777 except FileNotFoundError:
781 def _clear_tags_for_message(index, id):
783 Clear any existing index entries for message 'id'
785 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
788 dir = 'tags/{id}'.format(id=_hex_quote(string=id))
791 args=['ls-files', dir],
792 additional_env={'GIT_INDEX_FILE': index},
793 stdout=_subprocess.PIPE) as git:
794 for file in git.stdout:
795 line = '0 0000000000000000000000000000000000000000\t{:s}\n'.format(file.strip())
798 def _read_database_lastmod():
800 args=['notmuch', 'count', '--lastmod', '*'],
801 stdout=_subprocess.PIPE) as notmuch:
802 (count,uuid,lastmod_str) = notmuch.stdout.readline().split()
803 return (count,uuid,int(lastmod_str))
805 def _index_tags_for_message(id, status, tags):
807 Update the Git index to either create or delete an empty file.
809 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
816 hash = '0000000000000000000000000000000000000000'
819 path = 'tags/{id}/{tag}'.format(
820 id=_hex_quote(string=id), tag=_hex_quote(string=tag))
821 yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)
824 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
826 args=['diff', '--diff-filter', filter, '--name-only', a, b],
827 stdout=_subprocess.PIPE) as p:
828 # Once we drop Python < 3.3, we can use 'yield from' here
829 for id, tag in _unpack_diff_lines(stream=p.stdout):
833 def _unpack_diff_lines(stream):
834 "Iterate through (id, tag) tuples in a diff stream."
836 match = _TAG_FILE_REGEX.match(line.strip())
838 message = 'non-tag line in diff: {!r}'.format(line.strip())
839 if line.startswith(_TAG_DIRECTORY):
840 raise ValueError(message)
843 id = _unquote(match.group('id'))
844 tag = _unquote(match.group('tag'))
848 def _help(parser, command=None):
850 Show help for an nmbug command.
852 Because some folks prefer:
858 $ nmbug COMMAND --help
861 parser.parse_args([command, '--help'])
863 parser.parse_args(['--help'])
865 def _notmuch_config_get(key):
866 (status, stdout, stderr) = _spawn(
867 args=['notmuch', 'config', 'get', key],
868 stdout=_subprocess.PIPE, wait=True)
870 _LOG.error("failed to run notmuch config")
872 return stdout.rstrip()
874 # based on BaseDirectory.save_data_path from pyxdg (LGPL2+)
875 def xdg_data_path(profile):
876 resource = _os.path.join('notmuch',profile,'git')
877 assert not resource.startswith('/')
878 _home = _os.path.expanduser('~')
879 xdg_data_home = _os.environ.get('XDG_DATA_HOME') or \
880 _os.path.join(_home, '.local', 'share')
881 path = _os.path.join(xdg_data_home, resource)
884 if __name__ == '__main__':
887 parser = argparse.ArgumentParser(
888 description=__doc__.strip(),
889 formatter_class=argparse.RawDescriptionHelpFormatter)
891 '-C', '--git-dir', metavar='REPO',
892 help='Git repository to operate on.')
894 '-p', '--tag-prefix', metavar='PREFIX',
896 help='Prefix of tags to operate on.')
898 '-N', '--nmbug', action='store_true',
899 help='Set defaults for --tag-prefix and --git-dir for the notmuch bug tracker')
902 choices=['critical', 'error', 'warning', 'info', 'debug'],
903 help='Log verbosity. Defaults to {!r}.'.format(
904 _logging.getLevelName(_LOG.level).lower()))
906 help = _functools.partial(_help, parser=parser)
907 help.__doc__ = _help.__doc__
908 subparsers = parser.add_subparsers(
911 'For help on a particular command, run: '
912 "'%(prog)s ... <command> --help'."))
927 func = locals()[command]
928 doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
929 subparser = subparsers.add_parser(
931 help=doc.splitlines()[0],
933 formatter_class=argparse.RawDescriptionHelpFormatter)
934 subparser.set_defaults(func=func)
935 if command == 'archive':
936 subparser.add_argument(
937 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
939 'The tree or commit to produce an archive for. Defaults '
941 subparser.add_argument(
942 'args', metavar='ARG', nargs='*',
944 "Argument passed through to 'git archive'. Set anything "
945 'before <tree-ish>, see git-archive(1) for details.'))
946 elif command == 'clone':
947 subparser.add_argument(
950 'The (possibly remote) repository to clone from. See the '
951 'URLS section of git-clone(1) for more information on '
952 'specifying repositories.'))
953 elif command == 'commit':
954 subparser.add_argument(
955 'message', metavar='MESSAGE', default='', nargs='?',
956 help='Text for the commit message.')
957 elif command == 'fetch':
958 subparser.add_argument(
959 'remote', metavar='REMOTE', nargs='?',
961 'Override the default configured in branch.<name>.remote '
962 'to fetch from a particular remote repository (e.g. '
964 elif command == 'help':
965 subparser.add_argument(
966 'command', metavar='COMMAND', nargs='?',
967 help='The command to show help for.')
968 elif command == 'log':
969 subparser.add_argument(
970 'args', metavar='ARG', nargs='*',
971 help="Additional argument passed through to 'git log'.")
972 elif command == 'merge':
973 subparser.add_argument(
974 'reference', metavar='REFERENCE', default='@{upstream}',
977 'Reference, usually other branch heads, to merge into '
978 "our branch. Defaults to '@{upstream}'."))
979 elif command == 'pull':
980 subparser.add_argument(
981 'repository', metavar='REPOSITORY', default=None, nargs='?',
983 'The "remote" repository that is the source of the pull. '
984 'This parameter can be either a URL (see the section GIT '
985 'URLS in git-pull(1)) or the name of a remote (see the '
986 'section REMOTES in git-pull(1)).'))
987 subparser.add_argument(
988 'refspecs', metavar='REFSPEC', default=None, nargs='*',
990 'Refspec (usually a branch name) to fetch and merge. See '
991 'the <refspec> entry in the OPTIONS section of '
992 'git-pull(1) for other possibilities.'))
993 elif command == 'push':
994 subparser.add_argument(
995 'repository', metavar='REPOSITORY', default=None, nargs='?',
997 'The "remote" repository that is the destination of the '
998 'push. This parameter can be either a URL (see the '
999 'section GIT URLS in git-push(1)) or the name of a remote '
1000 '(see the section REMOTES in git-push(1)).'))
1001 subparser.add_argument(
1002 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1004 'Refspec (usually a branch name) to push. See '
1005 'the <refspec> entry in the OPTIONS section of '
1006 'git-push(1) for other possibilities.'))
1008 args = parser.parse_args()
1011 notmuch_profile = _os.getenv('NOTMUCH_PROFILE','default')
1013 if args.nmbug or _os.path.basename(__file__) == 'nmbug':
1017 NOTMUCH_GIT_DIR = args.git_dir
1020 default = _os.path.join('~', '.nmbug')
1022 default = xdg_data_path(notmuch_profile)
1024 NOTMUCH_GIT_DIR = _os.path.expanduser(_os.getenv('NOTMUCH_GIT_DIR', default))
1026 _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
1027 if _os.path.isdir(_NOTMUCH_GIT_DIR):
1028 NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
1031 TAG_PREFIX = args.tag_prefix
1034 prefix = 'notmuch::'
1038 TAG_PREFIX = _os.getenv('NOTMUCH_GIT_PREFIX', prefix)
1040 _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,') # quote ':'
1043 level = getattr(_logging, args.log_level.upper())
1044 _LOG.setLevel(level)
1047 for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
1048 _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
1050 if _notmuch_config_get('built_with.sexp_queries') != 'true':
1051 _LOG.error("notmuch git needs sexp query support")
1054 if not getattr(args, 'func', None):
1055 parser.print_usage()
1058 # The following two lines are used by the test suite.
1059 _LOG.debug('prefix = {:s}'.format(TAG_PREFIX))
1060 _LOG.debug('repository = {:s}'.format(NOTMUCH_GIT_DIR))
1062 if args.func == help:
1063 arg_names = ['command']
1065 (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
1066 kwargs = {key: getattr(args, key) for key in arg_names if key in args}
1069 except SubprocessError as e:
1070 if _LOG.level == _logging.DEBUG:
1071 raise # don't mask the traceback