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
704 from notmuch2 import Database
705 self._notmuch = Database()
711 if id in self._known:
712 return self._known[id];
716 _ = self._notmuch.find(id)
717 self._known[id] = True
719 self._known[id] = False
721 (_, stdout, stderr) = _spawn(
722 args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)],
723 stdout=_subprocess.PIPE,
725 self._known[id] = stdout != None
726 return self._known[id]
735 with PrivateIndex(repo=NOTMUCH_GIT_DIR, prefix=TAG_PREFIX) as index:
736 maybe_deleted = index.diff(filter='D')
737 for id, tags in maybe_deleted.items():
739 status['deleted'][id] = tags
741 status['missing'][id] = tags
742 status['added'] = index.diff(filter='A')
747 def __init__(self, repo, prefix):
749 _os.makedirs(_os.path.join(repo, 'notmuch'))
750 except FileExistsError:
753 file_name = 'notmuch/index'
754 self.index_path = _os.path.join(repo, file_name)
755 self.cache_path = _os.path.join(repo, 'notmuch', '{:s}.json'.format(_hex_quote(file_name)))
757 self.current_prefix = prefix
763 self._load_cache_file()
764 self.file_tree = None
770 def __exit__(self, type, value, traceback):
771 checksum = _read_index_checksum(self.index_path)
772 (count, uuid, lastmod) = _read_database_lastmod()
773 with open(self.cache_path, "w") as f:
774 _json.dump({'prefix': self.current_prefix, 'uuid': uuid, 'lastmod': lastmod, 'checksum': checksum }, f)
776 def _load_cache_file(self):
778 with open(self.cache_path) as f:
780 self.prefix = data['prefix']
781 self.uuid = data['uuid']
782 self.lastmod = data['lastmod']
783 self.checksum = data['checksum']
784 except FileNotFoundError:
786 except _json.JSONDecodeError:
787 _LOG.error("Error decoding cache")
791 def _read_file_tree(self):
795 args=['ls-files', 'tags'],
796 additional_env={'GIT_INDEX_FILE': self.index_path},
797 stdout=_subprocess.PIPE) as git:
798 for file in git.stdout:
799 dir=_os.path.dirname(file)
800 tag=_os.path.basename(file).rstrip()
801 if dir not in self.file_tree:
802 self.file_tree[dir]=[tag]
804 self.file_tree[dir].append(tag)
807 def _clear_tags_for_message(self, id):
809 Clear any existing index entries for message 'id'
811 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
814 if self.file_tree == None:
815 self._read_file_tree()
819 if dir not in self.file_tree:
822 for file in self.file_tree[dir]:
823 line = '0 0000000000000000000000000000000000000000\t{:s}/{:s}\n'.format(dir,file)
828 def _index_tags(self):
829 "Write notmuch tags to private git index."
830 prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)
831 current_checksum = _read_index_checksum(self.index_path)
832 if (self.prefix == None or self.prefix != self.current_prefix
833 or self.checksum == None or self.checksum != current_checksum):
835 args=['read-tree', '--empty'],
836 additional_env={'GIT_INDEX_FILE': self.index_path}, wait=True)
840 (count,uuid,lastmod) = _read_database_lastmod()
841 if self.prefix == self.current_prefix and self.uuid \
842 and self.uuid == uuid and self.checksum == current_checksum:
843 query = '(and (infix "lastmod:{:d}..")) {:s})'.format(self.lastmod+1, query)
846 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', query],
847 stdout=_subprocess.PIPE) as notmuch:
849 args=['update-index', '--index-info'],
850 stdin=_subprocess.PIPE,
851 additional_env={'GIT_INDEX_FILE': self.index_path}) as git:
852 for line in notmuch.stdout:
853 if line.strip().startswith('#'):
855 (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
857 _unquote(tag[len(prefix):])
858 for tag in tags_string.split()
859 if tag.startswith(prefix)]
860 id = _xapian_unquote(string=id)
862 for line in self._clear_tags_for_message(id=id):
863 git.stdin.write(line)
864 for line in _index_tags_for_message(
865 id=id, status='A', tags=tags):
866 git.stdin.write(line)
869 def diff(self, filter):
871 Get an {id: {tag, ...}} dict for a given filter.
873 For example, use 'A' to find added tags, and 'D' to find deleted tags.
875 s = _collections.defaultdict(set)
878 'diff-index', '--cached', '--diff-filter', filter,
879 '--name-only', 'HEAD'],
880 additional_env={'GIT_INDEX_FILE': self.index_path},
881 stdout=_subprocess.PIPE) as p:
882 # Once we drop Python < 3.3, we can use 'yield from' here
883 for id, tag in _unpack_diff_lines(stream=p.stdout):
887 def _read_index_checksum (index_path):
888 """Read the index checksum, as defined by index-format.txt in the git source
889 WARNING: assumes SHA1 repo"""
892 with open(index_path, 'rb') as f:
893 size=_os.path.getsize(index_path)
895 return binascii.hexlify(f.read(20)).decode('ascii')
896 except FileNotFoundError:
899 def _read_database_lastmod():
901 args=['notmuch', 'count', '--lastmod', '*'],
902 stdout=_subprocess.PIPE) as notmuch:
903 (count,uuid,lastmod_str) = notmuch.stdout.readline().split()
904 return (count,uuid,int(lastmod_str))
907 hid=_hex_quote(string=id)
908 from hashlib import blake2b
910 if FORMAT_VERSION==0:
911 return 'tags/{hid}'.format(hid=hid)
912 elif FORMAT_VERSION==1:
913 idhash = blake2b(hid.encode('utf8'), digest_size=2).hexdigest()
914 return 'tags/{dir1}/{dir2}/{hid}'.format(
916 dir1=idhash[0:2],dir2=idhash[2:])
918 _LOG.error("Unknown format version",FORMAT_VERSION)
921 def _index_tags_for_message(id, status, tags):
923 Update the Git index to either create or delete an empty file.
925 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
932 hash = '0000000000000000000000000000000000000000'
935 path = '{ipath}/{tag}'.format(ipath=_id_path(id),tag=_hex_quote(string=tag))
936 yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)
939 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
941 args=['diff', '--diff-filter', filter, '--name-only', a, b],
942 stdout=_subprocess.PIPE) as p:
943 # Once we drop Python < 3.3, we can use 'yield from' here
944 for id, tag in _unpack_diff_lines(stream=p.stdout):
948 def _unpack_diff_lines(stream):
949 "Iterate through (id, tag) tuples in a diff stream."
951 match = _TAG_FILE_REGEX[FORMAT_VERSION].match(line.strip())
953 message = 'non-tag line in diff: {!r}'.format(line.strip())
954 if line.startswith(_TAG_DIRECTORY):
955 raise ValueError(message)
958 id = _unquote(match.group('id'))
959 tag = _unquote(match.group('tag'))
963 def _help(parser, command=None):
965 Show help for an notmuch-git command.
967 Because some folks prefer:
969 $ notmuch-git help COMMAND
973 $ notmuch-git COMMAND --help
976 parser.parse_args([command, '--help'])
978 parser.parse_args(['--help'])
980 def _notmuch_config_get(key):
981 (status, stdout, stderr) = _spawn(
982 args=['notmuch', 'config', 'get', key],
983 stdout=_subprocess.PIPE, wait=True)
985 _LOG.error("failed to run notmuch config")
987 return stdout.rstrip()
989 def read_format_version():
991 (status, stdout, stderr) = _git(
992 args=['cat-file', 'blob', 'master:FORMAT'],
993 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
994 except SubprocessError as e:
995 _LOG.debug("failed to read FORMAT file from git, assuming format version 0")
1000 # based on BaseDirectory.save_data_path from pyxdg (LGPL2+)
1001 def xdg_data_path(profile):
1002 resource = _os.path.join('notmuch',profile,'git')
1003 assert not resource.startswith('/')
1004 _home = _os.path.expanduser('~')
1005 xdg_data_home = _os.environ.get('XDG_DATA_HOME') or \
1006 _os.path.join(_home, '.local', 'share')
1007 path = _os.path.join(xdg_data_home, resource)
1010 if __name__ == '__main__':
1013 parser = argparse.ArgumentParser(
1014 description=__doc__.strip(),
1015 formatter_class=argparse.RawDescriptionHelpFormatter)
1016 parser.add_argument(
1017 '-C', '--git-dir', metavar='REPO',
1018 help='Git repository to operate on.')
1019 parser.add_argument(
1020 '-p', '--tag-prefix', metavar='PREFIX',
1022 help='Prefix of tags to operate on.')
1023 parser.add_argument(
1024 '-N', '--nmbug', action='store_true',
1025 help='Set defaults for --tag-prefix and --git-dir for the notmuch bug tracker')
1026 parser.add_argument(
1027 '-l', '--log-level',
1028 choices=['critical', 'error', 'warning', 'info', 'debug'],
1029 help='Log verbosity. Defaults to {!r}.'.format(
1030 _logging.getLevelName(_LOG.level).lower()))
1032 help = _functools.partial(_help, parser=parser)
1033 help.__doc__ = _help.__doc__
1034 subparsers = parser.add_subparsers(
1037 'For help on a particular command, run: '
1038 "'%(prog)s ... <command> --help'."))
1053 func = locals()[command]
1054 doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
1055 subparser = subparsers.add_parser(
1057 help=doc.splitlines()[0],
1059 formatter_class=argparse.RawDescriptionHelpFormatter)
1060 subparser.set_defaults(func=func)
1061 if command == 'archive':
1062 subparser.add_argument(
1063 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
1065 'The tree or commit to produce an archive for. Defaults '
1067 subparser.add_argument(
1068 'args', metavar='ARG', nargs='*',
1070 "Argument passed through to 'git archive'. Set anything "
1071 'before <tree-ish>, see git-archive(1) for details.'))
1072 elif command == 'checkout':
1073 subparser.add_argument(
1074 '-f', '--force', action='store_true',
1075 help='checkout a large fraction of tags.')
1076 elif command == 'clone':
1077 subparser.add_argument(
1080 'The (possibly remote) repository to clone from. See the '
1081 'URLS section of git-clone(1) for more information on '
1082 'specifying repositories.'))
1083 elif command == 'commit':
1084 subparser.add_argument(
1085 '-f', '--force', action='store_true',
1086 help='commit a large fraction of tags.')
1087 subparser.add_argument(
1088 'message', metavar='MESSAGE', default='', nargs='?',
1089 help='Text for the commit message.')
1090 elif command == 'fetch':
1091 subparser.add_argument(
1092 'remote', metavar='REMOTE', nargs='?',
1094 'Override the default configured in branch.<name>.remote '
1095 'to fetch from a particular remote repository (e.g. '
1097 elif command == 'help':
1098 subparser.add_argument(
1099 'command', metavar='COMMAND', nargs='?',
1100 help='The command to show help for.')
1101 elif command == 'init':
1102 subparser.add_argument(
1103 '--format-version', metavar='VERSION',
1105 help='create format VERSION repository.')
1106 elif command == 'log':
1107 subparser.add_argument(
1108 'args', metavar='ARG', nargs='*',
1109 help="Additional argument passed through to 'git log'.")
1110 elif command == 'merge':
1111 subparser.add_argument(
1112 'reference', metavar='REFERENCE', default='@{upstream}',
1115 'Reference, usually other branch heads, to merge into '
1116 "our branch. Defaults to '@{upstream}'."))
1117 elif command == 'pull':
1118 subparser.add_argument(
1119 'repository', metavar='REPOSITORY', default=None, nargs='?',
1121 'The "remote" repository that is the source of the pull. '
1122 'This parameter can be either a URL (see the section GIT '
1123 'URLS in git-pull(1)) or the name of a remote (see the '
1124 'section REMOTES in git-pull(1)).'))
1125 subparser.add_argument(
1126 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1128 'Refspec (usually a branch name) to fetch and merge. See '
1129 'the <refspec> entry in the OPTIONS section of '
1130 'git-pull(1) for other possibilities.'))
1131 elif command == 'push':
1132 subparser.add_argument(
1133 'repository', metavar='REPOSITORY', default=None, nargs='?',
1135 'The "remote" repository that is the destination of the '
1136 'push. This parameter can be either a URL (see the '
1137 'section GIT URLS in git-push(1)) or the name of a remote '
1138 '(see the section REMOTES in git-push(1)).'))
1139 subparser.add_argument(
1140 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1142 'Refspec (usually a branch name) to push. See '
1143 'the <refspec> entry in the OPTIONS section of '
1144 'git-push(1) for other possibilities.'))
1146 args = parser.parse_args()
1149 notmuch_profile = _os.getenv('NOTMUCH_PROFILE','default')
1151 if args.nmbug or _os.path.basename(__file__) == 'nmbug':
1155 NOTMUCH_GIT_DIR = args.git_dir
1158 default = _os.path.join('~', '.nmbug')
1160 default = _notmuch_config_get ('git.path')
1162 default = xdg_data_path(notmuch_profile)
1164 NOTMUCH_GIT_DIR = _os.path.expanduser(_os.getenv('NOTMUCH_GIT_DIR', default))
1166 _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
1167 if _os.path.isdir(_NOTMUCH_GIT_DIR):
1168 NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
1171 TAG_PREFIX = args.tag_prefix
1174 prefix = 'notmuch::'
1176 prefix = _notmuch_config_get ('git.tag_prefix')
1178 TAG_PREFIX = _os.getenv('NOTMUCH_GIT_PREFIX', prefix)
1180 _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,') # quote ':'
1183 level = getattr(_logging, args.log_level.upper())
1184 _LOG.setLevel(level)
1187 for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
1188 _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
1190 if _notmuch_config_get('built_with.sexp_queries') != 'true':
1191 _LOG.error("notmuch git needs sexp query support")
1194 if not getattr(args, 'func', None):
1195 parser.print_usage()
1198 # The following two lines are used by the test suite.
1199 _LOG.debug('prefix = {:s}'.format(TAG_PREFIX))
1200 _LOG.debug('repository = {:s}'.format(NOTMUCH_GIT_DIR))
1202 if args.func != init:
1203 FORMAT_VERSION = read_format_version()
1205 _LOG.debug('FORMAT_VERSION={:d}'.format(FORMAT_VERSION))
1207 if args.func == help:
1208 arg_names = ['command']
1210 (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
1211 kwargs = {key: getattr(args, key) for key in arg_names if key in args}
1214 except SubprocessError as e:
1215 if _LOG.level == _logging.DEBUG:
1216 raise # don't mask the traceback