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):
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 _spawn(args=['git', '--git-dir', NOTMUCH_GIT_DIR, 'init',
470 '--initial-branch=master', '--quiet', '--bare'], wait=True)
471 _git(args=['config', 'core.logallrefupdates', 'true'], wait=True)
472 # create an empty blob (e69de29bb2d1d6434b8b29ae775ad8c2e48c5391)
473 _git(args=['hash-object', '-w', '--stdin'], input='', wait=True)
474 # create a blob for the FORMAT file
475 (status, stdout, _) = _git(args=['hash-object', '-w', '--stdin'], stdout=_subprocess.PIPE,
476 input='1\n', wait=True)
477 verhash=stdout.rstrip()
478 _LOG.debug('hash of FORMAT blob = {:s}'.format(verhash))
479 # Add FORMAT to the index
480 _git(args=['update-index', '--add', '--cacheinfo', '100644,{:s},FORMAT'.format(verhash)], wait=True)
484 'commit', '-m', 'Start a new notmuch-git repository'
486 additional_env={'GIT_WORK_TREE': NOTMUCH_GIT_DIR},
490 def checkout(force=None):
492 Update the notmuch database from Git.
494 This is mainly useful to discard your changes in notmuch relative
497 status = get_status()
500 check_safe_fraction(status)
503 args=['notmuch', 'tag', '--batch'], stdin=_subprocess.PIPE) as p:
504 for id, tags in status['added'].items():
505 p.stdin.write(_batch_line(action='-', id=id, tags=tags))
506 for id, tags in status['deleted'].items():
507 p.stdin.write(_batch_line(action='+', id=id, tags=tags))
510 def _batch_line(action, id, tags):
512 'notmuch tag --batch' line for adding/removing tags.
514 Set 'action' to '-' to remove a tag or '+' to add the tags to a
517 tag_string = ' '.join(
518 '{action}{prefix}{tag}'.format(
519 action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))
521 line = '{tags} -- id:{id}\n'.format(
522 tags=tag_string, id=_xapian_quote(string=id))
526 def _insist_committed():
527 "Die if the the notmuch tags don't match the current HEAD."
528 status = get_status()
529 if not _is_committed(status=status):
530 _LOG.error('\n'.join([
531 'Uncommitted changes to {prefix}* tags in notmuch',
533 "For a summary of changes, run 'notmuch-git status'",
534 "To save your changes, run 'notmuch-git commit' before merging/pull",
535 "To discard your changes, run 'notmuch-git checkout'",
536 ]).format(prefix=TAG_PREFIX))
540 def pull(repository=None, refspecs=None):
542 Pull (merge) remote repository changes to notmuch.
544 'pull' is equivalent to 'fetch' followed by 'merge'. We use the
545 Git-configured repository for your current branch
546 (branch.<name>.repository, likely 'origin', and
547 branch.<name>.merge, likely 'master').
550 if refspecs and not repository:
551 repository = _get_remote()
554 args.append(repository)
556 args.extend(refspecs)
557 with _tempfile.TemporaryDirectory(prefix='notmuch-git-pull.') as workdir:
563 additional_env={'GIT_WORK_TREE': workdir},
568 def merge(reference='@{upstream}'):
570 Merge changes from 'reference' into HEAD and load the result into notmuch.
572 The default reference is '@{upstream}'.
575 with _tempfile.TemporaryDirectory(prefix='notmuch-git-merge.') as workdir:
578 ['merge', reference]]:
581 additional_env={'GIT_WORK_TREE': workdir},
588 A simple wrapper for 'git log'.
590 After running 'notmuch-git fetch', you can inspect the changes with
591 'notmuch-git log HEAD..@{upstream}'.
593 # we don't want output trapping here, because we want the pager.
594 args = ['log', '--name-status', '--no-renames'] + list(args)
595 with _git(args=args, expect=(0, 1, -13)) as p:
599 def push(repository=None, refspecs=None):
600 "Push the local notmuch-git Git state to a remote repository."
601 if refspecs and not repository:
602 repository = _get_remote()
605 args.append(repository)
607 args.extend(refspecs)
608 _git(args=args, wait=True)
613 Show pending updates in notmuch or git repo.
615 Prints lines of the form
619 where n is a single character representing notmuch database status
623 Tag is present in notmuch database, but not committed to notmuch-git
624 (equivalently, tag has been deleted in notmuch-git repo, e.g. by a
625 pull, but not restored to notmuch database).
629 Tag is present in notmuch-git repo, but not restored to notmuch
630 database (equivalently, tag has been deleted in notmuch).
634 Message is unknown (missing from local notmuch database).
636 The second character (if present) represents a difference between
637 local and upstream branches. Typically 'notmuch-git fetch' needs to be
642 Tag is present in upstream, but not in the local Git branch.
646 Tag is present in local Git branch, but not upstream.
648 status = get_status()
649 # 'output' is a nested defaultdict for message status:
650 # * The outer dict is keyed by message id.
651 # * The inner dict is keyed by tag name.
652 # * The inner dict values are status strings (' a', 'Dd', ...).
653 output = _collections.defaultdict(
654 lambda : _collections.defaultdict(lambda : ' '))
655 for id, tags in status['added'].items():
657 output[id][tag] = 'A'
658 for id, tags in status['deleted'].items():
660 output[id][tag] = 'D'
661 for id, tags in status['missing'].items():
663 output[id][tag] = 'U'
665 for id, tag in _diff_refs(filter='A'):
666 output[id][tag] += 'a'
667 for id, tag in _diff_refs(filter='D'):
668 output[id][tag] += 'd'
669 for id, tag_status in sorted(output.items()):
670 for tag, status in sorted(tag_status.items()):
671 print('{status}\t{id}\t{tag}'.format(
672 status=status, id=id, tag=tag))
675 def _is_unmerged(ref='@{upstream}'):
677 (status, fetch_head, stderr) = _git(
678 args=['rev-parse', ref],
679 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
680 except SubprocessError as e:
681 if 'No upstream configured' in e.stderr:
684 (status, base, stderr) = _git(
685 args=['merge-base', 'HEAD', ref],
686 stdout=_subprocess.PIPE, wait=True)
687 return base != fetch_head
696 with PrivateIndex(repo=NOTMUCH_GIT_DIR, prefix=TAG_PREFIX) as index:
697 maybe_deleted = index.diff(filter='D')
698 for id, tags in maybe_deleted.items():
699 (_, stdout, stderr) = _spawn(
700 args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)],
701 stdout=_subprocess.PIPE,
704 status['deleted'][id] = tags
706 status['missing'][id] = tags
707 status['added'] = index.diff(filter='A')
712 def __init__(self, repo, prefix):
714 _os.makedirs(_os.path.join(repo, 'notmuch'))
715 except FileExistsError:
718 file_name = 'notmuch/index'
719 self.index_path = _os.path.join(repo, file_name)
720 self.cache_path = _os.path.join(repo, 'notmuch', '{:s}.json'.format(_hex_quote(file_name)))
722 self.current_prefix = prefix
728 self._load_cache_file()
734 def __exit__(self, type, value, traceback):
735 checksum = _read_index_checksum(self.index_path)
736 (count, uuid, lastmod) = _read_database_lastmod()
737 with open(self.cache_path, "w") as f:
738 _json.dump({'prefix': self.current_prefix, 'uuid': uuid, 'lastmod': lastmod, 'checksum': checksum }, f)
740 def _load_cache_file(self):
742 with open(self.cache_path) as f:
744 self.prefix = data['prefix']
745 self.uuid = data['uuid']
746 self.lastmod = data['lastmod']
747 self.checksum = data['checksum']
748 except FileNotFoundError:
750 except _json.JSONDecodeError:
751 _LOG.error("Error decoding cache")
755 def _index_tags(self):
756 "Write notmuch tags to private git index."
757 prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)
758 current_checksum = _read_index_checksum(self.index_path)
759 if (self.prefix == None or self.prefix != self.current_prefix
760 or self.checksum == None or self.checksum != current_checksum):
762 args=['read-tree', '--empty'],
763 additional_env={'GIT_INDEX_FILE': self.index_path}, wait=True)
767 (count,uuid,lastmod) = _read_database_lastmod()
768 if self.prefix == self.current_prefix and self.uuid \
769 and self.uuid == uuid and self.checksum == current_checksum:
770 query = '(and (infix "lastmod:{:d}..")) {:s})'.format(self.lastmod+1, query)
773 args=['notmuch', 'dump', '--format=batch-tag', '--query=sexp', '--', query],
774 stdout=_subprocess.PIPE) as notmuch:
776 args=['update-index', '--index-info'],
777 stdin=_subprocess.PIPE,
778 additional_env={'GIT_INDEX_FILE': self.index_path}) as git:
779 for line in notmuch.stdout:
780 if line.strip().startswith('#'):
782 (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
784 _unquote(tag[len(prefix):])
785 for tag in tags_string.split()
786 if tag.startswith(prefix)]
787 id = _xapian_unquote(string=id)
789 for line in _clear_tags_for_message(index=self.index_path, id=id):
790 git.stdin.write(line)
791 for line in _index_tags_for_message(
792 id=id, status='A', tags=tags):
793 git.stdin.write(line)
796 def diff(self, filter):
798 Get an {id: {tag, ...}} dict for a given filter.
800 For example, use 'A' to find added tags, and 'D' to find deleted tags.
802 s = _collections.defaultdict(set)
805 'diff-index', '--cached', '--diff-filter', filter,
806 '--name-only', 'HEAD'],
807 additional_env={'GIT_INDEX_FILE': self.index_path},
808 stdout=_subprocess.PIPE) as p:
809 # Once we drop Python < 3.3, we can use 'yield from' here
810 for id, tag in _unpack_diff_lines(stream=p.stdout):
814 def _read_index_checksum (index_path):
815 """Read the index checksum, as defined by index-format.txt in the git source
816 WARNING: assumes SHA1 repo"""
819 with open(index_path, 'rb') as f:
820 size=_os.path.getsize(index_path)
822 return binascii.hexlify(f.read(20)).decode('ascii')
823 except FileNotFoundError:
827 def _clear_tags_for_message(index, id):
829 Clear any existing index entries for message 'id'
831 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
837 args=['ls-files', dir],
838 additional_env={'GIT_INDEX_FILE': index},
839 stdout=_subprocess.PIPE) as git:
840 for file in git.stdout:
841 line = '0 0000000000000000000000000000000000000000\t{:s}\n'.format(file.strip())
844 def _read_database_lastmod():
846 args=['notmuch', 'count', '--lastmod', '*'],
847 stdout=_subprocess.PIPE) as notmuch:
848 (count,uuid,lastmod_str) = notmuch.stdout.readline().split()
849 return (count,uuid,int(lastmod_str))
852 hid=_hex_quote(string=id)
853 from hashlib import blake2b
855 if FORMAT_VERSION==0:
856 return 'tags/{hid}'.format(hid=hid)
857 elif FORMAT_VERSION==1:
858 idhash = blake2b(hid.encode('utf8'), digest_size=2).hexdigest()
859 return 'tags/{dir1}/{dir2}/{hid}'.format(
861 dir1=idhash[0:2],dir2=idhash[2:])
863 _LOG.error("Unknown format version",FORMAT_VERSION)
866 def _index_tags_for_message(id, status, tags):
868 Update the Git index to either create or delete an empty file.
870 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
877 hash = '0000000000000000000000000000000000000000'
880 path = '{ipath}/{tag}'.format(ipath=_id_path(id),tag=_hex_quote(string=tag))
881 yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)
884 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
886 args=['diff', '--diff-filter', filter, '--name-only', a, b],
887 stdout=_subprocess.PIPE) as p:
888 # Once we drop Python < 3.3, we can use 'yield from' here
889 for id, tag in _unpack_diff_lines(stream=p.stdout):
893 def _unpack_diff_lines(stream):
894 "Iterate through (id, tag) tuples in a diff stream."
896 match = _TAG_FILE_REGEX[FORMAT_VERSION].match(line.strip())
898 message = 'non-tag line in diff: {!r}'.format(line.strip())
899 if line.startswith(_TAG_DIRECTORY):
900 raise ValueError(message)
903 id = _unquote(match.group('id'))
904 tag = _unquote(match.group('tag'))
908 def _help(parser, command=None):
910 Show help for an notmuch-git command.
912 Because some folks prefer:
914 $ notmuch-git help COMMAND
918 $ notmuch-git COMMAND --help
921 parser.parse_args([command, '--help'])
923 parser.parse_args(['--help'])
925 def _notmuch_config_get(key):
926 (status, stdout, stderr) = _spawn(
927 args=['notmuch', 'config', 'get', key],
928 stdout=_subprocess.PIPE, wait=True)
930 _LOG.error("failed to run notmuch config")
932 return stdout.rstrip()
934 def read_format_version():
936 (status, stdout, stderr) = _git(
937 args=['cat-file', 'blob', 'master:FORMAT'],
938 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
939 except SubprocessError as e:
940 _LOG.debug("failed to read FORMAT file from git, assuming format version 0")
945 # based on BaseDirectory.save_data_path from pyxdg (LGPL2+)
946 def xdg_data_path(profile):
947 resource = _os.path.join('notmuch',profile,'git')
948 assert not resource.startswith('/')
949 _home = _os.path.expanduser('~')
950 xdg_data_home = _os.environ.get('XDG_DATA_HOME') or \
951 _os.path.join(_home, '.local', 'share')
952 path = _os.path.join(xdg_data_home, resource)
955 if __name__ == '__main__':
958 parser = argparse.ArgumentParser(
959 description=__doc__.strip(),
960 formatter_class=argparse.RawDescriptionHelpFormatter)
962 '-C', '--git-dir', metavar='REPO',
963 help='Git repository to operate on.')
965 '-p', '--tag-prefix', metavar='PREFIX',
967 help='Prefix of tags to operate on.')
969 '-N', '--nmbug', action='store_true',
970 help='Set defaults for --tag-prefix and --git-dir for the notmuch bug tracker')
973 choices=['critical', 'error', 'warning', 'info', 'debug'],
974 help='Log verbosity. Defaults to {!r}.'.format(
975 _logging.getLevelName(_LOG.level).lower()))
977 help = _functools.partial(_help, parser=parser)
978 help.__doc__ = _help.__doc__
979 subparsers = parser.add_subparsers(
982 'For help on a particular command, run: '
983 "'%(prog)s ... <command> --help'."))
998 func = locals()[command]
999 doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
1000 subparser = subparsers.add_parser(
1002 help=doc.splitlines()[0],
1004 formatter_class=argparse.RawDescriptionHelpFormatter)
1005 subparser.set_defaults(func=func)
1006 if command == 'archive':
1007 subparser.add_argument(
1008 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
1010 'The tree or commit to produce an archive for. Defaults '
1012 subparser.add_argument(
1013 'args', metavar='ARG', nargs='*',
1015 "Argument passed through to 'git archive'. Set anything "
1016 'before <tree-ish>, see git-archive(1) for details.'))
1017 elif command == 'checkout':
1018 subparser.add_argument(
1019 '-f', '--force', action='store_true',
1020 help='checkout a large fraction of tags.')
1021 elif command == 'clone':
1022 subparser.add_argument(
1025 'The (possibly remote) repository to clone from. See the '
1026 'URLS section of git-clone(1) for more information on '
1027 'specifying repositories.'))
1028 elif command == 'commit':
1029 subparser.add_argument(
1030 '-f', '--force', action='store_true',
1031 help='commit a large fraction of tags.')
1032 subparser.add_argument(
1033 'message', metavar='MESSAGE', default='', nargs='?',
1034 help='Text for the commit message.')
1035 elif command == 'fetch':
1036 subparser.add_argument(
1037 'remote', metavar='REMOTE', nargs='?',
1039 'Override the default configured in branch.<name>.remote '
1040 'to fetch from a particular remote repository (e.g. '
1042 elif command == 'help':
1043 subparser.add_argument(
1044 'command', metavar='COMMAND', nargs='?',
1045 help='The command to show help for.')
1046 elif command == 'log':
1047 subparser.add_argument(
1048 'args', metavar='ARG', nargs='*',
1049 help="Additional argument passed through to 'git log'.")
1050 elif command == 'merge':
1051 subparser.add_argument(
1052 'reference', metavar='REFERENCE', default='@{upstream}',
1055 'Reference, usually other branch heads, to merge into '
1056 "our branch. Defaults to '@{upstream}'."))
1057 elif command == 'pull':
1058 subparser.add_argument(
1059 'repository', metavar='REPOSITORY', default=None, nargs='?',
1061 'The "remote" repository that is the source of the pull. '
1062 'This parameter can be either a URL (see the section GIT '
1063 'URLS in git-pull(1)) or the name of a remote (see the '
1064 'section REMOTES in git-pull(1)).'))
1065 subparser.add_argument(
1066 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1068 'Refspec (usually a branch name) to fetch and merge. See '
1069 'the <refspec> entry in the OPTIONS section of '
1070 'git-pull(1) for other possibilities.'))
1071 elif command == 'push':
1072 subparser.add_argument(
1073 'repository', metavar='REPOSITORY', default=None, nargs='?',
1075 'The "remote" repository that is the destination of the '
1076 'push. This parameter can be either a URL (see the '
1077 'section GIT URLS in git-push(1)) or the name of a remote '
1078 '(see the section REMOTES in git-push(1)).'))
1079 subparser.add_argument(
1080 'refspecs', metavar='REFSPEC', default=None, nargs='*',
1082 'Refspec (usually a branch name) to push. See '
1083 'the <refspec> entry in the OPTIONS section of '
1084 'git-push(1) for other possibilities.'))
1086 args = parser.parse_args()
1089 notmuch_profile = _os.getenv('NOTMUCH_PROFILE','default')
1091 if args.nmbug or _os.path.basename(__file__) == 'nmbug':
1095 NOTMUCH_GIT_DIR = args.git_dir
1098 default = _os.path.join('~', '.nmbug')
1100 default = _notmuch_config_get ('git.path')
1102 default = xdg_data_path(notmuch_profile)
1104 NOTMUCH_GIT_DIR = _os.path.expanduser(_os.getenv('NOTMUCH_GIT_DIR', default))
1106 _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
1107 if _os.path.isdir(_NOTMUCH_GIT_DIR):
1108 NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
1111 TAG_PREFIX = args.tag_prefix
1114 prefix = 'notmuch::'
1116 prefix = _notmuch_config_get ('git.tag_prefix')
1118 TAG_PREFIX = _os.getenv('NOTMUCH_GIT_PREFIX', prefix)
1120 _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,') # quote ':'
1123 level = getattr(_logging, args.log_level.upper())
1124 _LOG.setLevel(level)
1127 for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
1128 _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
1130 if _notmuch_config_get('built_with.sexp_queries') != 'true':
1131 _LOG.error("notmuch git needs sexp query support")
1134 if not getattr(args, 'func', None):
1135 parser.print_usage()
1138 # The following two lines are used by the test suite.
1139 _LOG.debug('prefix = {:s}'.format(TAG_PREFIX))
1140 _LOG.debug('repository = {:s}'.format(NOTMUCH_GIT_DIR))
1142 FORMAT_VERSION = read_format_version()
1143 _LOG.debug('FORMAT_VERSION={:d}'.format(FORMAT_VERSION))
1145 if args.func == help:
1146 arg_names = ['command']
1148 (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
1149 kwargs = {key: getattr(args, key) for key in arg_names if key in args}
1152 except SubprocessError as e:
1153 if _LOG.level == _logging.DEBUG:
1154 raise # don't mask the traceback