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 subprocess as _subprocess
36 import tempfile as _tempfile
37 import textwrap as _textwrap
38 from urllib.parse import quote as _quote
39 from urllib.parse import unquote as _unquote
42 _LOG = _logging.getLogger('notmuch-git')
43 _LOG.setLevel(_logging.WARNING)
44 _LOG.addHandler(_logging.StreamHandler())
46 NOTMUCH_GIT_DIR = None
50 _HEX_ESCAPE_REGEX = _re.compile('%[0-9A-F]{2}')
51 _TAG_DIRECTORY = 'tags/'
52 _TAG_FILE_REGEX = ( _re.compile(_TAG_DIRECTORY + '(?P<id>[^/]*)/(?P<tag>[^/]*)'),
53 _re.compile(_TAG_DIRECTORY + '([0-9a-f]{2}/){2}(?P<id>[^/]*)/(?P<tag>[^/]*)'))
55 # magic hash for Git (git hash-object -t blob /dev/null)
56 _EMPTYBLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
58 def _hex_quote(string, safe='+@=:,'):
60 quote('abc def') -> 'abc%20def'.
62 Wrap urllib.parse.quote with additional safe characters (in
63 addition to letters, digits, and '_.-') and lowercase hex digits
64 (e.g. '%3a' instead of '%3A').
66 uppercase_escapes = _quote(string, safe)
67 return _HEX_ESCAPE_REGEX.sub(
68 lambda match: match.group(0).lower(),
71 def _xapian_quote(string):
73 Quote a string for Xapian's QueryParser.
75 Xapian uses double-quotes for quoting strings. You can escape
76 internal quotes by repeating them [1,2,3].
78 [1]: https://trac.xapian.org/ticket/128#comment:2
79 [2]: https://trac.xapian.org/ticket/128#comment:17
80 [3]: https://trac.xapian.org/changeset/13823/svn
82 return '"{0}"'.format(string.replace('"', '""'))
85 def _xapian_unquote(string):
87 Unquote a Xapian-quoted string.
89 if string.startswith('"') and string.endswith('"'):
90 return string[1:-1].replace('""', '"')
96 from time import perf_counter
98 def inner(*args, **kwargs):
99 start_time = perf_counter()
100 rval = fn(*args, **kwargs)
101 end_time = perf_counter()
102 _LOG.info('{0}: {1:.8f}s elapsed'.format(fn.__name__, end_time - start_time))
108 class SubprocessError(RuntimeError):
109 "A subprocess exited with a nonzero status"
110 def __init__(self, args, status, stdout=None, stderr=None):
114 msg = '{args} exited with {status}'.format(args=args, status=status)
116 msg = '{msg}: {stderr}'.format(msg=msg, stderr=stderr)
117 super(SubprocessError, self).__init__(msg)
120 class _SubprocessContextManager(object):
122 PEP 343 context manager for subprocesses.
124 'expect' holds a tuple of acceptable exit codes, otherwise we'll
125 raise a SubprocessError in __exit__.
127 def __init__(self, process, args, expect=(0,)):
128 self._process = process
130 self._expect = expect
135 def __exit__(self, type, value, traceback):
136 for name in ['stdin', 'stdout', 'stderr']:
137 stream = getattr(self._process, name)
140 setattr(self._process, name, None)
141 status = self._process.wait()
143 'collect {args} with status {status} (expected {expect})'.format(
144 args=self._args, status=status, expect=self._expect))
145 if status not in self._expect:
146 raise SubprocessError(args=self._args, status=status)
149 return self._process.wait()
152 def _spawn(args, input=None, additional_env=None, wait=False, stdin=None,
153 stdout=None, stderr=None, encoding=_locale.getpreferredencoding(),
154 expect=(0,), **kwargs):
155 """Spawn a subprocess, and optionally wait for it to finish.
157 This wrapper around subprocess.Popen has two modes, depending on
158 the truthiness of 'wait'. If 'wait' is true, we use p.communicate
159 internally to write 'input' to the subprocess's stdin and read
160 from it's stdout/stderr. If 'wait' is False, we return a
161 _SubprocessContextManager instance for fancier handling
162 (e.g. piping between processes).
164 For 'wait' calls when you want to write to the subprocess's stdin,
165 you only need to set 'input' to your content. When 'input' is not
166 None but 'stdin' is, we'll automatically set 'stdin' to PIPE
167 before calling Popen. This avoids having the subprocess
168 accidentally inherit the launching process's stdin.
170 _LOG.debug('spawn {args} (additional env. var.: {env})'.format(
171 args=args, env=additional_env))
172 if not stdin and input is not None:
173 stdin = _subprocess.PIPE
175 if not kwargs.get('env'):
176 kwargs['env'] = dict(_os.environ)
177 kwargs['env'].update(additional_env)
178 p = _subprocess.Popen(
179 args, stdin=stdin, stdout=stdout, stderr=stderr, **kwargs)
181 if hasattr(input, 'encode'):
182 input = input.encode(encoding)
183 (stdout, stderr) = p.communicate(input=input)
186 'collect {args} with status {status} (expected {expect})'.format(
187 args=args, status=status, expect=expect))
188 if stdout is not None:
189 stdout = stdout.decode(encoding)
190 if stderr is not None:
191 stderr = stderr.decode(encoding)
192 if status not in expect:
193 raise SubprocessError(
194 args=args, status=status, stdout=stdout, stderr=stderr)
195 return (status, stdout, stderr)
196 if p.stdin and not stdin:
200 p.stdin = _codecs.getwriter(encoding=encoding)(stream=p.stdin)
201 stream_reader = _codecs.getreader(encoding=encoding)
203 p.stdout = stream_reader(stream=p.stdout)
205 p.stderr = stream_reader(stream=p.stderr)
206 return _SubprocessContextManager(args=args, process=p, expect=expect)
209 def _git(args, **kwargs):
210 args = ['git', '--git-dir', NOTMUCH_GIT_DIR] + list(args)
211 return _spawn(args=args, **kwargs)
214 def _get_current_branch():
215 """Get the name of the current branch.
217 Return 'None' if we're not on a branch.
220 (status, branch, stderr) = _git(
221 args=['symbolic-ref', '--short', 'HEAD'],
222 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
223 except SubprocessError as e:
224 if 'not a symbolic ref' in e:
227 return branch.strip()
231 "Get the default remote for the current branch."
232 local_branch = _get_current_branch()
233 (status, remote, stderr) = _git(
234 args=['config', 'branch.{0}.remote'.format(local_branch)],
235 stdout=_subprocess.PIPE, wait=True)
236 return remote.strip()
238 def _tag_query(prefix=None):
241 return '(tag (starts-with "{:s}"))'.format(prefix.replace('"','\\\"'))
243 def count_messages(prefix=None):
244 "count messages with a given prefix."
245 (status, stdout, stderr) = _spawn(
246 args=['notmuch', 'count', '--query=sexp', _tag_query(prefix)],
247 stdout=_subprocess.PIPE, wait=True)
249 _LOG.error("failed to run notmuch config")
251 return int(stdout.rstrip())
253 def get_tags(prefix=None):
254 "Get a list of tags with a given prefix."
255 (status, stdout, stderr) = _spawn(
256 args=['notmuch', 'search', '--exclude=false', '--query=sexp', '--output=tags', _tag_query(prefix)],
257 stdout=_subprocess.PIPE, wait=True)
258 return [tag for tag in stdout.splitlines()]
260 def archive(treeish='HEAD', args=()):
262 Dump a tar archive of the current notmuch-git tag set.
266 Each tag $tag for message with Message-Id $id is written to
269 tags/hash1(id)/hash2(id)/encode($id)/encode($tag)
271 The encoding preserves alphanumerics, and the characters
272 "+-_@=.:," (not the quotes). All other octets are replaced with
273 '%' followed by a two digit hex number.
275 _git(args=['archive', treeish] + list(args), wait=True)
278 def clone(repository):
280 Create a local notmuch-git repository from a remote source.
282 This wraps 'git clone', adding some options to avoid creating a
283 working tree while preserving remote-tracking branches and
286 with _tempfile.TemporaryDirectory(prefix='notmuch-git-clone.') as workdir:
289 'git', 'clone', '--no-checkout', '--separate-git-dir', NOTMUCH_GIT_DIR,
290 repository, workdir],
292 _git(args=['config', '--unset', 'core.worktree'], wait=True, expect=(0, 5))
293 _git(args=['config', 'core.bare', 'true'], wait=True)
294 (status, stdout, stderr) = _git(args=['show-ref', '--verify',
296 'refs/remotes/origin/config'],
300 _git(args=['branch', 'config', 'origin/config'], wait=True)
301 existing_tags = get_tags()
304 'Not checking out to avoid clobbering existing tags: {}'.format(
305 ', '.join(existing_tags)))
310 def _is_committed(status):
311 return len(status['added']) + len(status['deleted']) == 0
315 def __init__(self, repo, treeish):
316 self.cache_path = _os.path.join(repo, 'notmuch', 'index_cache.json')
317 self.index_path = _os.path.join(repo, 'index')
318 self.current_treeish = treeish
322 self.index_checksum = None
324 self._load_cache_file()
326 def _load_cache_file(self):
328 with open(self.cache_path) as f:
330 self.treeish = data['treeish']
331 self.hash = data['hash']
332 self.index_checksum = data['index_checksum']
333 except FileNotFoundError:
335 except _json.JSONDecodeError:
336 _LOG.error("Error decoding cache")
343 def __exit__(self, type, value, traceback):
344 checksum = _read_index_checksum(self.index_path)
346 args=['rev-parse', self.current_treeish],
347 stdout=_subprocess.PIPE,
350 with open(self.cache_path, "w") as f:
351 _json.dump({'treeish': self.current_treeish,
352 'hash': hash.rstrip(), 'index_checksum': checksum }, f)
356 current_checksum = _read_index_checksum(self.index_path)
358 args=['rev-parse', self.current_treeish],
359 stdout=_subprocess.PIPE,
361 current_hash = hash.rstrip()
363 if self.current_treeish == self.treeish and \
364 self.index_checksum and self.index_checksum == current_checksum and \
365 self.hash and self.hash == current_hash:
368 _git(args=['read-tree', self.current_treeish], wait=True)
371 def check_safe_fraction(status):
373 conf = _notmuch_config_get ('git.safe_fraction')
374 if conf and conf != '':
377 total = count_messages (TAG_PREFIX)
379 _LOG.error('No existing tags with given prefix, stopping.')
380 _LOG.error('Use --force to override.')
382 change = len(status['added'])+len(status['deleted'])
383 fraction = change/total
384 _LOG.debug('total messages {:d}, change: {:d}, fraction: {:f}'.format(total,change,fraction))
386 _LOG.error('safe fraction {:f} exceeded, stopping.'.format(safe))
387 _LOG.error('Use --force to override or reconfigure git.safe_fraction.')
390 def commit(treeish='HEAD', message=None, force=False):
392 Commit prefix-matching tags from the notmuch database to Git.
395 status = get_status()
397 if _is_committed(status=status):
398 _LOG.warning('Nothing to commit')
402 check_safe_fraction (status)
404 with CachedIndex(NOTMUCH_GIT_DIR, treeish) as index:
406 _update_index(status=status)
409 stdout=_subprocess.PIPE,
411 (_, parent, _) = _git(
412 args=['rev-parse', treeish],
413 stdout=_subprocess.PIPE,
415 (_, commit, _) = _git(
416 args=['commit-tree', tree.strip(), '-p', parent.strip()],
418 stdout=_subprocess.PIPE,
421 args=['update-ref', treeish, commit.strip()],
422 stdout=_subprocess.PIPE,
424 except Exception as e:
425 _git(args=['read-tree', '--empty'], wait=True)
426 _git(args=['read-tree', treeish], wait=True)
430 def _update_index(status):
432 args=['update-index', '--index-info'],
433 stdin=_subprocess.PIPE) as p:
434 for id, tags in status['deleted'].items():
435 for line in _index_tags_for_message(id=id, status='D', tags=tags):
437 for id, tags in status['added'].items():
438 for line in _index_tags_for_message(id=id, status='A', tags=tags):
442 def fetch(remote=None):
444 Fetch changes from the remote repository.
446 See 'merge' to bring those changes into notmuch.
451 _git(args=args, wait=True)
454 def init(remote=None,format_version=None):
456 Create an empty notmuch-git repository.
458 This wraps 'git init' with a few extra steps to support subsequent
459 status and commit commands.
461 from pathlib import Path
462 parent = Path(NOTMUCH_GIT_DIR).parent
465 except FileExistsError:
468 if not format_version:
471 format_version=int(format_version)
473 if format_version > 1 or format_version < 0:
474 _LOG.error("Illegal format version {:d}".format(format_version))
477 _spawn(args=['git', '--git-dir', NOTMUCH_GIT_DIR, 'init',
478 '--initial-branch=master', '--quiet', '--bare'], wait=True)
479 _git(args=['config', 'core.logallrefupdates', 'true'], wait=True)
480 # create an empty blob (e69de29bb2d1d6434b8b29ae775ad8c2e48c5391)
481 _git(args=['hash-object', '-w', '--stdin'], input='', wait=True)
482 allow_empty=('--allow-empty',)
483 if format_version >= 1:
485 # create a blob for the FORMAT file
486 (status, stdout, _) = _git(args=['hash-object', '-w', '--stdin'], stdout=_subprocess.PIPE,
487 input='{:d}\n'.format(format_version), wait=True)
488 verhash=stdout.rstrip()
489 _LOG.debug('hash of FORMAT blob = {:s}'.format(verhash))
490 # Add FORMAT to the index
491 _git(args=['update-index', '--add', '--cacheinfo', '100644,{:s},FORMAT'.format(verhash)], wait=True)
495 'commit', *allow_empty, '-m', 'Start a new notmuch-git repository'
497 additional_env={'GIT_WORK_TREE': NOTMUCH_GIT_DIR},
501 def checkout(force=None):
503 Update the notmuch database from Git.
505 This is mainly useful to discard your changes in notmuch relative
508 status = get_status()
511 check_safe_fraction(status)
514 args=['notmuch', 'tag', '--batch'], stdin=_subprocess.PIPE) as p:
515 for id, tags in status['added'].items():
516 p.stdin.write(_batch_line(action='-', id=id, tags=tags))
517 for id, tags in status['deleted'].items():
518 p.stdin.write(_batch_line(action='+', id=id, tags=tags))
521 def _batch_line(action, id, tags):
523 'notmuch tag --batch' line for adding/removing tags.
525 Set 'action' to '-' to remove a tag or '+' to add the tags to a
528 tag_string = ' '.join(
529 '{action}{prefix}{tag}'.format(
530 action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))
532 line = '{tags} -- id:{id}\n'.format(
533 tags=tag_string, id=_xapian_quote(string=id))
537 def _insist_committed():
538 "Die if the the notmuch tags don't match the current HEAD."
539 status = get_status()
540 if not _is_committed(status=status):
541 _LOG.error('\n'.join([
542 'Uncommitted changes to {prefix}* tags in notmuch',
544 "For a summary of changes, run 'notmuch-git status'",
545 "To save your changes, run 'notmuch-git commit' before merging/pull",
546 "To discard your changes, run 'notmuch-git checkout'",
547 ]).format(prefix=TAG_PREFIX))
551 def pull(repository=None, refspecs=None):
553 Pull (merge) remote repository changes to notmuch.
555 'pull' is equivalent to 'fetch' followed by 'merge'. We use the
556 Git-configured repository for your current branch
557 (branch.<name>.repository, likely 'origin', and
558 branch.<name>.merge, likely 'master').
561 if refspecs and not repository:
562 repository = _get_remote()
565 args.append(repository)
567 args.extend(refspecs)
568 with _tempfile.TemporaryDirectory(prefix='notmuch-git-pull.') as workdir:
574 additional_env={'GIT_WORK_TREE': workdir},
579 def merge(reference='@{upstream}'):
581 Merge changes from 'reference' into HEAD and load the result into notmuch.
583 The default reference is '@{upstream}'.
586 with _tempfile.TemporaryDirectory(prefix='notmuch-git-merge.') as workdir:
589 ['merge', reference]]:
592 additional_env={'GIT_WORK_TREE': workdir},
599 A simple wrapper for 'git log'.
601 After running 'notmuch-git fetch', you can inspect the changes with
602 'notmuch-git log HEAD..@{upstream}'.
604 # we don't want output trapping here, because we want the pager.
605 args = ['log', '--name-status', '--no-renames'] + list(args)
606 with _git(args=args, expect=(0, 1, -13)) as p:
610 def push(repository=None, refspecs=None):
611 "Push the local notmuch-git Git state to a remote repository."
612 if refspecs and not repository:
613 repository = _get_remote()
616 args.append(repository)
618 args.extend(refspecs)
619 _git(args=args, wait=True)
624 Show pending updates in notmuch or git repo.
626 Prints lines of the form
630 where n is a single character representing notmuch database status
634 Tag is present in notmuch database, but not committed to notmuch-git
635 (equivalently, tag has been deleted in notmuch-git repo, e.g. by a
636 pull, but not restored to notmuch database).
640 Tag is present in notmuch-git repo, but not restored to notmuch
641 database (equivalently, tag has been deleted in notmuch).
645 Message is unknown (missing from local notmuch database).
647 The second character (if present) represents a difference between
648 local and upstream branches. Typically 'notmuch-git fetch' needs to be
653 Tag is present in upstream, but not in the local Git branch.
657 Tag is present in local Git branch, but not upstream.
659 status = get_status()
660 # 'output' is a nested defaultdict for message status:
661 # * The outer dict is keyed by message id.
662 # * The inner dict is keyed by tag name.
663 # * The inner dict values are status strings (' a', 'Dd', ...).
664 output = _collections.defaultdict(
665 lambda : _collections.defaultdict(lambda : ' '))
666 for id, tags in status['added'].items():
668 output[id][tag] = 'A'
669 for id, tags in status['deleted'].items():
671 output[id][tag] = 'D'
672 for id, tags in status['missing'].items():
674 output[id][tag] = 'U'
676 for id, tag in _diff_refs(filter='A'):
677 output[id][tag] += 'a'
678 for id, tag in _diff_refs(filter='D'):
679 output[id][tag] += 'd'
680 for id, tag_status in sorted(output.items()):
681 for tag, status in sorted(tag_status.items()):
682 print('{status}\t{id}\t{tag}'.format(
683 status=status, id=id, tag=tag))
686 def _is_unmerged(ref='@{upstream}'):
688 (status, fetch_head, stderr) = _git(
689 args=['rev-parse', ref],
690 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
691 except SubprocessError as e:
692 if 'No upstream configured' in e.stderr:
695 (status, base, stderr) = _git(
696 args=['merge-base', 'HEAD', ref],
697 stdout=_subprocess.PIPE, wait=True)
698 return base != fetch_head
703 from notmuch2 import Database
704 self._notmuch = Database()
710 if id in self._known:
711 return self._known[id];
715 _ = self._notmuch.find(id)
716 self._known[id] = True
718 self._known[id] = False
720 (_, stdout, stderr) = _spawn(
721 args=['notmuch', 'search', '--exclude=false', '--output=files', 'id:{0}'.format(id)],
722 stdout=_subprocess.PIPE,
724 self._known[id] = stdout != None
725 return self._known[id]
734 with PrivateIndex(repo=NOTMUCH_GIT_DIR, prefix=TAG_PREFIX) as index:
735 maybe_deleted = index.diff(filter='D')
736 for id, tags in maybe_deleted.items():
738 status['deleted'][id] = tags
740 status['missing'][id] = tags
741 status['added'] = index.diff(filter='A')
746 def __init__(self, repo, prefix):
748 _os.makedirs(_os.path.join(repo, 'notmuch'))
749 except FileExistsError:
752 file_name = 'notmuch/index'
753 self.index_path = _os.path.join(repo, file_name)
754 self.cache_path = _os.path.join(repo, 'notmuch', '{:s}.json'.format(_hex_quote(file_name)))
756 self.current_prefix = prefix
762 self._load_cache_file()
763 self.file_tree = None
769 def __exit__(self, type, value, traceback):
770 checksum = _read_index_checksum(self.index_path)
771 (count, uuid, lastmod) = _read_database_lastmod()
772 with open(self.cache_path, "w") as f:
773 _json.dump({'prefix': self.current_prefix, 'uuid': uuid, 'lastmod': lastmod, 'checksum': checksum }, f)
775 def _load_cache_file(self):
777 with open(self.cache_path) as f:
779 self.prefix = data['prefix']
780 self.uuid = data['uuid']
781 self.lastmod = data['lastmod']
782 self.checksum = data['checksum']
783 except FileNotFoundError:
785 except _json.JSONDecodeError:
786 _LOG.error("Error decoding cache")
790 def _read_file_tree(self):
794 args=['ls-files', 'tags'],
795 additional_env={'GIT_INDEX_FILE': self.index_path},
796 stdout=_subprocess.PIPE) as git:
797 for file in git.stdout:
798 dir=_os.path.dirname(file)
799 tag=_os.path.basename(file).rstrip()
800 if dir not in self.file_tree:
801 self.file_tree[dir]=[tag]
803 self.file_tree[dir].append(tag)
806 def _clear_tags_for_message(self, id):
808 Clear any existing index entries for message 'id'
810 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
813 if self.file_tree == None:
814 self._read_file_tree()
818 if dir not in self.file_tree:
821 for file in self.file_tree[dir]:
822 line = '0 0000000000000000000000000000000000000000\t{:s}/{:s}\n'.format(dir,file)
827 def _index_tags(self):
828 "Write notmuch tags to private git index."
829 prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)
830 current_checksum = _read_index_checksum(self.index_path)
831 if (self.prefix == None or self.prefix != self.current_prefix
832 or self.checksum == None or self.checksum != current_checksum):
834 args=['read-tree', '--empty'],
835 additional_env={'GIT_INDEX_FILE': self.index_path}, wait=True)
839 (count,uuid,lastmod) = _read_database_lastmod()
840 if self.prefix == self.current_prefix and self.uuid \
841 and self.uuid == uuid and self.checksum == current_checksum:
842 query = '(and (infix "lastmod:{:d}..")) {:s})'.format(self.lastmod+1, query)
845 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', query],
846 stdout=_subprocess.PIPE) as notmuch:
848 args=['update-index', '--index-info'],
849 stdin=_subprocess.PIPE,
850 additional_env={'GIT_INDEX_FILE': self.index_path}) as git:
851 for line in notmuch.stdout:
852 if line.strip().startswith('#'):
854 (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
856 _unquote(tag[len(prefix):])
857 for tag in tags_string.split()
858 if tag.startswith(prefix)]
859 id = _xapian_unquote(string=id)
861 for line in self._clear_tags_for_message(id=id):
862 git.stdin.write(line)
863 for line in _index_tags_for_message(
864 id=id, status='A', tags=tags):
865 git.stdin.write(line)
868 def diff(self, filter):
870 Get an {id: {tag, ...}} dict for a given filter.
872 For example, use 'A' to find added tags, and 'D' to find deleted tags.
874 s = _collections.defaultdict(set)
877 'diff-index', '--cached', '--diff-filter', filter,
878 '--name-only', 'HEAD'],
879 additional_env={'GIT_INDEX_FILE': self.index_path},
880 stdout=_subprocess.PIPE) as p:
881 # Once we drop Python < 3.3, we can use 'yield from' here
882 for id, tag in _unpack_diff_lines(stream=p.stdout):
886 def _read_index_checksum (index_path):
887 """Read the index checksum, as defined by index-format.txt in the git source
888 WARNING: assumes SHA1 repo"""
891 with open(index_path, 'rb') as f:
892 size=_os.path.getsize(index_path)
894 return binascii.hexlify(f.read(20)).decode('ascii')
895 except FileNotFoundError:
898 def _read_database_lastmod():
900 args=['notmuch', 'count', '--lastmod', '*'],
901 stdout=_subprocess.PIPE) as notmuch:
902 (count,uuid,lastmod_str) = notmuch.stdout.readline().split()
903 return (count,uuid,int(lastmod_str))
906 hid=_hex_quote(string=id)
907 from hashlib import blake2b
909 if FORMAT_VERSION==0:
910 return 'tags/{hid}'.format(hid=hid)
911 elif FORMAT_VERSION==1:
912 idhash = blake2b(hid.encode('utf8'), digest_size=2).hexdigest()
913 return 'tags/{dir1}/{dir2}/{hid}'.format(
915 dir1=idhash[0:2],dir2=idhash[2:])
917 _LOG.error("Unknown format version",FORMAT_VERSION)
920 def _index_tags_for_message(id, status, tags):
922 Update the Git index to either create or delete an empty file.
924 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
931 hash = '0000000000000000000000000000000000000000'
934 path = '{ipath}/{tag}'.format(ipath=_id_path(id),tag=_hex_quote(string=tag))
935 yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)
938 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
940 args=['diff', '--diff-filter', filter, '--name-only', a, b],
941 stdout=_subprocess.PIPE) as p:
942 # Once we drop Python < 3.3, we can use 'yield from' here
943 for id, tag in _unpack_diff_lines(stream=p.stdout):
947 def _unpack_diff_lines(stream):
948 "Iterate through (id, tag) tuples in a diff stream."
950 match = _TAG_FILE_REGEX[FORMAT_VERSION].match(line.strip())
952 message = 'non-tag line in diff: {!r}'.format(line.strip())
953 if line.startswith(_TAG_DIRECTORY):
954 raise ValueError(message)
957 id = _unquote(match.group('id'))
958 tag = _unquote(match.group('tag'))
962 def _help(parser, command=None):
964 Show help for an notmuch-git command.
966 Because some folks prefer:
968 $ notmuch-git help COMMAND
972 $ notmuch-git COMMAND --help
975 parser.parse_args([command, '--help'])
977 parser.parse_args(['--help'])
979 def _notmuch_config_get(key):
980 (status, stdout, stderr) = _spawn(
981 args=['notmuch', 'config', 'get', key],
982 stdout=_subprocess.PIPE, wait=True)
984 _LOG.error("failed to run notmuch config")
986 return stdout.rstrip()
988 def read_format_version():
990 (status, stdout, stderr) = _git(
991 args=['cat-file', 'blob', 'master:FORMAT'],
992 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
993 except SubprocessError as e:
994 _LOG.debug("failed to read FORMAT file from git, assuming format version 0")
999 # based on BaseDirectory.save_data_path from pyxdg (LGPL2+)
1000 def xdg_data_path(profile):
1001 resource = _os.path.join('notmuch',profile,'git')
1002 assert not resource.startswith('/')
1003 _home = _os.path.expanduser('~')
1004 xdg_data_home = _os.environ.get('XDG_DATA_HOME') or \
1005 _os.path.join(_home, '.local', 'share')
1006 path = _os.path.join(xdg_data_home, resource)
1009 if __name__ == '__main__':
1012 parser = argparse.ArgumentParser(
1013 description=__doc__.strip(),
1014 formatter_class=argparse.RawDescriptionHelpFormatter)
1015 parser.add_argument(
1016 '-C', '--git-dir', metavar='REPO',
1017 help='Git repository to operate on.')
1018 parser.add_argument(
1019 '-p', '--tag-prefix', metavar='PREFIX',
1021 help='Prefix of tags to operate on.')
1022 parser.add_argument(
1023 '-N', '--nmbug', action='store_true',
1024 help='Set defaults for --tag-prefix and --git-dir for the notmuch bug tracker')
1025 parser.add_argument(
1026 '-l', '--log-level',
1027 choices=['critical', 'error', 'warning', 'info', 'debug'],
1028 help='Log verbosity. Defaults to {!r}.'.format(
1029 _logging.getLevelName(_LOG.level).lower()))
1031 help = _functools.partial(_help, parser=parser)
1032 help.__doc__ = _help.__doc__
1033 subparsers = parser.add_subparsers(
1036 'For help on a particular command, run: '
1037 "'%(prog)s ... <command> --help'."))
1052 func = locals()[command]
1053 doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
1054 subparser = subparsers.add_parser(
1056 help=doc.splitlines()[0],
1058 formatter_class=argparse.RawDescriptionHelpFormatter)
1059 subparser.set_defaults(func=func)
1060 if command == 'archive':
1061 subparser.add_argument(
1062 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
1064 'The tree or commit to produce an archive for. Defaults '
1066 subparser.add_argument(
1067 'args', metavar='ARG', nargs='*',
1069 "Argument passed through to 'git archive'. Set anything "
1070 'before <tree-ish>, see git-archive(1) for details.'))
1071 elif command == 'checkout':
1072 subparser.add_argument(
1073 '-f', '--force', action='store_true',
1074 help='checkout a large fraction of tags.')
1075 elif command == 'clone':
1076 subparser.add_argument(
1079 'The (possibly remote) repository to clone from. See the '
1080 'URLS section of git-clone(1) for more information on '
1081 'specifying repositories.'))
1082 elif command == 'commit':
1083 subparser.add_argument(
1084 '-f', '--force', action='store_true',
1085 help='commit a large fraction of tags.')
1086 subparser.add_argument(
1087 'message', metavar='MESSAGE', default='', nargs='?',
1088 help='Text for the commit message.')
1089 elif command == 'fetch':
1090 subparser.add_argument(
1091 'remote', metavar='REMOTE', nargs='?',
1093 'Override the default configured in branch.<name>.remote '
1094 'to fetch from a particular remote repository (e.g. '
1096 elif command == 'help':
1097 subparser.add_argument(
1098 'command', metavar='COMMAND', nargs='?',
1099 help='The command to show help for.')
1100 elif command == 'init':
1101 subparser.add_argument(
1102 '--format-version', metavar='VERSION',
1104 help='create format VERSION repository.')
1105 elif command == 'log':
1106 subparser.add_argument(
1107 'args', metavar='ARG', nargs='*',
1108 help="Additional argument passed through to 'git log'.")
1109 elif command == 'merge':
1110 subparser.add_argument(
1111 'reference', metavar='REFERENCE', default='@{upstream}',
1114 'Reference, usually other branch heads, to merge into '
1115 "our branch. Defaults to '@{upstream}'."))
1116 elif command == 'pull':
1117 subparser.add_argument(
1118 'repository', metavar='REPOSITORY', default=None, nargs='?',
1120 'The "remote" repository that is the source of the pull. '
1121 'This parameter can be either a URL (see the section GIT '
1122 'URLS in git-pull(1)) or the name of a remote (see the '
1123 'section REMOTES in git-pull(1)).'))
1124 subparser.add_argument(
1125 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1127 'Refspec (usually a branch name) to fetch and merge. See '
1128 'the <refspec> entry in the OPTIONS section of '
1129 'git-pull(1) for other possibilities.'))
1130 elif command == 'push':
1131 subparser.add_argument(
1132 'repository', metavar='REPOSITORY', default=None, nargs='?',
1134 'The "remote" repository that is the destination of the '
1135 'push. This parameter can be either a URL (see the '
1136 'section GIT URLS in git-push(1)) or the name of a remote '
1137 '(see the section REMOTES in git-push(1)).'))
1138 subparser.add_argument(
1139 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1141 'Refspec (usually a branch name) to push. See '
1142 'the <refspec> entry in the OPTIONS section of '
1143 'git-push(1) for other possibilities.'))
1145 args = parser.parse_args()
1148 notmuch_profile = _os.getenv('NOTMUCH_PROFILE','default')
1150 if args.nmbug or _os.path.basename(__file__) == 'nmbug':
1154 NOTMUCH_GIT_DIR = args.git_dir
1157 default = _os.path.join('~', '.nmbug')
1159 default = _notmuch_config_get ('git.path')
1161 default = xdg_data_path(notmuch_profile)
1163 NOTMUCH_GIT_DIR = _os.path.expanduser(_os.getenv('NOTMUCH_GIT_DIR', default))
1165 _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
1166 if _os.path.isdir(_NOTMUCH_GIT_DIR):
1167 NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
1170 TAG_PREFIX = args.tag_prefix
1173 prefix = 'notmuch::'
1175 prefix = _notmuch_config_get ('git.tag_prefix')
1177 TAG_PREFIX = _os.getenv('NOTMUCH_GIT_PREFIX', prefix)
1179 _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,') # quote ':'
1182 level = getattr(_logging, args.log_level.upper())
1183 _LOG.setLevel(level)
1186 for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
1187 _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
1189 if _notmuch_config_get('built_with.sexp_queries') != 'true':
1190 _LOG.error("notmuch git needs sexp query support")
1193 if not getattr(args, 'func', None):
1194 parser.print_usage()
1197 # The following two lines are used by the test suite.
1198 _LOG.debug('prefix = {:s}'.format(TAG_PREFIX))
1199 _LOG.debug('repository = {:s}'.format(NOTMUCH_GIT_DIR))
1201 if args.func != init:
1202 FORMAT_VERSION = read_format_version()
1204 _LOG.debug('FORMAT_VERSION={:d}'.format(FORMAT_VERSION))
1206 if args.func == help:
1207 arg_names = ['command']
1209 (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
1210 kwargs = {key: getattr(args, key) for key in arg_names if key in args}
1213 except SubprocessError as e:
1214 if _LOG.level == _logging.DEBUG:
1215 raise # don't mask the traceback