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 count_messages(prefix=None):
243 "count messages with a given prefix."
244 (status, stdout, stderr) = _spawn(
245 args=['notmuch', 'count', '--query=sexp', _tag_query(prefix)],
246 stdout=_subprocess.PIPE, wait=True)
248 _LOG.error("failed to run notmuch config")
250 return int(stdout.rstrip())
252 def get_tags(prefix=None):
253 "Get a list of tags with a given prefix."
254 (status, stdout, stderr) = _spawn(
255 args=['notmuch', 'search', '--query=sexp', '--output=tags', _tag_query(prefix)],
256 stdout=_subprocess.PIPE, wait=True)
257 return [tag for tag in stdout.splitlines()]
259 def archive(treeish='HEAD', args=()):
261 Dump a tar archive of the current nmbug tag set.
265 Each tag $tag for message with Message-Id $id is written to
268 tags/encode($id)/encode($tag)
270 The encoding preserves alphanumerics, and the characters
271 "+-_@=.:," (not the quotes). All other octets are replaced with
272 '%' followed by a two digit hex number.
274 _git(args=['archive', treeish] + list(args), wait=True)
277 def clone(repository):
279 Create a local nmbug repository from a remote source.
281 This wraps 'git clone', adding some options to avoid creating a
282 working tree while preserving remote-tracking branches and
285 with _tempfile.TemporaryDirectory(prefix='nmbug-clone.') as workdir:
288 'git', 'clone', '--no-checkout', '--separate-git-dir', NOTMUCH_GIT_DIR,
289 repository, workdir],
291 _git(args=['config', '--unset', 'core.worktree'], wait=True, expect=(0, 5))
292 _git(args=['config', 'core.bare', 'true'], wait=True)
293 (status, stdout, stderr) = _git(args=['show-ref', '--verify',
295 'refs/remotes/origin/config'],
299 _git(args=['branch', 'config', 'origin/config'], wait=True)
300 existing_tags = get_tags()
303 'Not checking out to avoid clobbering existing tags: {}'.format(
304 ', '.join(existing_tags)))
309 def _is_committed(status):
310 return len(status['added']) + len(status['deleted']) == 0
314 def __init__(self, repo, treeish):
315 self.cache_path = _os.path.join(repo, 'notmuch', 'index_cache.json')
316 self.index_path = _os.path.join(repo, 'index')
317 self.current_treeish = treeish
321 self.index_checksum = None
323 self._load_cache_file()
325 def _load_cache_file(self):
327 with open(self.cache_path) as f:
329 self.treeish = data['treeish']
330 self.hash = data['hash']
331 self.index_checksum = data['index_checksum']
332 except FileNotFoundError:
334 except _json.JSONDecodeError:
335 _LOG.error("Error decoding cache")
342 def __exit__(self, type, value, traceback):
343 checksum = _read_index_checksum(self.index_path)
345 args=['rev-parse', self.current_treeish],
346 stdout=_subprocess.PIPE,
349 with open(self.cache_path, "w") as f:
350 _json.dump({'treeish': self.current_treeish,
351 'hash': hash.rstrip(), 'index_checksum': checksum }, f)
355 current_checksum = _read_index_checksum(self.index_path)
357 args=['rev-parse', self.current_treeish],
358 stdout=_subprocess.PIPE,
360 current_hash = hash.rstrip()
362 if self.current_treeish == self.treeish and \
363 self.index_checksum and self.index_checksum == current_checksum and \
364 self.hash and self.hash == current_hash:
367 _git(args=['read-tree', self.current_treeish], wait=True)
370 def check_safe_fraction(status):
372 conf = _notmuch_config_get ('git.safe_fraction')
373 if conf and conf != '':
376 total = count_messages (TAG_PREFIX)
378 _LOG.error('No existing tags with given prefix, stopping.'.format(safe))
379 _LOG.error('Use --force to override.')
381 change = len(status['added'])+len(status['deleted'])
382 fraction = change/total
383 _LOG.debug('total messages {:d}, change: {:d}, fraction: {:f}'.format(total,change,fraction))
385 _LOG.error('safe fraction {:f} exceeded, stopping.'.format(safe))
386 _LOG.error('Use --force to override or reconfigure git.safe_fraction.')
389 def commit(treeish='HEAD', message=None, force=False):
391 Commit prefix-matching tags from the notmuch database to Git.
394 status = get_status()
396 if _is_committed(status=status):
397 _LOG.warning('Nothing to commit')
401 check_safe_fraction (status)
403 with CachedIndex(NOTMUCH_GIT_DIR, treeish) as index:
405 _update_index(status=status)
408 stdout=_subprocess.PIPE,
410 (_, parent, _) = _git(
411 args=['rev-parse', treeish],
412 stdout=_subprocess.PIPE,
414 (_, commit, _) = _git(
415 args=['commit-tree', tree.strip(), '-p', parent.strip()],
417 stdout=_subprocess.PIPE,
420 args=['update-ref', treeish, commit.strip()],
421 stdout=_subprocess.PIPE,
423 except Exception as e:
424 _git(args=['read-tree', '--empty'], wait=True)
425 _git(args=['read-tree', treeish], wait=True)
429 def _update_index(status):
431 args=['update-index', '--index-info'],
432 stdin=_subprocess.PIPE) as p:
433 for id, tags in status['deleted'].items():
434 for line in _index_tags_for_message(id=id, status='D', tags=tags):
436 for id, tags in status['added'].items():
437 for line in _index_tags_for_message(id=id, status='A', tags=tags):
441 def fetch(remote=None):
443 Fetch changes from the remote repository.
445 See 'merge' to bring those changes into notmuch.
450 _git(args=args, wait=True)
453 def init(remote=None):
455 Create an empty nmbug repository.
457 This wraps 'git init' with a few extra steps to support subsequent
458 status and commit commands.
460 from pathlib import Path
461 parent = Path(NOTMUCH_GIT_DIR).parent
464 except FileExistsError:
467 _spawn(args=['git', '--git-dir', NOTMUCH_GIT_DIR, 'init',
468 '--initial-branch=master', '--quiet', '--bare'], wait=True)
469 _git(args=['config', 'core.logallrefupdates', 'true'], wait=True)
470 # create an empty blob (e69de29bb2d1d6434b8b29ae775ad8c2e48c5391)
471 _git(args=['hash-object', '-w', '--stdin'], input='', wait=True)
474 'commit', '--allow-empty', '-m', 'Start a new nmbug repository'
476 additional_env={'GIT_WORK_TREE': NOTMUCH_GIT_DIR},
480 def checkout(force=None):
482 Update the notmuch database from Git.
484 This is mainly useful to discard your changes in notmuch relative
487 status = get_status()
490 check_safe_fraction(status)
493 args=['notmuch', 'tag', '--batch'], stdin=_subprocess.PIPE) as p:
494 for id, tags in status['added'].items():
495 p.stdin.write(_batch_line(action='-', id=id, tags=tags))
496 for id, tags in status['deleted'].items():
497 p.stdin.write(_batch_line(action='+', id=id, tags=tags))
500 def _batch_line(action, id, tags):
502 'notmuch tag --batch' line for adding/removing tags.
504 Set 'action' to '-' to remove a tag or '+' to add the tags to a
507 tag_string = ' '.join(
508 '{action}{prefix}{tag}'.format(
509 action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))
511 line = '{tags} -- id:{id}\n'.format(
512 tags=tag_string, id=_xapian_quote(string=id))
516 def _insist_committed():
517 "Die if the the notmuch tags don't match the current HEAD."
518 status = get_status()
519 if not _is_committed(status=status):
520 _LOG.error('\n'.join([
521 'Uncommitted changes to {prefix}* tags in notmuch',
523 "For a summary of changes, run 'nmbug status'",
524 "To save your changes, run 'nmbug commit' before merging/pull",
525 "To discard your changes, run 'nmbug checkout'",
526 ]).format(prefix=TAG_PREFIX))
530 def pull(repository=None, refspecs=None):
532 Pull (merge) remote repository changes to notmuch.
534 'pull' is equivalent to 'fetch' followed by 'merge'. We use the
535 Git-configured repository for your current branch
536 (branch.<name>.repository, likely 'origin', and
537 branch.<name>.merge, likely 'master').
540 if refspecs and not repository:
541 repository = _get_remote()
544 args.append(repository)
546 args.extend(refspecs)
547 with _tempfile.TemporaryDirectory(prefix='nmbug-pull.') as workdir:
553 additional_env={'GIT_WORK_TREE': workdir},
558 def merge(reference='@{upstream}'):
560 Merge changes from 'reference' into HEAD and load the result into notmuch.
562 The default reference is '@{upstream}'.
565 with _tempfile.TemporaryDirectory(prefix='nmbug-merge.') as workdir:
568 ['merge', reference]]:
571 additional_env={'GIT_WORK_TREE': workdir},
578 A simple wrapper for 'git log'.
580 After running 'nmbug fetch', you can inspect the changes with
581 'nmbug log HEAD..@{upstream}'.
583 # we don't want output trapping here, because we want the pager.
584 args = ['log', '--name-status', '--no-renames'] + list(args)
585 with _git(args=args, expect=(0, 1, -13)) as p:
589 def push(repository=None, refspecs=None):
590 "Push the local nmbug Git state to a remote repository."
591 if refspecs and not repository:
592 repository = _get_remote()
595 args.append(repository)
597 args.extend(refspecs)
598 _git(args=args, wait=True)
603 Show pending updates in notmuch or git repo.
605 Prints lines of the form
609 where n is a single character representing notmuch database status
613 Tag is present in notmuch database, but not committed to nmbug
614 (equivalently, tag has been deleted in nmbug repo, e.g. by a
615 pull, but not restored to notmuch database).
619 Tag is present in nmbug repo, but not restored to notmuch
620 database (equivalently, tag has been deleted in notmuch).
624 Message is unknown (missing from local notmuch database).
626 The second character (if present) represents a difference between
627 local and upstream branches. Typically 'nmbug fetch' needs to be
632 Tag is present in upstream, but not in the local Git branch.
636 Tag is present in local Git branch, but not upstream.
638 status = get_status()
639 # 'output' is a nested defaultdict for message status:
640 # * The outer dict is keyed by message id.
641 # * The inner dict is keyed by tag name.
642 # * The inner dict values are status strings (' a', 'Dd', ...).
643 output = _collections.defaultdict(
644 lambda : _collections.defaultdict(lambda : ' '))
645 for id, tags in status['added'].items():
647 output[id][tag] = 'A'
648 for id, tags in status['deleted'].items():
650 output[id][tag] = 'D'
651 for id, tags in status['missing'].items():
653 output[id][tag] = 'U'
655 for id, tag in _diff_refs(filter='A'):
656 output[id][tag] += 'a'
657 for id, tag in _diff_refs(filter='D'):
658 output[id][tag] += 'd'
659 for id, tag_status in sorted(output.items()):
660 for tag, status in sorted(tag_status.items()):
661 print('{status}\t{id}\t{tag}'.format(
662 status=status, id=id, tag=tag))
665 def _is_unmerged(ref='@{upstream}'):
667 (status, fetch_head, stderr) = _git(
668 args=['rev-parse', ref],
669 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
670 except SubprocessError as e:
671 if 'No upstream configured' in e.stderr:
674 (status, base, stderr) = _git(
675 args=['merge-base', 'HEAD', ref],
676 stdout=_subprocess.PIPE, wait=True)
677 return base != fetch_head
686 with PrivateIndex(repo=NOTMUCH_GIT_DIR, prefix=TAG_PREFIX) as index:
687 maybe_deleted = index.diff(filter='D')
688 for id, tags in maybe_deleted.items():
689 (_, stdout, stderr) = _spawn(
690 args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)],
691 stdout=_subprocess.PIPE,
694 status['deleted'][id] = tags
696 status['missing'][id] = tags
697 status['added'] = index.diff(filter='A')
702 def __init__(self, repo, prefix):
704 _os.makedirs(_os.path.join(repo, 'notmuch'))
705 except FileExistsError:
708 file_name = 'notmuch/index'
709 self.index_path = _os.path.join(repo, file_name)
710 self.cache_path = _os.path.join(repo, 'notmuch', '{:s}.json'.format(_hex_quote(file_name)))
712 self.current_prefix = prefix
718 self._load_cache_file()
724 def __exit__(self, type, value, traceback):
725 checksum = _read_index_checksum(self.index_path)
726 (count, uuid, lastmod) = _read_database_lastmod()
727 with open(self.cache_path, "w") as f:
728 _json.dump({'prefix': self.current_prefix, 'uuid': uuid, 'lastmod': lastmod, 'checksum': checksum }, f)
730 def _load_cache_file(self):
732 with open(self.cache_path) as f:
734 self.prefix = data['prefix']
735 self.uuid = data['uuid']
736 self.lastmod = data['lastmod']
737 self.checksum = data['checksum']
738 except FileNotFoundError:
740 except _json.JSONDecodeError:
741 _LOG.error("Error decoding cache")
745 def _index_tags(self):
746 "Write notmuch tags to private git index."
747 prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)
748 current_checksum = _read_index_checksum(self.index_path)
749 if (self.prefix == None or self.prefix != self.current_prefix
750 or self.checksum == None or self.checksum != current_checksum):
752 args=['read-tree', '--empty'],
753 additional_env={'GIT_INDEX_FILE': self.index_path}, wait=True)
757 (count,uuid,lastmod) = _read_database_lastmod()
758 if self.prefix == self.current_prefix and self.uuid \
759 and self.uuid == uuid and self.checksum == current_checksum:
760 query = '(and (infix "lastmod:{:d}..")) {:s})'.format(self.lastmod+1, query)
763 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', query],
764 stdout=_subprocess.PIPE) as notmuch:
766 args=['update-index', '--index-info'],
767 stdin=_subprocess.PIPE,
768 additional_env={'GIT_INDEX_FILE': self.index_path}) as git:
769 for line in notmuch.stdout:
770 if line.strip().startswith('#'):
772 (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
774 _unquote(tag[len(prefix):])
775 for tag in tags_string.split()
776 if tag.startswith(prefix)]
777 id = _xapian_unquote(string=id)
779 for line in _clear_tags_for_message(index=self.index_path, id=id):
780 git.stdin.write(line)
781 for line in _index_tags_for_message(
782 id=id, status='A', tags=tags):
783 git.stdin.write(line)
786 def diff(self, filter):
788 Get an {id: {tag, ...}} dict for a given filter.
790 For example, use 'A' to find added tags, and 'D' to find deleted tags.
792 s = _collections.defaultdict(set)
795 'diff-index', '--cached', '--diff-filter', filter,
796 '--name-only', 'HEAD'],
797 additional_env={'GIT_INDEX_FILE': self.index_path},
798 stdout=_subprocess.PIPE) as p:
799 # Once we drop Python < 3.3, we can use 'yield from' here
800 for id, tag in _unpack_diff_lines(stream=p.stdout):
804 def _read_index_checksum (index_path):
805 """Read the index checksum, as defined by index-format.txt in the git source
806 WARNING: assumes SHA1 repo"""
809 with open(index_path, 'rb') as f:
810 size=_os.path.getsize(index_path)
812 return binascii.hexlify(f.read(20)).decode('ascii')
813 except FileNotFoundError:
817 def _clear_tags_for_message(index, id):
819 Clear any existing index entries for message 'id'
821 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
824 dir = 'tags/{id}'.format(id=_hex_quote(string=id))
827 args=['ls-files', dir],
828 additional_env={'GIT_INDEX_FILE': index},
829 stdout=_subprocess.PIPE) as git:
830 for file in git.stdout:
831 line = '0 0000000000000000000000000000000000000000\t{:s}\n'.format(file.strip())
834 def _read_database_lastmod():
836 args=['notmuch', 'count', '--lastmod', '*'],
837 stdout=_subprocess.PIPE) as notmuch:
838 (count,uuid,lastmod_str) = notmuch.stdout.readline().split()
839 return (count,uuid,int(lastmod_str))
841 def _index_tags_for_message(id, status, tags):
843 Update the Git index to either create or delete an empty file.
845 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
852 hash = '0000000000000000000000000000000000000000'
855 path = 'tags/{id}/{tag}'.format(
856 id=_hex_quote(string=id), tag=_hex_quote(string=tag))
857 yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)
860 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
862 args=['diff', '--diff-filter', filter, '--name-only', a, b],
863 stdout=_subprocess.PIPE) as p:
864 # Once we drop Python < 3.3, we can use 'yield from' here
865 for id, tag in _unpack_diff_lines(stream=p.stdout):
869 def _unpack_diff_lines(stream):
870 "Iterate through (id, tag) tuples in a diff stream."
872 match = _TAG_FILE_REGEX.match(line.strip())
874 message = 'non-tag line in diff: {!r}'.format(line.strip())
875 if line.startswith(_TAG_DIRECTORY):
876 raise ValueError(message)
879 id = _unquote(match.group('id'))
880 tag = _unquote(match.group('tag'))
884 def _help(parser, command=None):
886 Show help for an nmbug command.
888 Because some folks prefer:
894 $ nmbug COMMAND --help
897 parser.parse_args([command, '--help'])
899 parser.parse_args(['--help'])
901 def _notmuch_config_get(key):
902 (status, stdout, stderr) = _spawn(
903 args=['notmuch', 'config', 'get', key],
904 stdout=_subprocess.PIPE, wait=True)
906 _LOG.error("failed to run notmuch config")
908 return stdout.rstrip()
910 # based on BaseDirectory.save_data_path from pyxdg (LGPL2+)
911 def xdg_data_path(profile):
912 resource = _os.path.join('notmuch',profile,'git')
913 assert not resource.startswith('/')
914 _home = _os.path.expanduser('~')
915 xdg_data_home = _os.environ.get('XDG_DATA_HOME') or \
916 _os.path.join(_home, '.local', 'share')
917 path = _os.path.join(xdg_data_home, resource)
920 if __name__ == '__main__':
923 parser = argparse.ArgumentParser(
924 description=__doc__.strip(),
925 formatter_class=argparse.RawDescriptionHelpFormatter)
927 '-C', '--git-dir', metavar='REPO',
928 help='Git repository to operate on.')
930 '-p', '--tag-prefix', metavar='PREFIX',
932 help='Prefix of tags to operate on.')
934 '-N', '--nmbug', action='store_true',
935 help='Set defaults for --tag-prefix and --git-dir for the notmuch bug tracker')
938 choices=['critical', 'error', 'warning', 'info', 'debug'],
939 help='Log verbosity. Defaults to {!r}.'.format(
940 _logging.getLevelName(_LOG.level).lower()))
942 help = _functools.partial(_help, parser=parser)
943 help.__doc__ = _help.__doc__
944 subparsers = parser.add_subparsers(
947 'For help on a particular command, run: '
948 "'%(prog)s ... <command> --help'."))
963 func = locals()[command]
964 doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
965 subparser = subparsers.add_parser(
967 help=doc.splitlines()[0],
969 formatter_class=argparse.RawDescriptionHelpFormatter)
970 subparser.set_defaults(func=func)
971 if command == 'archive':
972 subparser.add_argument(
973 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
975 'The tree or commit to produce an archive for. Defaults '
977 subparser.add_argument(
978 'args', metavar='ARG', nargs='*',
980 "Argument passed through to 'git archive'. Set anything "
981 'before <tree-ish>, see git-archive(1) for details.'))
982 elif command == 'checkout':
983 subparser.add_argument(
984 '-f', '--force', action='store_true',
985 help='checkout a large fraction of tags.')
986 elif command == 'clone':
987 subparser.add_argument(
990 'The (possibly remote) repository to clone from. See the '
991 'URLS section of git-clone(1) for more information on '
992 'specifying repositories.'))
993 elif command == 'commit':
994 subparser.add_argument(
995 '-f', '--force', action='store_true',
996 help='commit a large fraction of tags.')
997 subparser.add_argument(
998 'message', metavar='MESSAGE', default='', nargs='?',
999 help='Text for the commit message.')
1000 elif command == 'fetch':
1001 subparser.add_argument(
1002 'remote', metavar='REMOTE', nargs='?',
1004 'Override the default configured in branch.<name>.remote '
1005 'to fetch from a particular remote repository (e.g. '
1007 elif command == 'help':
1008 subparser.add_argument(
1009 'command', metavar='COMMAND', nargs='?',
1010 help='The command to show help for.')
1011 elif command == 'log':
1012 subparser.add_argument(
1013 'args', metavar='ARG', nargs='*',
1014 help="Additional argument passed through to 'git log'.")
1015 elif command == 'merge':
1016 subparser.add_argument(
1017 'reference', metavar='REFERENCE', default='@{upstream}',
1020 'Reference, usually other branch heads, to merge into '
1021 "our branch. Defaults to '@{upstream}'."))
1022 elif command == 'pull':
1023 subparser.add_argument(
1024 'repository', metavar='REPOSITORY', default=None, nargs='?',
1026 'The "remote" repository that is the source of the pull. '
1027 'This parameter can be either a URL (see the section GIT '
1028 'URLS in git-pull(1)) or the name of a remote (see the '
1029 'section REMOTES in git-pull(1)).'))
1030 subparser.add_argument(
1031 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1033 'Refspec (usually a branch name) to fetch and merge. See '
1034 'the <refspec> entry in the OPTIONS section of '
1035 'git-pull(1) for other possibilities.'))
1036 elif command == 'push':
1037 subparser.add_argument(
1038 'repository', metavar='REPOSITORY', default=None, nargs='?',
1040 'The "remote" repository that is the destination of the '
1041 'push. This parameter can be either a URL (see the '
1042 'section GIT URLS in git-push(1)) or the name of a remote '
1043 '(see the section REMOTES in git-push(1)).'))
1044 subparser.add_argument(
1045 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1047 'Refspec (usually a branch name) to push. See '
1048 'the <refspec> entry in the OPTIONS section of '
1049 'git-push(1) for other possibilities.'))
1051 args = parser.parse_args()
1054 notmuch_profile = _os.getenv('NOTMUCH_PROFILE','default')
1056 if args.nmbug or _os.path.basename(__file__) == 'nmbug':
1060 NOTMUCH_GIT_DIR = args.git_dir
1063 default = _os.path.join('~', '.nmbug')
1065 default = _notmuch_config_get ('git.path')
1067 default = xdg_data_path(notmuch_profile)
1069 NOTMUCH_GIT_DIR = _os.path.expanduser(_os.getenv('NOTMUCH_GIT_DIR', default))
1071 _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
1072 if _os.path.isdir(_NOTMUCH_GIT_DIR):
1073 NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
1076 TAG_PREFIX = args.tag_prefix
1079 prefix = 'notmuch::'
1081 prefix = _notmuch_config_get ('git.tag_prefix')
1083 TAG_PREFIX = _os.getenv('NOTMUCH_GIT_PREFIX', prefix)
1085 _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,') # quote ':'
1088 level = getattr(_logging, args.log_level.upper())
1089 _LOG.setLevel(level)
1092 for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
1093 _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
1095 if _notmuch_config_get('built_with.sexp_queries') != 'true':
1096 _LOG.error("notmuch git needs sexp query support")
1099 if not getattr(args, 'func', None):
1100 parser.print_usage()
1103 # The following two lines are used by the test suite.
1104 _LOG.debug('prefix = {:s}'.format(TAG_PREFIX))
1105 _LOG.debug('repository = {:s}'.format(NOTMUCH_GIT_DIR))
1107 if args.func == help:
1108 arg_names = ['command']
1110 (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
1111 kwargs = {key: getattr(args, key) for key in arg_names if key in args}
1114 except SubprocessError as e:
1115 if _LOG.level == _logging.DEBUG:
1116 raise # don't mask the traceback