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('notmuch-git')
44 _LOG.setLevel(_logging.WARNING)
45 _LOG.addHandler(_logging.StreamHandler())
47 NOTMUCH_GIT_DIR = None
51 _HEX_ESCAPE_REGEX = _re.compile('%[0-9A-F]{2}')
52 _TAG_DIRECTORY = 'tags/'
53 _TAG_FILE_REGEX = ( _re.compile(_TAG_DIRECTORY + '(?P<id>[^/]*)/(?P<tag>[^/]*)'),
54 _re.compile(_TAG_DIRECTORY + '([0-9a-f]{2}/){2}(?P<id>[^/]*)/(?P<tag>[^/]*)'))
56 # magic hash for Git (git hash-object -t blob /dev/null)
57 _EMPTYBLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
59 def _hex_quote(string, safe='+@=:,'):
61 quote('abc def') -> 'abc%20def'.
63 Wrap urllib.parse.quote with additional safe characters (in
64 addition to letters, digits, and '_.-') and lowercase hex digits
65 (e.g. '%3a' instead of '%3A').
67 uppercase_escapes = _quote(string, safe)
68 return _HEX_ESCAPE_REGEX.sub(
69 lambda match: match.group(0).lower(),
72 def _xapian_quote(string):
74 Quote a string for Xapian's QueryParser.
76 Xapian uses double-quotes for quoting strings. You can escape
77 internal quotes by repeating them [1,2,3].
79 [1]: https://trac.xapian.org/ticket/128#comment:2
80 [2]: https://trac.xapian.org/ticket/128#comment:17
81 [3]: https://trac.xapian.org/changeset/13823/svn
83 return '"{0}"'.format(string.replace('"', '""'))
86 def _xapian_unquote(string):
88 Unquote a Xapian-quoted string.
90 if string.startswith('"') and string.endswith('"'):
91 return string[1:-1].replace('""', '"')
97 from time import perf_counter
99 def inner(*args, **kwargs):
100 start_time = perf_counter()
101 rval = fn(*args, **kwargs)
102 end_time = perf_counter()
103 _LOG.info('{0}: {1:.8f}s elapsed'.format(fn.__name__, end_time - start_time))
109 class SubprocessError(RuntimeError):
110 "A subprocess exited with a nonzero status"
111 def __init__(self, args, status, stdout=None, stderr=None):
115 msg = '{args} exited with {status}'.format(args=args, status=status)
117 msg = '{msg}: {stderr}'.format(msg=msg, stderr=stderr)
118 super(SubprocessError, self).__init__(msg)
121 class _SubprocessContextManager(object):
123 PEP 343 context manager for subprocesses.
125 'expect' holds a tuple of acceptable exit codes, otherwise we'll
126 raise a SubprocessError in __exit__.
128 def __init__(self, process, args, expect=(0,)):
129 self._process = process
131 self._expect = expect
136 def __exit__(self, type, value, traceback):
137 for name in ['stdin', 'stdout', 'stderr']:
138 stream = getattr(self._process, name)
141 setattr(self._process, name, None)
142 status = self._process.wait()
144 'collect {args} with status {status} (expected {expect})'.format(
145 args=self._args, status=status, expect=self._expect))
146 if status not in self._expect:
147 raise SubprocessError(args=self._args, status=status)
150 return self._process.wait()
153 def _spawn(args, input=None, additional_env=None, wait=False, stdin=None,
154 stdout=None, stderr=None, encoding=_locale.getpreferredencoding(),
155 expect=(0,), **kwargs):
156 """Spawn a subprocess, and optionally wait for it to finish.
158 This wrapper around subprocess.Popen has two modes, depending on
159 the truthiness of 'wait'. If 'wait' is true, we use p.communicate
160 internally to write 'input' to the subprocess's stdin and read
161 from it's stdout/stderr. If 'wait' is False, we return a
162 _SubprocessContextManager instance for fancier handling
163 (e.g. piping between processes).
165 For 'wait' calls when you want to write to the subprocess's stdin,
166 you only need to set 'input' to your content. When 'input' is not
167 None but 'stdin' is, we'll automatically set 'stdin' to PIPE
168 before calling Popen. This avoids having the subprocess
169 accidentally inherit the launching process's stdin.
171 _LOG.debug('spawn {args} (additional env. var.: {env})'.format(
172 args=args, env=additional_env))
173 if not stdin and input is not None:
174 stdin = _subprocess.PIPE
176 if not kwargs.get('env'):
177 kwargs['env'] = dict(_os.environ)
178 kwargs['env'].update(additional_env)
179 p = _subprocess.Popen(
180 args, stdin=stdin, stdout=stdout, stderr=stderr, **kwargs)
182 if hasattr(input, 'encode'):
183 input = input.encode(encoding)
184 (stdout, stderr) = p.communicate(input=input)
187 'collect {args} with status {status} (expected {expect})'.format(
188 args=args, status=status, expect=expect))
189 if stdout is not None:
190 stdout = stdout.decode(encoding)
191 if stderr is not None:
192 stderr = stderr.decode(encoding)
193 if status not in expect:
194 raise SubprocessError(
195 args=args, status=status, stdout=stdout, stderr=stderr)
196 return (status, stdout, stderr)
197 if p.stdin and not stdin:
201 p.stdin = _codecs.getwriter(encoding=encoding)(stream=p.stdin)
202 stream_reader = _codecs.getreader(encoding=encoding)
204 p.stdout = stream_reader(stream=p.stdout)
206 p.stderr = stream_reader(stream=p.stderr)
207 return _SubprocessContextManager(args=args, process=p, expect=expect)
210 def _git(args, **kwargs):
211 args = ['git', '--git-dir', NOTMUCH_GIT_DIR] + list(args)
212 return _spawn(args=args, **kwargs)
215 def _get_current_branch():
216 """Get the name of the current branch.
218 Return 'None' if we're not on a branch.
221 (status, branch, stderr) = _git(
222 args=['symbolic-ref', '--short', 'HEAD'],
223 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
224 except SubprocessError as e:
225 if 'not a symbolic ref' in e:
228 return branch.strip()
232 "Get the default remote for the current branch."
233 local_branch = _get_current_branch()
234 (status, remote, stderr) = _git(
235 args=['config', 'branch.{0}.remote'.format(local_branch)],
236 stdout=_subprocess.PIPE, wait=True)
237 return remote.strip()
239 def _tag_query(prefix=None):
242 return '(tag (starts-with "{:s}"))'.format(prefix.replace('"','\\\"'))
244 def count_messages(prefix=None):
245 "count messages with a given prefix."
246 (status, stdout, stderr) = _spawn(
247 args=['notmuch', 'count', '--query=sexp', _tag_query(prefix)],
248 stdout=_subprocess.PIPE, wait=True)
250 _LOG.error("failed to run notmuch config")
252 return int(stdout.rstrip())
254 def get_tags(prefix=None):
255 "Get a list of tags with a given prefix."
256 (status, stdout, stderr) = _spawn(
257 args=['notmuch', 'search', '--query=sexp', '--output=tags', _tag_query(prefix)],
258 stdout=_subprocess.PIPE, wait=True)
259 return [tag for tag in stdout.splitlines()]
261 def archive(treeish='HEAD', args=()):
263 Dump a tar archive of the current notmuch-git tag set.
267 Each tag $tag for message with Message-Id $id is written to
270 tags/hash1(id)/hash2(id)/encode($id)/encode($tag)
272 The encoding preserves alphanumerics, and the characters
273 "+-_@=.:," (not the quotes). All other octets are replaced with
274 '%' followed by a two digit hex number.
276 _git(args=['archive', treeish] + list(args), wait=True)
279 def clone(repository):
281 Create a local notmuch-git repository from a remote source.
283 This wraps 'git clone', adding some options to avoid creating a
284 working tree while preserving remote-tracking branches and
287 with _tempfile.TemporaryDirectory(prefix='notmuch-git-clone.') as workdir:
290 'git', 'clone', '--no-checkout', '--separate-git-dir', NOTMUCH_GIT_DIR,
291 repository, workdir],
293 _git(args=['config', '--unset', 'core.worktree'], wait=True, expect=(0, 5))
294 _git(args=['config', 'core.bare', 'true'], wait=True)
295 (status, stdout, stderr) = _git(args=['show-ref', '--verify',
297 'refs/remotes/origin/config'],
301 _git(args=['branch', 'config', 'origin/config'], wait=True)
302 existing_tags = get_tags()
305 'Not checking out to avoid clobbering existing tags: {}'.format(
306 ', '.join(existing_tags)))
311 def _is_committed(status):
312 return len(status['added']) + len(status['deleted']) == 0
316 def __init__(self, repo, treeish):
317 self.cache_path = _os.path.join(repo, 'notmuch', 'index_cache.json')
318 self.index_path = _os.path.join(repo, 'index')
319 self.current_treeish = treeish
323 self.index_checksum = None
325 self._load_cache_file()
327 def _load_cache_file(self):
329 with open(self.cache_path) as f:
331 self.treeish = data['treeish']
332 self.hash = data['hash']
333 self.index_checksum = data['index_checksum']
334 except FileNotFoundError:
336 except _json.JSONDecodeError:
337 _LOG.error("Error decoding cache")
344 def __exit__(self, type, value, traceback):
345 checksum = _read_index_checksum(self.index_path)
347 args=['rev-parse', self.current_treeish],
348 stdout=_subprocess.PIPE,
351 with open(self.cache_path, "w") as f:
352 _json.dump({'treeish': self.current_treeish,
353 'hash': hash.rstrip(), 'index_checksum': checksum }, f)
357 current_checksum = _read_index_checksum(self.index_path)
359 args=['rev-parse', self.current_treeish],
360 stdout=_subprocess.PIPE,
362 current_hash = hash.rstrip()
364 if self.current_treeish == self.treeish and \
365 self.index_checksum and self.index_checksum == current_checksum and \
366 self.hash and self.hash == current_hash:
369 _git(args=['read-tree', self.current_treeish], wait=True)
372 def check_safe_fraction(status):
374 conf = _notmuch_config_get ('git.safe_fraction')
375 if conf and conf != '':
378 total = count_messages (TAG_PREFIX)
380 _LOG.error('No existing tags with given prefix, stopping.'.format(safe))
381 _LOG.error('Use --force to override.')
383 change = len(status['added'])+len(status['deleted'])
384 fraction = change/total
385 _LOG.debug('total messages {:d}, change: {:d}, fraction: {:f}'.format(total,change,fraction))
387 _LOG.error('safe fraction {:f} exceeded, stopping.'.format(safe))
388 _LOG.error('Use --force to override or reconfigure git.safe_fraction.')
391 def commit(treeish='HEAD', message=None, force=False):
393 Commit prefix-matching tags from the notmuch database to Git.
396 status = get_status()
398 if _is_committed(status=status):
399 _LOG.warning('Nothing to commit')
403 check_safe_fraction (status)
405 with CachedIndex(NOTMUCH_GIT_DIR, treeish) as index:
407 _update_index(status=status)
410 stdout=_subprocess.PIPE,
412 (_, parent, _) = _git(
413 args=['rev-parse', treeish],
414 stdout=_subprocess.PIPE,
416 (_, commit, _) = _git(
417 args=['commit-tree', tree.strip(), '-p', parent.strip()],
419 stdout=_subprocess.PIPE,
422 args=['update-ref', treeish, commit.strip()],
423 stdout=_subprocess.PIPE,
425 except Exception as e:
426 _git(args=['read-tree', '--empty'], wait=True)
427 _git(args=['read-tree', treeish], wait=True)
431 def _update_index(status):
433 args=['update-index', '--index-info'],
434 stdin=_subprocess.PIPE) as p:
435 for id, tags in status['deleted'].items():
436 for line in _index_tags_for_message(id=id, status='D', tags=tags):
438 for id, tags in status['added'].items():
439 for line in _index_tags_for_message(id=id, status='A', tags=tags):
443 def fetch(remote=None):
445 Fetch changes from the remote repository.
447 See 'merge' to bring those changes into notmuch.
452 _git(args=args, wait=True)
455 def init(remote=None,format_version=None):
457 Create an empty notmuch-git repository.
459 This wraps 'git init' with a few extra steps to support subsequent
460 status and commit commands.
462 from pathlib import Path
463 parent = Path(NOTMUCH_GIT_DIR).parent
466 except FileExistsError:
469 if not format_version:
472 format_version=int(format_version)
474 if format_version > 1 or format_version < 0:
475 _LOG.error("Illegal format version {:d}".format(format_version))
478 _spawn(args=['git', '--git-dir', NOTMUCH_GIT_DIR, 'init',
479 '--initial-branch=master', '--quiet', '--bare'], wait=True)
480 _git(args=['config', 'core.logallrefupdates', 'true'], wait=True)
481 # create an empty blob (e69de29bb2d1d6434b8b29ae775ad8c2e48c5391)
482 _git(args=['hash-object', '-w', '--stdin'], input='', wait=True)
483 allow_empty=('--allow-empty',)
484 if format_version >= 1:
486 # create a blob for the FORMAT file
487 (status, stdout, _) = _git(args=['hash-object', '-w', '--stdin'], stdout=_subprocess.PIPE,
488 input='{:d}\n'.format(format_version), wait=True)
489 verhash=stdout.rstrip()
490 _LOG.debug('hash of FORMAT blob = {:s}'.format(verhash))
491 # Add FORMAT to the index
492 _git(args=['update-index', '--add', '--cacheinfo', '100644,{:s},FORMAT'.format(verhash)], wait=True)
496 'commit', *allow_empty, '-m', 'Start a new notmuch-git repository'
498 additional_env={'GIT_WORK_TREE': NOTMUCH_GIT_DIR},
502 def checkout(force=None):
504 Update the notmuch database from Git.
506 This is mainly useful to discard your changes in notmuch relative
509 status = get_status()
512 check_safe_fraction(status)
515 args=['notmuch', 'tag', '--batch'], stdin=_subprocess.PIPE) as p:
516 for id, tags in status['added'].items():
517 p.stdin.write(_batch_line(action='-', id=id, tags=tags))
518 for id, tags in status['deleted'].items():
519 p.stdin.write(_batch_line(action='+', id=id, tags=tags))
522 def _batch_line(action, id, tags):
524 'notmuch tag --batch' line for adding/removing tags.
526 Set 'action' to '-' to remove a tag or '+' to add the tags to a
529 tag_string = ' '.join(
530 '{action}{prefix}{tag}'.format(
531 action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))
533 line = '{tags} -- id:{id}\n'.format(
534 tags=tag_string, id=_xapian_quote(string=id))
538 def _insist_committed():
539 "Die if the the notmuch tags don't match the current HEAD."
540 status = get_status()
541 if not _is_committed(status=status):
542 _LOG.error('\n'.join([
543 'Uncommitted changes to {prefix}* tags in notmuch',
545 "For a summary of changes, run 'notmuch-git status'",
546 "To save your changes, run 'notmuch-git commit' before merging/pull",
547 "To discard your changes, run 'notmuch-git checkout'",
548 ]).format(prefix=TAG_PREFIX))
552 def pull(repository=None, refspecs=None):
554 Pull (merge) remote repository changes to notmuch.
556 'pull' is equivalent to 'fetch' followed by 'merge'. We use the
557 Git-configured repository for your current branch
558 (branch.<name>.repository, likely 'origin', and
559 branch.<name>.merge, likely 'master').
562 if refspecs and not repository:
563 repository = _get_remote()
566 args.append(repository)
568 args.extend(refspecs)
569 with _tempfile.TemporaryDirectory(prefix='notmuch-git-pull.') as workdir:
575 additional_env={'GIT_WORK_TREE': workdir},
580 def merge(reference='@{upstream}'):
582 Merge changes from 'reference' into HEAD and load the result into notmuch.
584 The default reference is '@{upstream}'.
587 with _tempfile.TemporaryDirectory(prefix='notmuch-git-merge.') as workdir:
590 ['merge', reference]]:
593 additional_env={'GIT_WORK_TREE': workdir},
600 A simple wrapper for 'git log'.
602 After running 'notmuch-git fetch', you can inspect the changes with
603 'notmuch-git log HEAD..@{upstream}'.
605 # we don't want output trapping here, because we want the pager.
606 args = ['log', '--name-status', '--no-renames'] + list(args)
607 with _git(args=args, expect=(0, 1, -13)) as p:
611 def push(repository=None, refspecs=None):
612 "Push the local notmuch-git Git state to a remote repository."
613 if refspecs and not repository:
614 repository = _get_remote()
617 args.append(repository)
619 args.extend(refspecs)
620 _git(args=args, wait=True)
625 Show pending updates in notmuch or git repo.
627 Prints lines of the form
631 where n is a single character representing notmuch database status
635 Tag is present in notmuch database, but not committed to notmuch-git
636 (equivalently, tag has been deleted in notmuch-git repo, e.g. by a
637 pull, but not restored to notmuch database).
641 Tag is present in notmuch-git repo, but not restored to notmuch
642 database (equivalently, tag has been deleted in notmuch).
646 Message is unknown (missing from local notmuch database).
648 The second character (if present) represents a difference between
649 local and upstream branches. Typically 'notmuch-git fetch' needs to be
654 Tag is present in upstream, but not in the local Git branch.
658 Tag is present in local Git branch, but not upstream.
660 status = get_status()
661 # 'output' is a nested defaultdict for message status:
662 # * The outer dict is keyed by message id.
663 # * The inner dict is keyed by tag name.
664 # * The inner dict values are status strings (' a', 'Dd', ...).
665 output = _collections.defaultdict(
666 lambda : _collections.defaultdict(lambda : ' '))
667 for id, tags in status['added'].items():
669 output[id][tag] = 'A'
670 for id, tags in status['deleted'].items():
672 output[id][tag] = 'D'
673 for id, tags in status['missing'].items():
675 output[id][tag] = 'U'
677 for id, tag in _diff_refs(filter='A'):
678 output[id][tag] += 'a'
679 for id, tag in _diff_refs(filter='D'):
680 output[id][tag] += 'd'
681 for id, tag_status in sorted(output.items()):
682 for tag, status in sorted(tag_status.items()):
683 print('{status}\t{id}\t{tag}'.format(
684 status=status, id=id, tag=tag))
687 def _is_unmerged(ref='@{upstream}'):
689 (status, fetch_head, stderr) = _git(
690 args=['rev-parse', ref],
691 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
692 except SubprocessError as e:
693 if 'No upstream configured' in e.stderr:
696 (status, base, stderr) = _git(
697 args=['merge-base', 'HEAD', ref],
698 stdout=_subprocess.PIPE, wait=True)
699 return base != fetch_head
708 with PrivateIndex(repo=NOTMUCH_GIT_DIR, prefix=TAG_PREFIX) as index:
709 maybe_deleted = index.diff(filter='D')
710 for id, tags in maybe_deleted.items():
711 (_, stdout, stderr) = _spawn(
712 args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)],
713 stdout=_subprocess.PIPE,
716 status['deleted'][id] = tags
718 status['missing'][id] = tags
719 status['added'] = index.diff(filter='A')
724 def __init__(self, repo, prefix):
726 _os.makedirs(_os.path.join(repo, 'notmuch'))
727 except FileExistsError:
730 file_name = 'notmuch/index'
731 self.index_path = _os.path.join(repo, file_name)
732 self.cache_path = _os.path.join(repo, 'notmuch', '{:s}.json'.format(_hex_quote(file_name)))
734 self.current_prefix = prefix
740 self._load_cache_file()
746 def __exit__(self, type, value, traceback):
747 checksum = _read_index_checksum(self.index_path)
748 (count, uuid, lastmod) = _read_database_lastmod()
749 with open(self.cache_path, "w") as f:
750 _json.dump({'prefix': self.current_prefix, 'uuid': uuid, 'lastmod': lastmod, 'checksum': checksum }, f)
752 def _load_cache_file(self):
754 with open(self.cache_path) as f:
756 self.prefix = data['prefix']
757 self.uuid = data['uuid']
758 self.lastmod = data['lastmod']
759 self.checksum = data['checksum']
760 except FileNotFoundError:
762 except _json.JSONDecodeError:
763 _LOG.error("Error decoding cache")
767 def _index_tags(self):
768 "Write notmuch tags to private git index."
769 prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)
770 current_checksum = _read_index_checksum(self.index_path)
771 if (self.prefix == None or self.prefix != self.current_prefix
772 or self.checksum == None or self.checksum != current_checksum):
774 args=['read-tree', '--empty'],
775 additional_env={'GIT_INDEX_FILE': self.index_path}, wait=True)
779 (count,uuid,lastmod) = _read_database_lastmod()
780 if self.prefix == self.current_prefix and self.uuid \
781 and self.uuid == uuid and self.checksum == current_checksum:
782 query = '(and (infix "lastmod:{:d}..")) {:s})'.format(self.lastmod+1, query)
785 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', query],
786 stdout=_subprocess.PIPE) as notmuch:
788 args=['update-index', '--index-info'],
789 stdin=_subprocess.PIPE,
790 additional_env={'GIT_INDEX_FILE': self.index_path}) as git:
791 for line in notmuch.stdout:
792 if line.strip().startswith('#'):
794 (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
796 _unquote(tag[len(prefix):])
797 for tag in tags_string.split()
798 if tag.startswith(prefix)]
799 id = _xapian_unquote(string=id)
801 for line in _clear_tags_for_message(index=self.index_path, id=id):
802 git.stdin.write(line)
803 for line in _index_tags_for_message(
804 id=id, status='A', tags=tags):
805 git.stdin.write(line)
808 def diff(self, filter):
810 Get an {id: {tag, ...}} dict for a given filter.
812 For example, use 'A' to find added tags, and 'D' to find deleted tags.
814 s = _collections.defaultdict(set)
817 'diff-index', '--cached', '--diff-filter', filter,
818 '--name-only', 'HEAD'],
819 additional_env={'GIT_INDEX_FILE': self.index_path},
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 _read_index_checksum (index_path):
827 """Read the index checksum, as defined by index-format.txt in the git source
828 WARNING: assumes SHA1 repo"""
831 with open(index_path, 'rb') as f:
832 size=_os.path.getsize(index_path)
834 return binascii.hexlify(f.read(20)).decode('ascii')
835 except FileNotFoundError:
839 def _clear_tags_for_message(index, id):
841 Clear any existing index entries for message 'id'
843 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
849 args=['ls-files', dir],
850 additional_env={'GIT_INDEX_FILE': index},
851 stdout=_subprocess.PIPE) as git:
852 for file in git.stdout:
853 line = '0 0000000000000000000000000000000000000000\t{:s}\n'.format(file.strip())
856 def _read_database_lastmod():
858 args=['notmuch', 'count', '--lastmod', '*'],
859 stdout=_subprocess.PIPE) as notmuch:
860 (count,uuid,lastmod_str) = notmuch.stdout.readline().split()
861 return (count,uuid,int(lastmod_str))
864 hid=_hex_quote(string=id)
865 from hashlib import blake2b
867 if FORMAT_VERSION==0:
868 return 'tags/{hid}'.format(hid=hid)
869 elif FORMAT_VERSION==1:
870 idhash = blake2b(hid.encode('utf8'), digest_size=2).hexdigest()
871 return 'tags/{dir1}/{dir2}/{hid}'.format(
873 dir1=idhash[0:2],dir2=idhash[2:])
875 _LOG.error("Unknown format version",FORMAT_VERSION)
878 def _index_tags_for_message(id, status, tags):
880 Update the Git index to either create or delete an empty file.
882 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
889 hash = '0000000000000000000000000000000000000000'
892 path = '{ipath}/{tag}'.format(ipath=_id_path(id),tag=_hex_quote(string=tag))
893 yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)
896 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
898 args=['diff', '--diff-filter', filter, '--name-only', a, b],
899 stdout=_subprocess.PIPE) as p:
900 # Once we drop Python < 3.3, we can use 'yield from' here
901 for id, tag in _unpack_diff_lines(stream=p.stdout):
905 def _unpack_diff_lines(stream):
906 "Iterate through (id, tag) tuples in a diff stream."
908 match = _TAG_FILE_REGEX[FORMAT_VERSION].match(line.strip())
910 message = 'non-tag line in diff: {!r}'.format(line.strip())
911 if line.startswith(_TAG_DIRECTORY):
912 raise ValueError(message)
915 id = _unquote(match.group('id'))
916 tag = _unquote(match.group('tag'))
920 def _help(parser, command=None):
922 Show help for an notmuch-git command.
924 Because some folks prefer:
926 $ notmuch-git help COMMAND
930 $ notmuch-git COMMAND --help
933 parser.parse_args([command, '--help'])
935 parser.parse_args(['--help'])
937 def _notmuch_config_get(key):
938 (status, stdout, stderr) = _spawn(
939 args=['notmuch', 'config', 'get', key],
940 stdout=_subprocess.PIPE, wait=True)
942 _LOG.error("failed to run notmuch config")
944 return stdout.rstrip()
946 def read_format_version():
948 (status, stdout, stderr) = _git(
949 args=['cat-file', 'blob', 'master:FORMAT'],
950 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
951 except SubprocessError as e:
952 _LOG.debug("failed to read FORMAT file from git, assuming format version 0")
957 # based on BaseDirectory.save_data_path from pyxdg (LGPL2+)
958 def xdg_data_path(profile):
959 resource = _os.path.join('notmuch',profile,'git')
960 assert not resource.startswith('/')
961 _home = _os.path.expanduser('~')
962 xdg_data_home = _os.environ.get('XDG_DATA_HOME') or \
963 _os.path.join(_home, '.local', 'share')
964 path = _os.path.join(xdg_data_home, resource)
967 if __name__ == '__main__':
970 parser = argparse.ArgumentParser(
971 description=__doc__.strip(),
972 formatter_class=argparse.RawDescriptionHelpFormatter)
974 '-C', '--git-dir', metavar='REPO',
975 help='Git repository to operate on.')
977 '-p', '--tag-prefix', metavar='PREFIX',
979 help='Prefix of tags to operate on.')
981 '-N', '--nmbug', action='store_true',
982 help='Set defaults for --tag-prefix and --git-dir for the notmuch bug tracker')
985 choices=['critical', 'error', 'warning', 'info', 'debug'],
986 help='Log verbosity. Defaults to {!r}.'.format(
987 _logging.getLevelName(_LOG.level).lower()))
989 help = _functools.partial(_help, parser=parser)
990 help.__doc__ = _help.__doc__
991 subparsers = parser.add_subparsers(
994 'For help on a particular command, run: '
995 "'%(prog)s ... <command> --help'."))
1010 func = locals()[command]
1011 doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
1012 subparser = subparsers.add_parser(
1014 help=doc.splitlines()[0],
1016 formatter_class=argparse.RawDescriptionHelpFormatter)
1017 subparser.set_defaults(func=func)
1018 if command == 'archive':
1019 subparser.add_argument(
1020 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
1022 'The tree or commit to produce an archive for. Defaults '
1024 subparser.add_argument(
1025 'args', metavar='ARG', nargs='*',
1027 "Argument passed through to 'git archive'. Set anything "
1028 'before <tree-ish>, see git-archive(1) for details.'))
1029 elif command == 'checkout':
1030 subparser.add_argument(
1031 '-f', '--force', action='store_true',
1032 help='checkout a large fraction of tags.')
1033 elif command == 'clone':
1034 subparser.add_argument(
1037 'The (possibly remote) repository to clone from. See the '
1038 'URLS section of git-clone(1) for more information on '
1039 'specifying repositories.'))
1040 elif command == 'commit':
1041 subparser.add_argument(
1042 '-f', '--force', action='store_true',
1043 help='commit a large fraction of tags.')
1044 subparser.add_argument(
1045 'message', metavar='MESSAGE', default='', nargs='?',
1046 help='Text for the commit message.')
1047 elif command == 'fetch':
1048 subparser.add_argument(
1049 'remote', metavar='REMOTE', nargs='?',
1051 'Override the default configured in branch.<name>.remote '
1052 'to fetch from a particular remote repository (e.g. '
1054 elif command == 'help':
1055 subparser.add_argument(
1056 'command', metavar='COMMAND', nargs='?',
1057 help='The command to show help for.')
1058 elif command == 'init':
1059 subparser.add_argument(
1060 '--format-version', metavar='VERSION',
1062 help='create format VERSION repository.')
1063 elif command == 'log':
1064 subparser.add_argument(
1065 'args', metavar='ARG', nargs='*',
1066 help="Additional argument passed through to 'git log'.")
1067 elif command == 'merge':
1068 subparser.add_argument(
1069 'reference', metavar='REFERENCE', default='@{upstream}',
1072 'Reference, usually other branch heads, to merge into '
1073 "our branch. Defaults to '@{upstream}'."))
1074 elif command == 'pull':
1075 subparser.add_argument(
1076 'repository', metavar='REPOSITORY', default=None, nargs='?',
1078 'The "remote" repository that is the source of the pull. '
1079 'This parameter can be either a URL (see the section GIT '
1080 'URLS in git-pull(1)) or the name of a remote (see the '
1081 'section REMOTES in git-pull(1)).'))
1082 subparser.add_argument(
1083 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1085 'Refspec (usually a branch name) to fetch and merge. See '
1086 'the <refspec> entry in the OPTIONS section of '
1087 'git-pull(1) for other possibilities.'))
1088 elif command == 'push':
1089 subparser.add_argument(
1090 'repository', metavar='REPOSITORY', default=None, nargs='?',
1092 'The "remote" repository that is the destination of the '
1093 'push. This parameter can be either a URL (see the '
1094 'section GIT URLS in git-push(1)) or the name of a remote '
1095 '(see the section REMOTES in git-push(1)).'))
1096 subparser.add_argument(
1097 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1099 'Refspec (usually a branch name) to push. See '
1100 'the <refspec> entry in the OPTIONS section of '
1101 'git-push(1) for other possibilities.'))
1103 args = parser.parse_args()
1106 notmuch_profile = _os.getenv('NOTMUCH_PROFILE','default')
1108 if args.nmbug or _os.path.basename(__file__) == 'nmbug':
1112 NOTMUCH_GIT_DIR = args.git_dir
1115 default = _os.path.join('~', '.nmbug')
1117 default = _notmuch_config_get ('git.path')
1119 default = xdg_data_path(notmuch_profile)
1121 NOTMUCH_GIT_DIR = _os.path.expanduser(_os.getenv('NOTMUCH_GIT_DIR', default))
1123 _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
1124 if _os.path.isdir(_NOTMUCH_GIT_DIR):
1125 NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
1128 TAG_PREFIX = args.tag_prefix
1131 prefix = 'notmuch::'
1133 prefix = _notmuch_config_get ('git.tag_prefix')
1135 TAG_PREFIX = _os.getenv('NOTMUCH_GIT_PREFIX', prefix)
1137 _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,') # quote ':'
1140 level = getattr(_logging, args.log_level.upper())
1141 _LOG.setLevel(level)
1144 for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
1145 _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
1147 if _notmuch_config_get('built_with.sexp_queries') != 'true':
1148 _LOG.error("notmuch git needs sexp query support")
1151 if not getattr(args, 'func', None):
1152 parser.print_usage()
1155 # The following two lines are used by the test suite.
1156 _LOG.debug('prefix = {:s}'.format(TAG_PREFIX))
1157 _LOG.debug('repository = {:s}'.format(NOTMUCH_GIT_DIR))
1159 if args.func != init:
1160 FORMAT_VERSION = read_format_version()
1162 _LOG.debug('FORMAT_VERSION={:d}'.format(FORMAT_VERSION))
1164 if args.func == help:
1165 arg_names = ['command']
1167 (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
1168 kwargs = {key: getattr(args, key) for key in arg_names if key in args}
1171 except SubprocessError as e:
1172 if _LOG.level == _logging.DEBUG:
1173 raise # don't mask the traceback