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()
741 self.file_tree = None
747 def __exit__(self, type, value, traceback):
748 checksum = _read_index_checksum(self.index_path)
749 (count, uuid, lastmod) = _read_database_lastmod()
750 with open(self.cache_path, "w") as f:
751 _json.dump({'prefix': self.current_prefix, 'uuid': uuid, 'lastmod': lastmod, 'checksum': checksum }, f)
753 def _load_cache_file(self):
755 with open(self.cache_path) as f:
757 self.prefix = data['prefix']
758 self.uuid = data['uuid']
759 self.lastmod = data['lastmod']
760 self.checksum = data['checksum']
761 except FileNotFoundError:
763 except _json.JSONDecodeError:
764 _LOG.error("Error decoding cache")
768 def _read_file_tree(self):
772 args=['ls-files', 'tags'],
773 additional_env={'GIT_INDEX_FILE': self.index_path},
774 stdout=_subprocess.PIPE) as git:
775 for file in git.stdout:
776 dir=_os.path.dirname(file)
777 tag=_os.path.basename(file).rstrip()
778 if dir not in self.file_tree:
779 self.file_tree[dir]=[tag]
781 self.file_tree[dir].append(tag)
784 def _clear_tags_for_message(self, id):
786 Clear any existing index entries for message 'id'
788 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
791 if self.file_tree == None:
792 self._read_file_tree()
796 if dir not in self.file_tree:
799 for file in self.file_tree[dir]:
800 line = '0 0000000000000000000000000000000000000000\t{:s}/{:s}\n'.format(dir,file)
805 def _index_tags(self):
806 "Write notmuch tags to private git index."
807 prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)
808 current_checksum = _read_index_checksum(self.index_path)
809 if (self.prefix == None or self.prefix != self.current_prefix
810 or self.checksum == None or self.checksum != current_checksum):
812 args=['read-tree', '--empty'],
813 additional_env={'GIT_INDEX_FILE': self.index_path}, wait=True)
817 (count,uuid,lastmod) = _read_database_lastmod()
818 if self.prefix == self.current_prefix and self.uuid \
819 and self.uuid == uuid and self.checksum == current_checksum:
820 query = '(and (infix "lastmod:{:d}..")) {:s})'.format(self.lastmod+1, query)
823 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', query],
824 stdout=_subprocess.PIPE) as notmuch:
826 args=['update-index', '--index-info'],
827 stdin=_subprocess.PIPE,
828 additional_env={'GIT_INDEX_FILE': self.index_path}) as git:
829 for line in notmuch.stdout:
830 if line.strip().startswith('#'):
832 (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
834 _unquote(tag[len(prefix):])
835 for tag in tags_string.split()
836 if tag.startswith(prefix)]
837 id = _xapian_unquote(string=id)
839 for line in self._clear_tags_for_message(id=id):
840 git.stdin.write(line)
841 for line in _index_tags_for_message(
842 id=id, status='A', tags=tags):
843 git.stdin.write(line)
846 def diff(self, filter):
848 Get an {id: {tag, ...}} dict for a given filter.
850 For example, use 'A' to find added tags, and 'D' to find deleted tags.
852 s = _collections.defaultdict(set)
855 'diff-index', '--cached', '--diff-filter', filter,
856 '--name-only', 'HEAD'],
857 additional_env={'GIT_INDEX_FILE': self.index_path},
858 stdout=_subprocess.PIPE) as p:
859 # Once we drop Python < 3.3, we can use 'yield from' here
860 for id, tag in _unpack_diff_lines(stream=p.stdout):
864 def _read_index_checksum (index_path):
865 """Read the index checksum, as defined by index-format.txt in the git source
866 WARNING: assumes SHA1 repo"""
869 with open(index_path, 'rb') as f:
870 size=_os.path.getsize(index_path)
872 return binascii.hexlify(f.read(20)).decode('ascii')
873 except FileNotFoundError:
876 def _read_database_lastmod():
878 args=['notmuch', 'count', '--lastmod', '*'],
879 stdout=_subprocess.PIPE) as notmuch:
880 (count,uuid,lastmod_str) = notmuch.stdout.readline().split()
881 return (count,uuid,int(lastmod_str))
884 hid=_hex_quote(string=id)
885 from hashlib import blake2b
887 if FORMAT_VERSION==0:
888 return 'tags/{hid}'.format(hid=hid)
889 elif FORMAT_VERSION==1:
890 idhash = blake2b(hid.encode('utf8'), digest_size=2).hexdigest()
891 return 'tags/{dir1}/{dir2}/{hid}'.format(
893 dir1=idhash[0:2],dir2=idhash[2:])
895 _LOG.error("Unknown format version",FORMAT_VERSION)
898 def _index_tags_for_message(id, status, tags):
900 Update the Git index to either create or delete an empty file.
902 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
909 hash = '0000000000000000000000000000000000000000'
912 path = '{ipath}/{tag}'.format(ipath=_id_path(id),tag=_hex_quote(string=tag))
913 yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)
916 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
918 args=['diff', '--diff-filter', filter, '--name-only', a, b],
919 stdout=_subprocess.PIPE) as p:
920 # Once we drop Python < 3.3, we can use 'yield from' here
921 for id, tag in _unpack_diff_lines(stream=p.stdout):
925 def _unpack_diff_lines(stream):
926 "Iterate through (id, tag) tuples in a diff stream."
928 match = _TAG_FILE_REGEX[FORMAT_VERSION].match(line.strip())
930 message = 'non-tag line in diff: {!r}'.format(line.strip())
931 if line.startswith(_TAG_DIRECTORY):
932 raise ValueError(message)
935 id = _unquote(match.group('id'))
936 tag = _unquote(match.group('tag'))
940 def _help(parser, command=None):
942 Show help for an notmuch-git command.
944 Because some folks prefer:
946 $ notmuch-git help COMMAND
950 $ notmuch-git COMMAND --help
953 parser.parse_args([command, '--help'])
955 parser.parse_args(['--help'])
957 def _notmuch_config_get(key):
958 (status, stdout, stderr) = _spawn(
959 args=['notmuch', 'config', 'get', key],
960 stdout=_subprocess.PIPE, wait=True)
962 _LOG.error("failed to run notmuch config")
964 return stdout.rstrip()
966 def read_format_version():
968 (status, stdout, stderr) = _git(
969 args=['cat-file', 'blob', 'master:FORMAT'],
970 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
971 except SubprocessError as e:
972 _LOG.debug("failed to read FORMAT file from git, assuming format version 0")
977 # based on BaseDirectory.save_data_path from pyxdg (LGPL2+)
978 def xdg_data_path(profile):
979 resource = _os.path.join('notmuch',profile,'git')
980 assert not resource.startswith('/')
981 _home = _os.path.expanduser('~')
982 xdg_data_home = _os.environ.get('XDG_DATA_HOME') or \
983 _os.path.join(_home, '.local', 'share')
984 path = _os.path.join(xdg_data_home, resource)
987 if __name__ == '__main__':
990 parser = argparse.ArgumentParser(
991 description=__doc__.strip(),
992 formatter_class=argparse.RawDescriptionHelpFormatter)
994 '-C', '--git-dir', metavar='REPO',
995 help='Git repository to operate on.')
997 '-p', '--tag-prefix', metavar='PREFIX',
999 help='Prefix of tags to operate on.')
1000 parser.add_argument(
1001 '-N', '--nmbug', action='store_true',
1002 help='Set defaults for --tag-prefix and --git-dir for the notmuch bug tracker')
1003 parser.add_argument(
1004 '-l', '--log-level',
1005 choices=['critical', 'error', 'warning', 'info', 'debug'],
1006 help='Log verbosity. Defaults to {!r}.'.format(
1007 _logging.getLevelName(_LOG.level).lower()))
1009 help = _functools.partial(_help, parser=parser)
1010 help.__doc__ = _help.__doc__
1011 subparsers = parser.add_subparsers(
1014 'For help on a particular command, run: '
1015 "'%(prog)s ... <command> --help'."))
1030 func = locals()[command]
1031 doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
1032 subparser = subparsers.add_parser(
1034 help=doc.splitlines()[0],
1036 formatter_class=argparse.RawDescriptionHelpFormatter)
1037 subparser.set_defaults(func=func)
1038 if command == 'archive':
1039 subparser.add_argument(
1040 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
1042 'The tree or commit to produce an archive for. Defaults '
1044 subparser.add_argument(
1045 'args', metavar='ARG', nargs='*',
1047 "Argument passed through to 'git archive'. Set anything "
1048 'before <tree-ish>, see git-archive(1) for details.'))
1049 elif command == 'checkout':
1050 subparser.add_argument(
1051 '-f', '--force', action='store_true',
1052 help='checkout a large fraction of tags.')
1053 elif command == 'clone':
1054 subparser.add_argument(
1057 'The (possibly remote) repository to clone from. See the '
1058 'URLS section of git-clone(1) for more information on '
1059 'specifying repositories.'))
1060 elif command == 'commit':
1061 subparser.add_argument(
1062 '-f', '--force', action='store_true',
1063 help='commit a large fraction of tags.')
1064 subparser.add_argument(
1065 'message', metavar='MESSAGE', default='', nargs='?',
1066 help='Text for the commit message.')
1067 elif command == 'fetch':
1068 subparser.add_argument(
1069 'remote', metavar='REMOTE', nargs='?',
1071 'Override the default configured in branch.<name>.remote '
1072 'to fetch from a particular remote repository (e.g. '
1074 elif command == 'help':
1075 subparser.add_argument(
1076 'command', metavar='COMMAND', nargs='?',
1077 help='The command to show help for.')
1078 elif command == 'init':
1079 subparser.add_argument(
1080 '--format-version', metavar='VERSION',
1082 help='create format VERSION repository.')
1083 elif command == 'log':
1084 subparser.add_argument(
1085 'args', metavar='ARG', nargs='*',
1086 help="Additional argument passed through to 'git log'.")
1087 elif command == 'merge':
1088 subparser.add_argument(
1089 'reference', metavar='REFERENCE', default='@{upstream}',
1092 'Reference, usually other branch heads, to merge into '
1093 "our branch. Defaults to '@{upstream}'."))
1094 elif command == 'pull':
1095 subparser.add_argument(
1096 'repository', metavar='REPOSITORY', default=None, nargs='?',
1098 'The "remote" repository that is the source of the pull. '
1099 'This parameter can be either a URL (see the section GIT '
1100 'URLS in git-pull(1)) or the name of a remote (see the '
1101 'section REMOTES in git-pull(1)).'))
1102 subparser.add_argument(
1103 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1105 'Refspec (usually a branch name) to fetch and merge. See '
1106 'the <refspec> entry in the OPTIONS section of '
1107 'git-pull(1) for other possibilities.'))
1108 elif command == 'push':
1109 subparser.add_argument(
1110 'repository', metavar='REPOSITORY', default=None, nargs='?',
1112 'The "remote" repository that is the destination of the '
1113 'push. This parameter can be either a URL (see the '
1114 'section GIT URLS in git-push(1)) or the name of a remote '
1115 '(see the section REMOTES in git-push(1)).'))
1116 subparser.add_argument(
1117 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1119 'Refspec (usually a branch name) to push. See '
1120 'the <refspec> entry in the OPTIONS section of '
1121 'git-push(1) for other possibilities.'))
1123 args = parser.parse_args()
1126 notmuch_profile = _os.getenv('NOTMUCH_PROFILE','default')
1128 if args.nmbug or _os.path.basename(__file__) == 'nmbug':
1132 NOTMUCH_GIT_DIR = args.git_dir
1135 default = _os.path.join('~', '.nmbug')
1137 default = _notmuch_config_get ('git.path')
1139 default = xdg_data_path(notmuch_profile)
1141 NOTMUCH_GIT_DIR = _os.path.expanduser(_os.getenv('NOTMUCH_GIT_DIR', default))
1143 _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
1144 if _os.path.isdir(_NOTMUCH_GIT_DIR):
1145 NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
1148 TAG_PREFIX = args.tag_prefix
1151 prefix = 'notmuch::'
1153 prefix = _notmuch_config_get ('git.tag_prefix')
1155 TAG_PREFIX = _os.getenv('NOTMUCH_GIT_PREFIX', prefix)
1157 _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,') # quote ':'
1160 level = getattr(_logging, args.log_level.upper())
1161 _LOG.setLevel(level)
1164 for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
1165 _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
1167 if _notmuch_config_get('built_with.sexp_queries') != 'true':
1168 _LOG.error("notmuch git needs sexp query support")
1171 if not getattr(args, 'func', None):
1172 parser.print_usage()
1175 # The following two lines are used by the test suite.
1176 _LOG.debug('prefix = {:s}'.format(TAG_PREFIX))
1177 _LOG.debug('repository = {:s}'.format(NOTMUCH_GIT_DIR))
1179 if args.func != init:
1180 FORMAT_VERSION = read_format_version()
1182 _LOG.debug('FORMAT_VERSION={:d}'.format(FORMAT_VERSION))
1184 if args.func == help:
1185 arg_names = ['command']
1187 (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
1188 kwargs = {key: getattr(args, key) for key in arg_names if key in args}
1191 except SubprocessError as e:
1192 if _LOG.level == _logging.DEBUG:
1193 raise # don't mask the traceback