[PATCH v3] nmbug: Translate to Python
authorW. Trevor King <wking@tremily.us>
Sun, 20 Jul 2014 22:59:49 +0000 (15:59 +1700)
committerW. Trevor King <wking@tremily.us>
Fri, 7 Nov 2014 18:03:48 +0000 (10:03 -0800)
03/00c51d8b50949e76701140d73dae12703244ea [new file with mode: 0644]

diff --git a/03/00c51d8b50949e76701140d73dae12703244ea b/03/00c51d8b50949e76701140d73dae12703244ea
new file mode 100644 (file)
index 0000000..db0993e
--- /dev/null
@@ -0,0 +1,1625 @@
+Return-Path: <wking@tremily.us>\r
+X-Original-To: notmuch@notmuchmail.org\r
+Delivered-To: notmuch@notmuchmail.org\r
+Received: from localhost (localhost [127.0.0.1])\r
+       by olra.theworths.org (Postfix) with ESMTP id 2B324431FBF\r
+       for <notmuch@notmuchmail.org>; Sun, 20 Jul 2014 16:00:54 -0700 (PDT)\r
+X-Virus-Scanned: Debian amavisd-new at olra.theworths.org\r
+X-Spam-Flag: NO\r
+X-Spam-Score: 0.001\r
+X-Spam-Level: \r
+X-Spam-Status: No, score=0.001 tagged_above=-999 required=5\r
+       tests=[DKIM_SIGNED=0.1, DKIM_VALID=-0.1, NORMAL_HTTP_TO_IP=0.001,\r
+       RCVD_IN_DNSWL_NONE=-0.0001] autolearn=disabled\r
+Received: from olra.theworths.org ([127.0.0.1])\r
+       by localhost (olra.theworths.org [127.0.0.1]) (amavisd-new, port 10024)\r
+       with ESMTP id YMe5GjwtRK2l for <notmuch@notmuchmail.org>;\r
+       Sun, 20 Jul 2014 16:00:48 -0700 (PDT)\r
+Received: from qmta09.emeryville.ca.mail.comcast.net\r
+       (qmta09.emeryville.ca.mail.comcast.net [76.96.30.96])\r
+       by olra.theworths.org (Postfix) with ESMTP id 54069431FAE\r
+       for <notmuch@notmuchmail.org>; Sun, 20 Jul 2014 16:00:48 -0700 (PDT)\r
+Received: from omta16.emeryville.ca.mail.comcast.net ([76.96.30.72])\r
+       by qmta09.emeryville.ca.mail.comcast.net with comcast\r
+       id Umyd1o0031ZMdJ4A9n0nZN; Sun, 20 Jul 2014 23:00:47 +0000\r
+Received: from odin.tremily.us ([24.18.63.50])\r
+       by omta16.emeryville.ca.mail.comcast.net with comcast\r
+       id Un0m1o002152l3L8cn0m53; Sun, 20 Jul 2014 23:00:47 +0000\r
+Received: from mjolnir.tremily.us (unknown [192.168.0.150])\r
+       by odin.tremily.us (Postfix) with ESMTPS id EA2CD12AB01F;\r
+       Sun, 20 Jul 2014 16:00:45 -0700 (PDT)\r
+Received: (nullmailer pid 6989 invoked by uid 1000);\r
+       Sun, 20 Jul 2014 22:59:50 -0000\r
+From: "W. Trevor King" <wking@tremily.us>\r
+To: notmuch@notmuchmail.org\r
+Subject: [PATCH v3] nmbug: Translate to Python\r
+Date: Sun, 20 Jul 2014 15:59:49 -0700\r
+Message-Id:\r
+ <84447a0ed48412e1587761d560d18cb5affd4f66.1405897133.git.wking@tremily.us>\r
+X-Mailer: git-send-email 1.9.1.353.gc66d89d\r
+DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=comcast.net;\r
+       s=q20140121; t=1405897247;\r
+       bh=hVNdpYaUV2Njh5NyrQYCjLOe/m9G6ak/gTKvvGbu4e4=;\r
+       h=Received:Received:Received:Received:From:To:Subject:Date:\r
+       Message-Id;\r
+       b=h/AtyNhgkZTeA6cXFeHX4S3O7qcNBwQe+zYfC7xkT+pYf9CAhVrz+JKiUsuyjoA/C\r
+       ZVFBjtL/L1Ndj2Q8FDDfhwjPj1kSRZET4OOKx+HgdxJCIgoVjrkm6ikwqTbreMCWgl\r
+       Ey4GZKfmGQFM+wQpI/ZXIF9sNknB0QZj0forijxPBLNrA/B/SDQ70atQ5qvWqgIg+U\r
+       xexPiyerXKCGqb6EMcleqdqoGAjfHiOUuluvpBpXO12V1bL/WIJMzI/Ol06ApPURKm\r
+       Ao0HCHLYe4xbvE36K8kWQsWgY0/YY06CcxDHTz+LFTTeqU5tuPb6KTz8pw/f3lFiIK\r
+       Td9pWz9bTWH+A==\r
+Cc: David Bremner <bremner@debian.org>\r
+X-BeenThere: notmuch@notmuchmail.org\r
+X-Mailman-Version: 2.1.13\r
+Precedence: list\r
+List-Id: "Use and development of the notmuch mail system."\r
+       <notmuch.notmuchmail.org>\r
+List-Unsubscribe: <http://notmuchmail.org/mailman/options/notmuch>,\r
+       <mailto:notmuch-request@notmuchmail.org?subject=unsubscribe>\r
+List-Archive: <http://notmuchmail.org/pipermail/notmuch>\r
+List-Post: <mailto:notmuch@notmuchmail.org>\r
+List-Help: <mailto:notmuch-request@notmuchmail.org?subject=help>\r
+List-Subscribe: <http://notmuchmail.org/mailman/listinfo/notmuch>,\r
+       <mailto:notmuch-request@notmuchmail.org?subject=subscribe>\r
+X-List-Received-Date: Sun, 20 Jul 2014 23:00:54 -0000\r
+\r
+This allows us to capture stdout and stderr separately, and do other\r
+explicit subprocess manipulation without resorting to external\r
+packages.  It should be compatible with Python 2.6 and later\r
+(including the 3.x series), although with 2.6 you'll need the external\r
+argparse package.\r
+\r
+Most of the user-facing interface is the same, but there are a few\r
+changes, where reproducing the original interface was too difficult or\r
+I saw a change to make the underlying Git UI accessible:\r
+\r
+* 'nmbug help' has been split between the general 'nmbug --help' and\r
+  the command-specific 'nmbug COMMAND --help'.\r
+\r
+* Commands are no longer split into "most common", "other useful", and\r
+  "less common" sets.  If we need something like this, I'd prefer\r
+  workflow examples highlighting common commands in the module\r
+  docstring (available with 'nmbug --help').\r
+\r
+* The default repository for 'nmbug push' and 'nmbug fetch' is now the\r
+  current branch's upstream (branch.<name>.remote) instead of\r
+  'origin'.  When we have to, we extract this remote by hand, but\r
+  where possible we just call the Git command without a repository\r
+  argument, and leave it to Git to figure out the default.\r
+\r
+* 'nmbug push' accepts multiple refspecs if you want to explicitly\r
+  specify what to push.  Otherwise, the refspec(s) pushed depend on\r
+  push.default.  The Perl version hardcoded 'master' as the pushed\r
+  refspec.\r
+\r
+* 'nmbug pull' defaults to the current branch's upstream\r
+  (branch.<name>.remote and branch.<name>.merge) instead of hardcoding\r
+  'origin' and 'master'.  It also supports multiple refspecs if for\r
+  some crazy reason you need an octopus merge (but mostly to avoid\r
+  breaking consistency with 'git pull').\r
+\r
+* 'nmbug log' now execs 'git log', as there's no need to keep the\r
+  Python process around once we've launched Git there.\r
+\r
+* 'nmbug status' now catches stderr, and doesn't print errors like:\r
+\r
+    No upstream configured for branch 'master'\r
+\r
+  The Perl implementation had just learned to avoid crashing on that\r
+  case, but wasn't yet catching the dying subprocess's stderr.\r
+\r
+* 'nmbug archive' now accepts positional arguments for the tree-ish\r
+  and additional 'git archive' options.  For example, you can run:\r
+\r
+    $ nmbug archive HEAD -- --format tar.gz\r
+\r
+  I wish I could have preserved the argument order from 'git archive'\r
+  (with the tree-ish at the end), but I'm not sure how to make\r
+  argparse accept arbitrary possitional arguments (some of which take\r
+  arguments).  Flipping the order to put the tree-ish first seemed\r
+  easiest.\r
+---\r
+Changes since v2 [1]:\r
+\r
+* Use four spaces (instead of two) to indent the _is_committed() body.\r
+* Finish the dangling sentence for the _insist_committed() docstring.\r
+\r
+Sorry for the noisy v1/v2/v3 submisson.  Obviously this patch is too\r
+long for me to proof-read accurately so soon ;).  I'd hold off merging\r
+it for at least a week to give me time to go over it again with\r
+clearer eyes.  Of course, anyone else who wants to chip in reviewing\r
+it is welcome to :).\r
+\r
+Cheers,\r
+Trevor\r
+\r
+[1]: id:57f22f6cd86b390969851e7805c9499ba99d2489.1405896148.git.wking@tremily.us\r
+     http://article.gmane.org/gmane.mail.notmuch.general/18758\r
+\r
+ devel/nmbug/nmbug | 1474 ++++++++++++++++++++++++++++-------------------------\r
+ 1 file changed, 766 insertions(+), 708 deletions(-)\r
+ rewrite devel/nmbug/nmbug (97%)\r
+\r
+diff --git a/devel/nmbug/nmbug b/devel/nmbug/nmbug\r
+dissimilarity index 97%\r
+index 998ee6b..57948d3 100755\r
+--- a/devel/nmbug/nmbug\r
++++ b/devel/nmbug/nmbug\r
+@@ -1,708 +1,766 @@\r
+-#!/usr/bin/env perl\r
+-# Copyright (c) 2011 David Bremner\r
+-# License: same as notmuch\r
+-\r
+-use strict;\r
+-use warnings;\r
+-use File::Temp qw(tempdir);\r
+-use Pod::Usage;\r
+-\r
+-no encoding;\r
+-\r
+-my $NMBGIT = $ENV{NMBGIT} || $ENV{HOME}.'/.nmbug';\r
+-\r
+-$NMBGIT .= '/.git' if (-d $NMBGIT.'/.git');\r
+-\r
+-my $TAGPREFIX = defined($ENV{NMBPREFIX}) ? $ENV{NMBPREFIX} : 'notmuch::';\r
+-\r
+-# for encoding\r
+-\r
+-my $ESCAPE_CHAR =     '%';\r
+-my $NO_ESCAPE =               'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.\r
+-                      '0123456789+-_@=.:,';\r
+-my $MUST_ENCODE =     qr{[^\Q$NO_ESCAPE\E]};\r
+-my $ESCAPED_RX =      qr{$ESCAPE_CHAR([A-Fa-f0-9]{2})};\r
+-\r
+-my %command = (\r
+-           archive    => \&do_archive,\r
+-           checkout   => \&do_checkout,\r
+-           clone      => \&do_clone,\r
+-           commit     => \&do_commit,\r
+-           fetch      => \&do_fetch,\r
+-           help       => \&do_help,\r
+-           log        => \&do_log,\r
+-           merge      => \&do_merge,\r
+-           pull       => \&do_pull,\r
+-           push       => \&do_push,\r
+-           status     => \&do_status,\r
+-           );\r
+-\r
+-# Convert prefix into form suitable for literal matching against\r
+-# notmuch dump --format=batch-tag output.\r
+-my $ENCPREFIX = encode_for_fs ($TAGPREFIX);\r
+-$ENCPREFIX =~ s/:/%3a/g;\r
+-\r
+-my $subcommand = shift || usage ();\r
+-\r
+-if (!exists $command{$subcommand}) {\r
+-  usage ();\r
+-}\r
+-\r
+-# magic hash for git\r
+-my $EMPTYBLOB = git (qw{hash-object -t blob /dev/null});\r
+-\r
+-&{$command{$subcommand}}(@ARGV);\r
+-\r
+-sub git_pipe {\r
+-  my $envref = (ref $_[0] eq 'HASH') ? shift : {};\r
+-  my $ioref  = (ref $_[0] eq 'ARRAY') ? shift : undef;\r
+-  my $dir = ($_[0] eq '-|' or $_[0] eq '|-') ? shift : undef;\r
+-\r
+-  unshift @_, 'git';\r
+-  $envref->{GIT_DIR} ||= $NMBGIT;\r
+-  spawn ($envref, defined $ioref ? $ioref : (), defined $dir ? $dir : (), @_);\r
+-}\r
+-\r
+-sub git_with_status {\r
+-  my $fh = git_pipe (@_);\r
+-  my $str = join ('', <$fh>);\r
+-  close $fh;\r
+-  my $status = $?;\r
+-  chomp($str);\r
+-  return ($str, $status);\r
+-}\r
+-\r
+-sub git {\r
+-  my ($str, $status) = git_with_status (@_);\r
+-  if ($status) {\r
+-    die "'git @_' exited with nonzero value\n";\r
+-  }\r
+-  return $str;\r
+-}\r
+-\r
+-sub spawn {\r
+-  my $envref = (ref $_[0] eq 'HASH') ? shift : {};\r
+-  my $ioref  = (ref $_[0] eq 'ARRAY') ? shift : undef;\r
+-  my $dir = ($_[0] eq '-|' or $_[0] eq '|-') ? shift : '-|';\r
+-\r
+-  die unless @_;\r
+-\r
+-  if (open my $child, $dir) {\r
+-    return $child;\r
+-  }\r
+-  # child\r
+-  while (my ($key, $value) = each %{$envref}) {\r
+-    $ENV{$key} = $value;\r
+-  }\r
+-\r
+-  if (defined $ioref && $dir eq '-|') {\r
+-      open my $fh, '|-', @_ or die "open |- @_: $!";\r
+-      foreach my $line (@{$ioref}) {\r
+-      print $fh $line, "\n";\r
+-      }\r
+-      exit ! close $fh;\r
+-    } else {\r
+-      if ($dir ne '|-') {\r
+-      open STDIN, '<', '/dev/null' or die "reopening stdin: $!"\r
+-      }\r
+-      exec @_;\r
+-      die "exec @_: $!";\r
+-    }\r
+-}\r
+-\r
+-\r
+-sub get_tags {\r
+-  my $prefix = shift;\r
+-  my @tags;\r
+-\r
+-  my $fh = spawn ('-|', qw/notmuch search --output=tags/, "*")\r
+-    or die 'error dumping tags';\r
+-\r
+-  while (<$fh>) {\r
+-    chomp ();\r
+-    push @tags, $_ if (m/^$prefix/);\r
+-  }\r
+-  unless (close $fh) {\r
+-    die "'notmuch search --output=tags *' exited with nonzero value\n";\r
+-  }\r
+-  return @tags;\r
+-}\r
+-\r
+-\r
+-sub do_archive {\r
+-  system ('git', "--git-dir=$NMBGIT", 'archive', 'HEAD');\r
+-}\r
+-\r
+-sub do_clone {\r
+-  my $repository = shift;\r
+-\r
+-  my $tempwork = tempdir ('/tmp/nmbug-clone.XXXXXX', CLEANUP => 1);\r
+-  system ('git', 'clone', '--no-checkout', '--separate-git-dir', $NMBGIT,\r
+-          $repository, $tempwork) == 0\r
+-    or die "'git clone' exited with nonzero value\n";\r
+-  git ('config', '--unset', 'core.worktree');\r
+-  git ('config', 'core.bare', 'true');\r
+-}\r
+-\r
+-sub is_committed {\r
+-  my $status = shift;\r
+-  return scalar (@{$status->{added}} ) + scalar (@{$status->{deleted}} ) == 0;\r
+-}\r
+-\r
+-\r
+-sub do_commit {\r
+-  my @args = @_;\r
+-\r
+-  my $status = compute_status ();\r
+-\r
+-  if ( is_committed ($status) ) {\r
+-    print "Nothing to commit\n";\r
+-    return;\r
+-  }\r
+-\r
+-  my $index = read_tree ('HEAD');\r
+-\r
+-  update_index ($index, $status);\r
+-\r
+-  my $tree = git ( { GIT_INDEX_FILE => $index }, 'write-tree')\r
+-    or die 'no output from write-tree';\r
+-\r
+-  my $parent = git ( 'rev-parse', 'HEAD'  )\r
+-    or die 'no output from rev-parse';\r
+-\r
+-  my $commit = git ([ @args ], 'commit-tree', $tree, '-p', $parent)\r
+-    or die 'commit-tree';\r
+-\r
+-  git ('update-ref', 'HEAD', $commit);\r
+-\r
+-  unlink $index || die "unlink: $!";\r
+-\r
+-}\r
+-\r
+-sub read_tree {\r
+-  my $treeish = shift;\r
+-  my $index = $NMBGIT.'/nmbug.index';\r
+-  git ({ GIT_INDEX_FILE => $index }, 'read-tree', '--empty');\r
+-  git ({ GIT_INDEX_FILE => $index }, 'read-tree', $treeish);\r
+-  return $index;\r
+-}\r
+-\r
+-sub update_index {\r
+-  my $index = shift;\r
+-  my $status = shift;\r
+-\r
+-  my $git = spawn ({ GIT_DIR => $NMBGIT, GIT_INDEX_FILE => $index },\r
+-                   '|-', qw/git update-index --index-info/)\r
+-    or die 'git update-index';\r
+-\r
+-  foreach my $pair (@{$status->{deleted}}) {\r
+-    index_tags_for_msg ($git, $pair->{id}, 'D', $pair->{tag});\r
+-  }\r
+-\r
+-  foreach my $pair (@{$status->{added}}) {\r
+-    index_tags_for_msg ($git, $pair->{id}, 'A', $pair->{tag});\r
+-  }\r
+-  unless (close $git) {\r
+-    die "'git update-index --index-info' exited with nonzero value\n";\r
+-  }\r
+-\r
+-}\r
+-\r
+-\r
+-sub do_fetch {\r
+-  my $remote = shift || 'origin';\r
+-\r
+-  git ('fetch', $remote);\r
+-}\r
+-\r
+-\r
+-sub notmuch {\r
+-  my @args = @_;\r
+-  system ('notmuch', @args) == 0 or die  "notmuch @args failed: $?";\r
+-}\r
+-\r
+-\r
+-sub index_tags {\r
+-\r
+-  my $index = $NMBGIT.'/nmbug.index';\r
+-\r
+-  my $query = join ' ', map ("tag:\"$_\"", get_tags ($TAGPREFIX));\r
+-\r
+-  my $fh = spawn ('-|', qw/notmuch dump --format=batch-tag --/, $query)\r
+-    or die "notmuch dump: $!";\r
+-\r
+-  git ('read-tree', '--empty');\r
+-  my $git = spawn ({ GIT_DIR => $NMBGIT, GIT_INDEX_FILE => $index },\r
+-                   '|-', qw/git update-index --index-info/)\r
+-    or die 'git update-index';\r
+-\r
+-  while (<$fh>) {\r
+-\r
+-    chomp();\r
+-    my ($rest,$id) = split(/ -- id:/);\r
+-\r
+-    if ($id =~ s/^"(.*)"\s*$/$1/) {\r
+-      # xapian quoted string, dequote.\r
+-      $id =~ s/""/"/g;\r
+-    }\r
+-\r
+-    #strip prefixes from tags before writing\r
+-    my @tags = grep { s/^[+]$ENCPREFIX//; } split (' ', $rest);\r
+-    index_tags_for_msg ($git,$id, 'A', @tags);\r
+-  }\r
+-  unless (close $git) {\r
+-    die "'git update-index --index-info' exited with nonzero value\n";\r
+-  }\r
+-  unless (close $fh) {\r
+-    die "'notmuch dump --format=batch-tag -- $query' exited with nonzero value\n";\r
+-  }\r
+-  return $index;\r
+-}\r
+-\r
+-# update the git index to either create or delete an empty file.\r
+-# Neither argument should be encoded/escaped.\r
+-sub index_tags_for_msg {\r
+-  my $fh = shift;\r
+-  my $msgid = shift;\r
+-  my $mode = shift;\r
+-\r
+-  my $hash = $EMPTYBLOB;\r
+-  my $blobmode = '100644';\r
+-\r
+-  if ($mode eq 'D') {\r
+-    $blobmode = '0';\r
+-    $hash = '0000000000000000000000000000000000000000';\r
+-  }\r
+-\r
+-  foreach my $tag (@_) {\r
+-    my $tagpath = 'tags/' . encode_for_fs ($msgid) . '/' . encode_for_fs ($tag);\r
+-    print $fh "$blobmode $hash\t$tagpath\n";\r
+-  }\r
+-}\r
+-\r
+-\r
+-sub do_checkout {\r
+-  do_sync (action => 'checkout');\r
+-}\r
+-\r
+-sub quote_for_xapian {\r
+-  my $str = shift;\r
+-  $str =~ s/"/""/g;\r
+-  return '"' . $str . '"';\r
+-}\r
+-\r
+-sub pair_to_batch_line {\r
+-  my ($action, $pair) = @_;\r
+-\r
+-  # the tag should already be suitably encoded\r
+-\r
+-  return $action . $ENCPREFIX . $pair->{tag} .\r
+-    ' -- id:' . quote_for_xapian ($pair->{id})."\n";\r
+-}\r
+-\r
+-sub do_sync {\r
+-\r
+-  my %args = @_;\r
+-\r
+-  my $status = compute_status ();\r
+-  my ($A_action, $D_action);\r
+-\r
+-  if ($args{action} eq 'checkout') {\r
+-    $A_action = '-';\r
+-    $D_action = '+';\r
+-  } else {\r
+-    $A_action = '+';\r
+-    $D_action = '-';\r
+-  }\r
+-\r
+-  my $notmuch = spawn ({}, '|-', qw/notmuch tag --batch/)\r
+-    or die 'notmuch tag --batch';\r
+-\r
+-  foreach my $pair (@{$status->{added}}) {\r
+-    print $notmuch pair_to_batch_line ($A_action, $pair);\r
+-  }\r
+-\r
+-  foreach my $pair (@{$status->{deleted}}) {\r
+-    print $notmuch pair_to_batch_line ($D_action, $pair);\r
+-  }\r
+-\r
+-  unless (close $notmuch) {\r
+-    die "'notmuch tag --batch' exited with nonzero value\n";\r
+-  }\r
+-}\r
+-\r
+-\r
+-sub insist_committed {\r
+-\r
+-  my $status=compute_status();\r
+-  if ( !is_committed ($status) ) {\r
+-    print "Uncommitted changes to $TAGPREFIX* tags in notmuch\r
+-\r
+-For a summary of changes, run 'nmbug status'\r
+-To save your changes,     run 'nmbug commit' before merging/pull\r
+-To discard your changes,  run 'nmbug checkout'\r
+-";\r
+-    exit (1);\r
+-  }\r
+-\r
+-}\r
+-\r
+-\r
+-sub do_pull {\r
+-  my $remote = shift || 'origin';\r
+-  my $branch = shift || 'master';\r
+-\r
+-  git ( 'fetch', $remote);\r
+-\r
+-  do_merge ("$remote/$branch");\r
+-}\r
+-\r
+-\r
+-sub do_merge {\r
+-  my $commit = shift || '@{upstream}';\r
+-\r
+-  insist_committed ();\r
+-\r
+-  my $tempwork = tempdir ('/tmp/nmbug-merge.XXXXXX', CLEANUP => 1);\r
+-\r
+-  git ( { GIT_WORK_TREE => $tempwork }, 'checkout', '-f', 'HEAD');\r
+-\r
+-  git ( { GIT_WORK_TREE => $tempwork }, 'merge', $commit);\r
+-\r
+-  do_checkout ();\r
+-}\r
+-\r
+-\r
+-sub do_log {\r
+-  # we don't want output trapping here, because we want the pager.\r
+-  system ( 'git', "--git-dir=$NMBGIT", 'log', '--name-status', @_);\r
+-}\r
+-\r
+-\r
+-sub do_push {\r
+-  my $remote = shift || 'origin';\r
+-\r
+-  git ('push', $remote, 'master');\r
+-}\r
+-\r
+-\r
+-sub do_status {\r
+-  my $status = compute_status ();\r
+-\r
+-  my %output = ();\r
+-  foreach my $pair (@{$status->{added}}) {\r
+-    $output{$pair->{id}} ||= {};\r
+-    $output{$pair->{id}}{$pair->{tag}} = 'A'\r
+-  }\r
+-\r
+-  foreach my $pair (@{$status->{deleted}}) {\r
+-    $output{$pair->{id}} ||= {};\r
+-    $output{$pair->{id}}{$pair->{tag}} = 'D'\r
+-  }\r
+-\r
+-  foreach my $pair (@{$status->{missing}}) {\r
+-    $output{$pair->{id}} ||= {};\r
+-    $output{$pair->{id}}{$pair->{tag}} = 'U'\r
+-  }\r
+-\r
+-  if (is_unmerged ()) {\r
+-    foreach my $pair (diff_refs ('A')) {\r
+-      $output{$pair->{id}} ||= {};\r
+-      $output{$pair->{id}}{$pair->{tag}} ||= ' ';\r
+-      $output{$pair->{id}}{$pair->{tag}} .= 'a';\r
+-    }\r
+-\r
+-    foreach my $pair (diff_refs ('D')) {\r
+-      $output{$pair->{id}} ||= {};\r
+-      $output{$pair->{id}}{$pair->{tag}} ||= ' ';\r
+-      $output{$pair->{id}}{$pair->{tag}} .= 'd';\r
+-    }\r
+-  }\r
+-\r
+-  foreach my $id (sort keys %output) {\r
+-    foreach my $tag (sort keys %{$output{$id}}) {\r
+-      printf "%s\t%s\t%s\n", $output{$id}{$tag}, $id, $tag;\r
+-    }\r
+-  }\r
+-}\r
+-\r
+-\r
+-sub is_unmerged {\r
+-  my $commit = shift || '@{upstream}';\r
+-\r
+-  my ($fetch_head, $status) = git_with_status ('rev-parse', $commit);\r
+-  if ($status) {\r
+-    return 0;\r
+-  }\r
+-  my $base = git ( 'merge-base', 'HEAD', $commit);\r
+-\r
+-  return ($base ne $fetch_head);\r
+-\r
+-}\r
+-\r
+-sub compute_status {\r
+-  my %args = @_;\r
+-\r
+-  my @added;\r
+-  my @deleted;\r
+-  my @missing;\r
+-\r
+-  my $index = index_tags ();\r
+-\r
+-  my @maybe_deleted = diff_index ($index, 'D');\r
+-\r
+-  foreach my $pair (@maybe_deleted) {\r
+-\r
+-    my $id = $pair->{id};\r
+-\r
+-    my $fh = spawn ('-|', qw/notmuch search --output=files/,"id:$id")\r
+-      or die "searching for $id";\r
+-    if (!<$fh>) {\r
+-      push @missing, $pair;\r
+-    } else {\r
+-      push @deleted, $pair;\r
+-    }\r
+-    unless (close $fh) {\r
+-      die "'notmuch search --output=files id:$id' exited with nonzero value\n";\r
+-    }\r
+-  }\r
+-\r
+-\r
+-  @added = diff_index ($index, 'A');\r
+-\r
+-  unlink $index || die "unlink $index: $!";\r
+-\r
+-  return { added => [@added], deleted => [@deleted], missing => [@missing] };\r
+-}\r
+-\r
+-\r
+-sub diff_index {\r
+-  my $index = shift;\r
+-  my $filter = shift;\r
+-\r
+-  my $fh = git_pipe ({ GIT_INDEX_FILE => $index },\r
+-                qw/diff-index --cached/,\r
+-               "--diff-filter=$filter", qw/--name-only HEAD/ );\r
+-\r
+-  my @lines = unpack_diff_lines ($fh);\r
+-  unless (close $fh) {\r
+-    die "'git diff-index --cached --diff-filter=$filter --name-only HEAD' ",\r
+-      "exited with nonzero value\n";\r
+-  }\r
+-  return @lines;\r
+-}\r
+-\r
+-\r
+-sub diff_refs {\r
+-  my $filter = shift;\r
+-  my $ref1 = shift || 'HEAD';\r
+-  my $ref2 = shift || '@{upstream}';\r
+-\r
+-  my $fh= git_pipe ( 'diff', "--diff-filter=$filter", '--name-only',\r
+-               $ref1, $ref2);\r
+-\r
+-  my @lines = unpack_diff_lines ($fh);\r
+-  unless (close $fh) {\r
+-    die "'git diff --diff-filter=$filter --name-only $ref1 $ref2' ",\r
+-      "exited with nonzero value\n";\r
+-  }\r
+-  return @lines;\r
+-}\r
+-\r
+-\r
+-sub unpack_diff_lines {\r
+-  my $fh = shift;\r
+-\r
+-  my @found;\r
+-  while(<$fh>) {\r
+-    chomp ();\r
+-    my ($id,$tag) = m|tags/ ([^/]+) / ([^/]+) |x;\r
+-\r
+-    $id = decode_from_fs ($id);\r
+-    $tag = decode_from_fs ($tag);\r
+-\r
+-    push @found, { id => $id, tag => $tag };\r
+-  }\r
+-\r
+-  return @found;\r
+-}\r
+-\r
+-\r
+-sub encode_for_fs {\r
+-  my $str = shift;\r
+-\r
+-  $str =~ s/($MUST_ENCODE)/"$ESCAPE_CHAR".sprintf ("%02x",ord ($1))/ge;\r
+-  return $str;\r
+-}\r
+-\r
+-\r
+-sub decode_from_fs {\r
+-  my $str = shift;\r
+-\r
+-  $str =~ s/$ESCAPED_RX/ chr (hex ($1))/eg;\r
+-\r
+-  return $str;\r
+-\r
+-}\r
+-\r
+-\r
+-sub usage {\r
+-  pod2usage ();\r
+-  exit (1);\r
+-}\r
+-\r
+-\r
+-sub do_help {\r
+-  pod2usage ( -verbose => 2 );\r
+-  exit (0);\r
+-}\r
+-\r
+-__END__\r
+-\r
+-=head1 NAME\r
+-\r
+-nmbug - manage notmuch tags about notmuch\r
+-\r
+-=head1 SYNOPSIS\r
+-\r
+-nmbug subcommand [options]\r
+-\r
+-B<nmbug help> for more help\r
+-\r
+-=head1 OPTIONS\r
+-\r
+-=head2 Most common commands\r
+-\r
+-=over 8\r
+-\r
+-=item B<commit> [message]\r
+-\r
+-Commit appropriately prefixed tags from the notmuch database to\r
+-git. Any extra arguments are used (one per line) as a commit message.\r
+-\r
+-=item  B<push> [remote]\r
+-\r
+-push local nmbug git state to remote repo\r
+-\r
+-=item  B<pull> [remote] [branch]\r
+-\r
+-pull (merge) remote repo changes to notmuch. B<pull> is equivalent to\r
+-B<fetch> followed by B<merge>.  The default remote is C<origin>, and\r
+-the default branch is C<master>.\r
+-\r
+-=back\r
+-\r
+-=head2 Other Useful Commands\r
+-\r
+-=over 8\r
+-\r
+-=item B<clone> repository\r
+-\r
+-Create a local nmbug repository from a remote source.  This wraps\r
+-C<git clone>, adding some options to avoid creating a working tree\r
+-while preserving remote-tracking branches and upstreams.\r
+-\r
+-=item B<checkout>\r
+-\r
+-Update the notmuch database from git. This is mainly useful to discard\r
+-your changes in notmuch relative to git.\r
+-\r
+-=item B<fetch> [remote]\r
+-\r
+-Fetch changes from the remote repo (see merge to bring those changes\r
+-into notmuch).\r
+-\r
+-=item B<help> [subcommand]\r
+-\r
+-print help [for subcommand]\r
+-\r
+-=item B<log> [parameters]\r
+-\r
+-A simple wrapper for git log. After running C<nmbug fetch>, you can\r
+-inspect the changes with C<nmbug log HEAD..@{upstream}>\r
+-\r
+-=item B<merge> [commit]\r
+-\r
+-Merge changes from C<commit> into HEAD, and load the result into\r
+-notmuch.  The default commit is C<@{upstream}>.\r
+-\r
+-=item  B<status>\r
+-\r
+-Show pending updates in notmuch or git repo. See below for more\r
+-information about the output format.\r
+-\r
+-=back\r
+-\r
+-=head2 Less common commands\r
+-\r
+-=over 8\r
+-\r
+-=item B<archive>\r
+-\r
+-Dump a tar archive (using git archive) of the current nmbug tag set.\r
+-\r
+-=back\r
+-\r
+-=head1 STATUS FORMAT\r
+-\r
+-B<nmbug status> prints lines of the form\r
+-\r
+-   ng Message-Id tag\r
+-\r
+-where n is a single character representing notmuch database status\r
+-\r
+-=over 8\r
+-\r
+-=item B<A>\r
+-\r
+-Tag is present in notmuch database, but not committed to nmbug\r
+-(equivalently, tag has been deleted in nmbug repo, e.g. by a pull, but\r
+-not restored to notmuch database).\r
+-\r
+-=item B<D>\r
+-\r
+-Tag is present in nmbug repo, but not restored to notmuch database\r
+-(equivalently, tag has been deleted in notmuch)\r
+-\r
+-=item B<U>\r
+-\r
+-Message is unknown (missing from local notmuch database)\r
+-\r
+-=back\r
+-\r
+-The second character (if present) represents a difference between remote\r
+-git and local. Typically C<nmbug fetch> needs to be run to update this.\r
+-\r
+-=over 8\r
+-\r
+-\r
+-=item B<a>\r
+-\r
+-Tag is present in remote, but not in local git.\r
+-\r
+-\r
+-=item B<d>\r
+-\r
+-Tag is present in local git, but not in remote git.\r
+-\r
+-\r
+-=back\r
+-\r
+-=head1 DUMP FORMAT\r
+-\r
+-Each tag $tag for message with Message-Id $id is written to\r
+-an empty file\r
+-\r
+-      tags/encode($id)/encode($tag)\r
+-\r
+-The encoding preserves alphanumerics, and the characters "+-_@=.:,"\r
+-(not the quotes).  All other octets are replaced with '%' followed by\r
+-a two digit hex number.\r
+-\r
+-=head1 ENVIRONMENT\r
+-\r
+-B<NMBGIT> specifies the location of the git repository used by nmbug.\r
+-If not specified $HOME/.nmbug is used.\r
+-\r
+-B<NMBPREFIX> specifies the prefix in the notmuch database for tags of\r
+-interest to nmbug. If not specified 'notmuch::' is used.\r
++#!/usr/bin/env python\r
++# Copyright (c) 2011 David Bremner\r
++# License: same as notmuch\r
++\r
++"""\r
++Manage notmuch tags with Git\r
++\r
++Environment variables:\r
++\r
++* NMBGIT specifies the location of the git repository used by nmbug.\r
++  If not specified $HOME/.nmbug is used.\r
++* NMBPREFIX specifies the prefix in the notmuch database for tags of\r
++  interest to nmbug. If not specified 'notmuch::' is used.\r
++"""\r
++\r
++import codecs as _codecs\r
++import collections as _collections\r
++import inspect as _inspect\r
++import logging as _logging\r
++import os as _os\r
++import re as _re\r
++import shutil as _shutil\r
++import subprocess as _subprocess\r
++import sys as _sys\r
++import tempfile as _tempfile\r
++import textwrap as _textwrap\r
++try:  # Python 3\r
++    from urllib.parse import quote as _quote\r
++    from urllib.parse import unquote as _unquote\r
++except ImportError:  # Python 2\r
++    from urllib import quote as _quote\r
++    from urllib import unquote as _unquote\r
++\r
++\r
++__version__ = '0.2'\r
++\r
++_LOG = _logging.getLogger('nmbug')\r
++_LOG.setLevel(_logging.ERROR)\r
++_LOG.addHandler(_logging.StreamHandler())\r
++\r
++NMBGIT = _os.path.expanduser(\r
++    _os.getenv('NMBGIT', _os.path.join('~', '.nmbug')))\r
++_NMBGIT = _os.path.join(NMBGIT, '.git')\r
++if _os.path.isdir(_NMBGIT):\r
++    NMBGIT = _NMBGIT\r
++\r
++TAG_PREFIX = _os.getenv('NMBPREFIX', 'notmuch::')\r
++_HEX_ESCAPE_REGEX = _re.compile('%[0-9A-F]{2}')\r
++_TAG_FILE_REGEX = _re.compile('tags/(?P<id>[^/]*)/(?P<tag>[^/]*)')\r
++\r
++# magic hash for Git (git hash-object -t blob /dev/null)\r
++_EMPTYBLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'\r
++\r
++\r
++try:\r
++    getattr(_tempfile, 'TemporaryDirectory')\r
++except AttributeError:  # Python < 3.2\r
++    class _TemporaryDirectory(object):\r
++        """\r
++        Fallback context manager for Python < 3.2\r
++\r
++        See PEP 343 for details on context managers [1].\r
++\r
++        [1]: http://legacy.python.org/dev/peps/pep-0343/\r
++        """\r
++        def __init__(self, **kwargs):\r
++            self.name = _tempfile.mkdtemp(**kwargs)\r
++\r
++        def __enter__(self):\r
++            return self.name\r
++\r
++        def __exit__(self, type, value, traceback):\r
++            _shutil.rmtree(self.name)\r
++\r
++\r
++    _tempfile.TemporaryDirectory = _TemporaryDirectory\r
++\r
++\r
++def _hex_quote(string, safe='+@=:,'):\r
++    """\r
++    quote('abc def') -> 'abc%20def'\r
++\r
++    Wrap urllib.parse.quote with additional safe characters (in\r
++    addition to letters, digits, and '_.-') and lowercase hex digits\r
++    (e.g. '%3a' instead of '%3A').\r
++    """\r
++    uppercase_escapes = _quote(string, safe)\r
++    return _HEX_ESCAPE_REGEX.sub(\r
++        lambda match: match.group(0).lower(),\r
++        uppercase_escapes)\r
++\r
++\r
++_ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,')  # quote ':'\r
++\r
++\r
++def _xapian_quote(string):\r
++    """\r
++    Quote a string for Xapian's QueryParser.\r
++\r
++    Xapian uses double-quotes for quoting strings.  You can escape\r
++    internal quotes by repeating them [1,2,3].\r
++\r
++    [1]: http://trac.xapian.org/ticket/128#comment:2\r
++    [2]: http://trac.xapian.org/ticket/128#comment:17\r
++    [3]: http://trac.xapian.org/changeset/13823/svn\r
++    """\r
++    return '"{0}"'.format(string.replace('"', '""'))\r
++\r
++\r
++def _xapian_unquote(string):\r
++    """\r
++    Unquote a Xapian-quoted string.\r
++    """\r
++    if string.startswith('"') and string.endswith('"'):\r
++        return string[1:-1].replace('""', '"')\r
++    return string\r
++\r
++\r
++class SubprocessError(RuntimeError):\r
++    "A subprocess exited with a nonzero status"\r
++    def __init__(self, args, status, stdout=None, stderr=None):\r
++        self.status = status\r
++        self.stdout = stdout\r
++        self.stderr = stderr\r
++        msg = '{args} exited with {status}'.format(args=args, status=status)\r
++        if stderr:\r
++            msg = '{msg}: {stderr}'.format(msg=msg, stderr=stderr)\r
++        super(SubprocessError, self).__init__(msg)\r
++\r
++\r
++class _SubprocessContextManager(object):\r
++    "PEP 343 context manager for subprocesses."\r
++    def __init__(self, process, args):\r
++        self._process = process\r
++        self._args = args\r
++\r
++    def __enter__(self):\r
++        return self._process\r
++\r
++    def __exit__(self, type, value, traceback):\r
++        for name in ['stdin', 'stdout', 'stderr']:\r
++            stream = getattr(self._process, name)\r
++            if stream:\r
++                stream.close()\r
++                setattr(self._process, name, None)\r
++        status = self._process.wait()\r
++        _LOG.debug('collect {args} with status {status}'.format(\r
++            args=self._args, status=status))\r
++        if status:\r
++            raise SubprocessError(args=self._args, status=status)\r
++\r
++\r
++def _spawn(args, input=None, additional_env=None, wait=False, stdin=None,\r
++           stdout=None, stderr=None, encoding=_sys.stdout.encoding, **kwargs):\r
++    """Spawn a subprocess, and optionally wait for it to finish.\r
++\r
++    This wrapper around subprocess.Popen has two modes, depending on\r
++    the truthiness of 'wait'.  If 'wait' is true, we use p.communicate\r
++    internally to write 'input' to the subprocess's stdin and read\r
++    from it's stdout/stderr.  If 'wait' is False, we return a\r
++    _SubprocessContextManager instance for fancier handling\r
++    (e.g. piping between processes).\r
++\r
++    For 'wait' calls when you want to write to the subprocess's stdin,\r
++    you only need to set 'input' to your content.  When 'input' is not\r
++    None but 'stdin' is, we'll automatically set 'stdin' to PIPE\r
++    before calling Popen.  This avoids having the subprocess\r
++    accidentally inherit the launching process's stdin.\r
++    """\r
++    _LOG.debug('spawn {args} (additional env. var.: {env})'.format(\r
++        args=args, env=additional_env))\r
++    if not stdin and input is not None:\r
++        stdin = _subprocess.PIPE\r
++    if additional_env:\r
++        if not kwargs.get('env'):\r
++            kwargs['env'] = dict(_os.environ)\r
++        kwargs['env'].update(additional_env)\r
++    p = _subprocess.Popen(\r
++        args, stdin=stdin, stdout=stdout, stderr=stderr, **kwargs)\r
++    if wait:\r
++        if hasattr(input, 'encode'):\r
++            input = input.encode(encoding)\r
++        stdout, stderr = p.communicate(input=input)\r
++        status = p.wait()\r
++        _LOG.debug('collect {args} with status {status}'.format(\r
++            args=args, status=status))\r
++        if stdout is not None:\r
++            stdout = stdout.decode(encoding)\r
++        if stderr is not None:\r
++            stderr = stderr.decode(encoding)\r
++        if status:\r
++            raise SubprocessError(\r
++                args=args, status=status, stdout=stdout, stderr=stderr)\r
++        return (status, stdout, stderr)\r
++    if p.stdin and not stdin:\r
++        p.stdin.close()\r
++        p.stdin = None\r
++    if p.stdin:\r
++        p.stdin = _codecs.getwriter(encoding=encoding)(stream=p.stdin)\r
++    stream_reader = _codecs.getreader(encoding=encoding)\r
++    if p.stdout:\r
++        p.stdout = stream_reader(stream=p.stdout)\r
++    if p.stderr:\r
++        p.stderr = stream_reader(stream=p.stderr)\r
++    return _SubprocessContextManager(args=args, process=p)\r
++\r
++\r
++def _git(args, **kwargs):\r
++    args = ['git', '--git-dir', NMBGIT] + list(args)\r
++    return _spawn(args=args, **kwargs)\r
++\r
++\r
++def _get_current_branch():\r
++    """Get the name of the current branch.\r
++\r
++    Return 'None' if we're not on a branch.\r
++    """\r
++    try:\r
++        status, branch, stderr = _git(\r
++            args=['symbolic-ref', '--short', 'HEAD'],\r
++            stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)\r
++    except SubprocessError as e:\r
++        if 'not a symbolic ref' in e:\r
++            return None\r
++        raise\r
++    return branch.strip()\r
++\r
++\r
++def _get_remote():\r
++    "Get the default remote for the current branch."\r
++    local_branch = _get_current_branch()\r
++    status, remote, stderr = _git(\r
++        args=['config', 'branch.{0}.remote'.format(local_branch)],\r
++        stdout=_subprocess.PIPE, wait=True)\r
++    return remote.strip()\r
++\r
++\r
++def get_tags(prefix=None):\r
++    "Get a list of tags with a given prefix"\r
++    if prefix is None:\r
++        prefix = TAG_PREFIX\r
++    status, stdout, stderr = _spawn(\r
++        args=['notmuch', 'search', '--output=tags', '*'],\r
++        stdout=_subprocess.PIPE, wait=True)\r
++    return [tag for tag in stdout.splitlines() if tag.startswith(prefix)]\r
++\r
++\r
++def archive(treeish='HEAD', args=()):\r
++    """\r
++    Dump a tar archive of the current nmbug tag set.\r
++\r
++    Using 'git archive'.\r
++\r
++    Each tag $tag for message with Message-Id $id is written to\r
++    an empty file\r
++\r
++      tags/encode($id)/encode($tag)\r
++\r
++    The encoding preserves alphanumerics, and the characters\r
++    "+-_@=.:," (not the quotes).  All other octets are replaced with\r
++    '%' followed by a two digit hex number.\r
++    """\r
++    _git(args=['archive', treeish] + list(args), wait=True)\r
++\r
++\r
++def clone(repository):\r
++    """\r
++    Create a local nmbug repository from a remote source.\r
++\r
++    This wraps 'git clone', adding some options to avoid creating a\r
++    working tree while preserving remote-tracking branches and\r
++    upstreams.\r
++    """\r
++    with _tempfile.TemporaryDirectory(prefix='nmbug-clone.') as workdir:\r
++        _spawn(\r
++            args=[\r
++                'git', 'clone', '--no-checkout', '--separate-git-dir', NMBGIT,\r
++                repository, workdir],\r
++            wait=True)\r
++    _git(args=['config', '--unset', 'core.worktree'], wait=True)\r
++    _git(args=['config', 'core.bare', 'true'], wait=True)\r
++\r
++\r
++def _is_committed(status):\r
++    return len(status['added']) + len(status['deleted']) == 0\r
++\r
++\r
++def commit(treeish='HEAD', message=None):\r
++    """\r
++    Commit prefix-matching tags from the notmuch database to Git.\r
++    """\r
++    status = get_status()\r
++\r
++    if _is_committed(status=status):\r
++        _LOG.warning('Nothing to commit')\r
++        return\r
++\r
++    index = _read_tree(treeish=treeish)\r
++    _update_index(index=index, status=status)\r
++    status, tree, stderr = _git(\r
++        args=['write-tree'],\r
++        stdout=_subprocess.PIPE,\r
++        additional_env={'GIT_INDEX_FILE': index},\r
++        wait=True)\r
++    status, parent, stderr = _git(\r
++        args=['rev-parse', treeish],\r
++        stdout=_subprocess.PIPE,\r
++        wait=True)\r
++    status, commit, stderr = _git(\r
++        args=['commit-tree', tree.strip(), '-p', parent.strip()],\r
++        input=message,\r
++        stdout=_subprocess.PIPE,\r
++        wait=True)\r
++    status, commit, stderr = _git(\r
++        args=['update-ref', treeish, commit.strip()],\r
++        stdout=_subprocess.PIPE,\r
++        wait=True)\r
++    _os.remove(index)\r
++\r
++\r
++def _read_tree(treeish):\r
++    "Create and index file using 'treeish'"\r
++    path = _os.path.join(NMBGIT, 'nmbug.index')\r
++    _git(\r
++        args=['read-tree', '--empty'],\r
++        additional_env={'GIT_INDEX_FILE': path}, wait=True)\r
++    _git(\r
++        args=['read-tree', treeish],\r
++        additional_env={'GIT_INDEX_FILE': path}, wait=True)\r
++    return path\r
++\r
++\r
++def _update_index(index, status):\r
++    with _git(\r
++            args=['update-index', '--index-info'],\r
++            stdin=_subprocess.PIPE,\r
++            additional_env={'GIT_INDEX_FILE': index}) as p:\r
++        for id, tags in status['deleted'].items():\r
++            for line in _index_tags_for_message(id=id, status='D', tags=tags):\r
++                p.stdin.write(line)\r
++        for id, tags in status['added'].items():\r
++            for line in _index_tags_for_message(id=id, status='A', tags=tags):\r
++                p.stdin.write(line)\r
++\r
++\r
++def fetch(remote=None):\r
++    """\r
++    Fetch changes from the remote repository\r
++\r
++    See 'merge' to bring those changes into notmuch.\r
++    """\r
++    args = ['fetch']\r
++    if remote:\r
++        args.append(remote)\r
++    _git(args=args, wait=True)\r
++\r
++\r
++def checkout():\r
++    """\r
++    Update the notmuch database from Git.\r
++\r
++    This is mainly useful to discard your changes in notmuch relative\r
++    to Git.\r
++    """\r
++    status = get_status()\r
++    with _spawn(\r
++            args=['notmuch', 'tag', '--batch'], stdin=_subprocess.PIPE) as p:\r
++        for id, tags in status['added'].items():\r
++            p.stdin.write(_batch_line(action='-', id=id, tags=tags))\r
++        for id, tags in status['deleted'].items():\r
++            p.stdin.write(_batch_line(action='+', id=id, tags=tags))\r
++\r
++\r
++def _batch_line(action, id, tags):\r
++    """\r
++    'notmuch tag --batch' line for adding/removing tags.\r
++\r
++    Set 'action' to '-' to remove a tag or '+' to add the tags to a\r
++    given message id.\r
++    """\r
++    tag_string = ' '.join(\r
++        '{action}{prefix}{tag}'.format(\r
++            action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))\r
++        for tag in tags)\r
++    line = '{tags} -- id:{id}\n'.format(\r
++        tags=tag_string, id=_xapian_quote(string=id))\r
++    return line\r
++\r
++\r
++def _insist_committed():\r
++    "Die if the the notmuch tags don't match the current HEAD."\r
++    status = get_status()\r
++    if not _is_committed(status=status):\r
++        _LOG.error('\n'.join([\r
++            'Uncommitted changes to {prefix}* tags in notmuch',\r
++            '',\r
++            "For a summary of changes, run 'nmbug status'",\r
++            "To save your changes,     run 'nmbug commit' before merging/pull",\r
++            "To discard your changes,  run 'nmbug checkout'",\r
++            ]).format(prefix=TAG_PREFIX))\r
++        _sys.exit(1)\r
++\r
++\r
++def pull(repository=None, refspecs=None):\r
++    """\r
++    Pull (merge) remote repository changes to notmuch.\r
++\r
++    'pull' is equivalent to 'fetch' followed by 'merge'.  We use the\r
++    Git-configured repository for your current branch\r
++    (branch.<name>.repository, likely 'origin', and\r
++    branch.<name>.merge, likely 'master').\r
++    """\r
++    _insist_committed()\r
++    if refspecs and not repository:\r
++        repository = _get_remote()\r
++    args = ['pull']\r
++    if repository:\r
++        args.append(repository)\r
++    if refspecs:\r
++        args.extend(refspecs)\r
++    with _tempfile.TemporaryDirectory(prefix='nmbug-merge.') as workdir:\r
++        _git(args=args, additional_env={'GIT_WORK_TREE': workdir}, wait=True)\r
++    checkout()\r
++\r
++\r
++def merge(reference='@{upstream}'):\r
++    """\r
++    Merge changes from 'reference' into HEAD and load the result into notmuch.\r
++\r
++    The default reference is '@{upstream}'.\r
++    """\r
++    _insist_committed()\r
++    with _tempfile.TemporaryDirectory(prefix='nmbug-merge.') as workdir:\r
++        _git(\r
++            args=['merge', reference],\r
++            additional_env={'GIT_WORK_TREE': workdir},\r
++            wait=True)\r
++    checkout()\r
++\r
++\r
++def log(args=()):\r
++    """\r
++    A simple wrapper for 'git log'.\r
++\r
++    After running 'nmbug fetch', you can inspect the changes with\r
++    'nmbug log HEAD..@{upstream}'.\r
++    """\r
++    # we don't want output trapping here, because we want the pager.\r
++    args = ['git', '--git-dir', NMBGIT, 'log', '--name-status'] + list(args)\r
++    _LOG.debug('exec {args}'.format(args=args))\r
++    _os.execvp('git', args)\r
++\r
++\r
++def push(repository=None, refspecs=None):\r
++    "Push the local nmbug Git state to a remote repository."\r
++    if refspecs and not repository:\r
++        repository = _get_remote()\r
++    args = ['push']\r
++    if repository:\r
++        args.append(repository)\r
++    if refspecs:\r
++        args.extend(refspecs)\r
++    _git(args=args, wait=True)\r
++\r
++\r
++def status():\r
++    """\r
++    Show pending updates in notmuch or git repo.\r
++\r
++    Prints lines of the form\r
++\r
++      ng Message-Id tag\r
++\r
++    where n is a single character representing notmuch database status\r
++\r
++    * A\r
++\r
++      Tag is present in notmuch database, but not committed to nmbug\r
++      (equivalently, tag has been deleted in nmbug repo, e.g. by a\r
++      pull, but not restored to notmuch database).\r
++\r
++    * D\r
++\r
++      Tag is present in nmbug repo, but not restored to notmuch\r
++      database (equivalently, tag has been deleted in notmuch).\r
++\r
++    * U\r
++\r
++      Message is unknown (missing from local notmuch database).\r
++\r
++    The second character (if present) represents a difference between\r
++    local and upstream branches. Typically 'nmbug fetch' needs to be\r
++    run to update this.\r
++\r
++    * a\r
++\r
++      Tag is present in upstream, but not in the local Git branch.\r
++\r
++    * d\r
++\r
++      Tag is present in local Git branch, but not upstream.\r
++    """\r
++    status = get_status()\r
++    output = _collections.defaultdict(\r
++        lambda : _collections.defaultdict( # {tag: status_string}\r
++            lambda : ' '))  # default local status\r
++    for id, tags in status['added'].items():\r
++        for tag in tags:\r
++            output[id][tag] = 'A'\r
++    for id, tags in status['deleted'].items():\r
++        for tag in tags:\r
++            output[id][tag] = 'D'\r
++    for id, tags in status['missing'].items():\r
++        for tag in tags:\r
++            output[id][tag] = 'U'\r
++    if _is_unmerged():\r
++        for id, tag in _diff_refs(filter='A'):\r
++            output[id][tag] += 'a'\r
++        for id, tag in _diff_refs(filter='D'):\r
++            output[id][tag] += 'd'\r
++    for id, tag_status in sorted(output.items()):\r
++        for tag, status in sorted(tag_status.items()):\r
++            print('{status}\t{id}\t{tag}'.format(\r
++                status=status, id=id, tag=tag))\r
++\r
++\r
++def _is_unmerged(ref='@{upstream}'):\r
++    try:\r
++        status, fetch_head, stderr = _git(\r
++            args=['rev-parse', ref],\r
++            stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)\r
++    except SubprocessError as e:\r
++        if 'No upstream configured' in e.stderr:\r
++            return\r
++        raise\r
++    status, base, stderr = _git(\r
++        args=['merge-base', 'HEAD', ref],\r
++        stdout=_subprocess.PIPE, wait=True)\r
++    return base != fetch_head\r
++\r
++\r
++def get_status():\r
++    status = {\r
++        'deleted': {},\r
++        'missing': {},\r
++        }\r
++    index = _index_tags()\r
++    maybe_deleted = _diff_index(index=index, filter='D')\r
++    for id, tags in maybe_deleted.items():\r
++        _, stdout, stderr = _spawn(\r
++            args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)],\r
++            stdout=_subprocess.PIPE,\r
++            wait=True)\r
++        if stdout:\r
++            status['deleted'][id] = tags\r
++        else:\r
++            status['missing'][id] = tags\r
++    status['added'] = _diff_index(index=index, filter='A')\r
++    _os.remove(index)\r
++    return status\r
++\r
++\r
++def _index_tags():\r
++    "Write notmuch tags to the nmbug.index"\r
++    path = _os.path.join(NMBGIT, 'nmbug.index')\r
++    query = ' '.join('tag:"{tag}"'.format(tag=tag) for tag in get_tags())\r
++    prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)\r
++    _git(\r
++        args=['read-tree', '--empty'],\r
++        additional_env={'GIT_INDEX_FILE': path}, wait=True)\r
++    with _spawn(\r
++            args=['notmuch', 'dump', '--format=batch-tag', '--', query],\r
++            stdout=_subprocess.PIPE) as notmuch:\r
++        with _git(\r
++                args=['update-index', '--index-info'],\r
++                stdin=_subprocess.PIPE,\r
++                additional_env={'GIT_INDEX_FILE': path}) as git:\r
++            for line in notmuch.stdout:\r
++                tags_string, id = [_.strip() for _ in line.split(' -- id:')]\r
++                tags = [\r
++                    _unquote(tag[len(prefix):])\r
++                    for tag in tags_string.split()\r
++                    if tag.startswith(prefix)]\r
++                id = _xapian_unquote(string=id)\r
++                for line in _index_tags_for_message(\r
++                        id=id, status='A', tags=tags):\r
++                    git.stdin.write(line)\r
++    return path\r
++\r
++\r
++def _index_tags_for_message(id, status, tags):\r
++    """\r
++    Update the Git index to either create or delete an empty file.\r
++\r
++    Neither 'id' nor the tags in 'tags' should be encoded/escaped.\r
++    """\r
++    mode = '100644'\r
++    hash = _EMPTYBLOB\r
++\r
++    if status == 'D':\r
++        mode = '0'\r
++        hash = '0000000000000000000000000000000000000000'\r
++\r
++    for tag in tags:\r
++        path = 'tags/{id}/{tag}'.format(\r
++            id=_hex_quote(string=id), tag=_hex_quote(string=tag))\r
++        yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)\r
++\r
++\r
++def _diff_index(index, filter):\r
++    """Get an {id: {tag, ...}} dict for a given filter.\r
++\r
++    For example, use 'A' to find added tags, and 'D' to find deleted tags.\r
++    """\r
++    s = _collections.defaultdict(set)\r
++    with _git(\r
++            args=[\r
++                'diff-index', '--cached', '--diff-filter', filter,\r
++                '--name-only', 'HEAD'],\r
++            additional_env={'GIT_INDEX_FILE': index},\r
++            stdout=_subprocess.PIPE) as p:\r
++        # Once we drop Python < 3.3, we can use 'yield from' here\r
++        for id, tag in _unpack_diff_lines(stream=p.stdout):\r
++            s[id].add(tag)\r
++    return s\r
++\r
++\r
++def _diff_refs(filter, a='HEAD', b='@{upstream}'):\r
++    with _git(\r
++            args=['diff', '--diff-filter', filter, '--name-only', a, b],\r
++            stdout=_subprocess.PIPE) as p:\r
++        # Once we drop Python < 3.3, we can use 'yield from' here\r
++        for id, tag in _unpack_diff_lines(stream=p.stdout):\r
++            yield id, tag\r
++\r
++\r
++def _unpack_diff_lines(stream):\r
++    "Iterate through (id, tag) tuples in a diff stream"\r
++    for line in stream:\r
++        match = _TAG_FILE_REGEX.match(line.strip())\r
++        if not match:\r
++            raise ValueError(\r
++                'Invalid line in diff: {!r}'.format(line.strip()))\r
++        id = _unquote(match.group('id'))\r
++        tag = _unquote(match.group('tag'))\r
++        yield (id, tag)\r
++\r
++\r
++if __name__ == '__main__':\r
++    import argparse\r
++\r
++    parser = argparse.ArgumentParser(\r
++        description=__doc__.strip(),\r
++        formatter_class=argparse.RawDescriptionHelpFormatter)\r
++    parser.add_argument(\r
++        '-v', '--version', action='version',\r
++        version='%(prog)s {}'.format(__version__))\r
++    parser.add_argument(\r
++        '-l', '--log-level',\r
++        choices=['critical', 'error', 'warning', 'info', 'debug'],\r
++        help='Log verbosity.  Defaults to {!r}.'.format(\r
++            _logging.getLevelName(_LOG.level).lower()))\r
++\r
++    subparsers = parser.add_subparsers(title='commands')\r
++    for command in [\r
++            'archive',\r
++            'checkout',\r
++            'clone',\r
++            'commit',\r
++            'fetch',\r
++            'log',\r
++            'merge',\r
++            'pull',\r
++            'push',\r
++            'status',\r
++            ]:\r
++        func = locals()[command]\r
++        doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')\r
++        subparser = subparsers.add_parser(\r
++            command,\r
++            help=doc.splitlines()[0],\r
++            description=doc,\r
++            formatter_class=argparse.RawDescriptionHelpFormatter)\r
++        subparser.set_defaults(func=func)\r
++        if command == 'archive':\r
++            subparser.add_argument(\r
++                'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',\r
++                help=(\r
++                    'The tree or commit to produce an archive for.  Defaults '\r
++                    "to 'HEAD'."))\r
++            subparser.add_argument(\r
++                'args', metavar='ARG', nargs='*',\r
++                help=(\r
++                    "Argument passed through to 'git archive'.  Set anything "\r
++                    'before <tree-ish>, see git-archive(1) for details.'))\r
++        elif command == 'clone':\r
++            subparser.add_argument(\r
++                'repository',\r
++                help=(\r
++                    'The (possibly remote) repository to clone from.  See the '\r
++                    'URLS section of git-clone(1) for more information on '\r
++                    'specifying repositories.'))\r
++        elif command == 'commit':\r
++            subparser.add_argument(\r
++                'message', metavar='MESSAGE', default='', nargs='?',\r
++                help='Text for the commit message.')\r
++        elif command == 'fetch':\r
++            subparser.add_argument(\r
++                'remote', metavar='REMOTE', nargs='?',\r
++                help=(\r
++                    'Override the default configured in branch.<name>.remote '\r
++                    'to fetch from a particular remote repository (e.g. '\r
++                    "'origin')."))\r
++        elif command == 'log':\r
++            subparser.add_argument(\r
++                'args', metavar='ARG', nargs='*',\r
++                help="Additional argument passed through to 'git log'.")\r
++        elif command == 'merge':\r
++            subparser.add_argument(\r
++                'reference', metavar='REFERENCE', default='@{upstream}',\r
++                nargs='?',\r
++                help=(\r
++                    'Reference, usually other branch heads, to merge into '\r
++                    "our branch.  Defaults to '@{upstream}'."))\r
++        elif command == 'pull':\r
++            subparser.add_argument(\r
++                'repository', metavar='REPOSITORY', default=None, nargs='?',\r
++                help=(\r
++                    'The "remote" repository that is the source of the pull.  '\r
++                    'This parameter can be either a URL (see the section GIT '\r
++                    'URLS in git-pull(1)) or the name of a remote (see the '\r
++                    'section REMOTES in git-pull(1)).'))\r
++            subparser.add_argument(\r
++                'refspecs', metavar='REFSPEC', default=None, nargs='*',\r
++                help=(\r
++                    'Refspec (usually a branch name) to fetch and merge.  See '\r
++                    'the <refspec> entry in the OPTIONS section of '\r
++                    'git-pull(1) for other possibilities.'))\r
++        elif command == 'push':\r
++            subparser.add_argument(\r
++               'repository', metavar='REPOSITORY', default=None, nargs='?',\r
++                help=(\r
++                    'The "remote" repository that is the destination of the '\r
++                    'push.  This parameter can be either a URL (see the '\r
++                    'section GIT URLS in git-push(1)) or the name of a remote '\r
++                    '(see the section REMOTES in git-push(1)).'))\r
++            subparser.add_argument(\r
++                'refspecs', metavar='REFSPEC', default=None, nargs='*',\r
++                help=(\r
++                    'Refspec (usually a branch name) to push.  See '\r
++                    'the <refspec> entry in the OPTIONS section of '\r
++                    'git-push(1) for other possibilities.'))\r
++\r
++    args = parser.parse_args()\r
++\r
++    if args.log_level:\r
++        level = getattr(_logging, args.log_level.upper())\r
++        _LOG.setLevel(level)\r
++\r
++    if not getattr(args, 'func', None):\r
++        parser.print_usage()\r
++        _sys.exit(1)\r
++\r
++    arg_names, varargs, varkw = _inspect.getargs(args.func.__code__)\r
++    kwargs = {key: getattr(args, key) for key in arg_names if key in args}\r
++    args.func(**kwargs)\r
+-- \r
+1.9.1.353.gc66d89d\r
+\r