[PATCH v5] nmbug: Translate to Python
authorW. Trevor King <wking@tremily.us>
Wed, 24 Sep 2014 15:33:01 +0000 (08:33 +1700)
committerW. Trevor King <wking@tremily.us>
Fri, 7 Nov 2014 18:04:43 +0000 (10:04 -0800)
d3/22a0dc8ca306e26dff70120049d46a2b6b4f58 [new file with mode: 0644]

diff --git a/d3/22a0dc8ca306e26dff70120049d46a2b6b4f58 b/d3/22a0dc8ca306e26dff70120049d46a2b6b4f58
new file mode 100644 (file)
index 0000000..885b074
--- /dev/null
@@ -0,0 +1,1699 @@
+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 1B602431FB6\r
+       for <notmuch@notmuchmail.org>; Wed, 24 Sep 2014 08:36:34 -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 oqzfA7ZfY+wI for <notmuch@notmuchmail.org>;\r
+       Wed, 24 Sep 2014 08:36:26 -0700 (PDT)\r
+Received: from resqmta-po-03v.sys.comcast.net (resqmta-po-03v.sys.comcast.net\r
+       [96.114.154.162])\r
+       (using TLSv1 with cipher DHE-RSA-AES128-SHA (128/128 bits))\r
+       (No client certificate requested)\r
+       by olra.theworths.org (Postfix) with ESMTPS id F4139431FAF\r
+       for <notmuch@notmuchmail.org>; Wed, 24 Sep 2014 08:36:25 -0700 (PDT)\r
+Received: from resomta-po-12v.sys.comcast.net ([96.114.154.236])\r
+       by resqmta-po-03v.sys.comcast.net with comcast\r
+       id v3aj1o00J56HXL0013cQi6; Wed, 24 Sep 2014 15:36:24 +0000\r
+Received: from odin.tremily.us ([24.18.63.50])\r
+       by resomta-po-12v.sys.comcast.net with comcast\r
+       id v3cM1o00L152l3L013cNdZ; Wed, 24 Sep 2014 15:36:23 +0000\r
+Received: from mjolnir.tremily.us (unknown [192.168.0.150])\r
+       by odin.tremily.us (Postfix) with ESMTPS id 5261C13AAB07;\r
+       Wed, 24 Sep 2014 08:36:21 -0700 (PDT)\r
+Received: (nullmailer pid 3084 invoked by uid 1000);\r
+       Wed, 24 Sep 2014 15:33:02 -0000\r
+From: "W. Trevor King" <wking@tremily.us>\r
+To: notmuch@notmuchmail.org\r
+Subject: [PATCH v5] nmbug: Translate to Python\r
+Date: Wed, 24 Sep 2014 08:33:01 -0700\r
+Message-Id:\r
+ <d44bb6ad59ee0a30ac4a8d2e9fe50e3b98d1c408.1411572592.git.wking@tremily.us>\r
+X-Mailer: git-send-email 2.1.0.60.g85f0837\r
+DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=comcast.net;\r
+       s=q20140121; t=1411572984;\r
+       bh=eb+6sI+rg6mXk3F+BLSvQAl8Sh8a4fkTnhTFRUDxjM4=;\r
+       h=Received:Received:Received:Received:From:To:Subject:Date:\r
+       Message-Id;\r
+       b=cFtL5MX9dkSxvuWcfN7AjEdZEUw8x2qQsfkwj9+BEREe0GKW9yTHiXgIv5mopaZ31\r
+       0UXNieZ9o5mU9qWwJS9yfrH/vcmhDhZDMAYGdmBUcQMfv6Is9im9ZQkf62HM7qsQh6\r
+       f5en4oyIU+p7zBQwc3xbJ10hEbf2Kf7hKmAeaM7V38Y6ejdbQR/2a7Pn+cK6kfLuKK\r
+       FbMTb00GXlClkWD/Qnb5KY5jFV2Q+PnRys2Pum3VWWPpyJjNtUE1dRCGFqjtMBmOYq\r
+       6UsU0R63IXJ5xSHGJhrfy52ST+HmaaZEuw5yiaiYyfgS0bIvjG9Ic/M5FkyHW5pP3h\r
+       sMorZgsCk0nng==\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: Wed, 24 Sep 2014 15:36:34 -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
+* 'nmbug commit' now only uses a single argument for the optional\r
+  commit-message text.  I wanted to expose more of the underlying 'git\r
+  commit' UI, since I personally like to write my commit messages in\r
+  an editor with the notes added by 'git commit -v' to jog my memory.\r
+  Unfortunately, we're using 'git commit-tree' instead of 'git\r
+  commit', and commit-tree is too low-level for editor-launching.  I'd\r
+  be interested in rewriting commit() to use 'git commit', but that\r
+  seemed like it was outside the scope of this rewrite.  So I'm not\r
+  supporting all of Git's commit syntax in this patch, but I can at\r
+  least match 'git commit -m MESSAGE' in requiring command-line commit\r
+  messages to be a single argument.\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
+* 'nmbug merge' and 'pull' no longer checkout HEAD before running\r
+  their command, because blindly clobbering the index seems overly\r
+  risky.\r
+\r
+* In order to avoid creating a dirty index, 'nmbug commit' now uses\r
+  the default index (instead of nmbug.index) for composing the commit.\r
+  That way the index matches the committed tree.  To avoid leaving a\r
+  broken index after a failed commit, I've wrapped the whole thing in\r
+  a try/except block that resets the index to match the pre-commit\r
+  treeish on errors.  That means that 'nmbug commit' will ignore\r
+  anything you've cached in the index via direct Git calls, and you'll\r
+  either end up with an index matching your notmuch tags and the new\r
+  HEAD (after a successful commit) or an index matching the original\r
+  HEAD (after a failed commit).\r
+---\r
+Changes since v4 [1]:\r
+\r
+* Use locale.getpreferredencoding() instead of sys.stdout.encoding to\r
+  guess the encoding for the input/output streams of spawned\r
+  processes.  In Python 2, sys.stdout.encoding is None when stdout\r
+  isn't a TTY, so we need to avoid it if we want to redirect logs to\r
+  files.\r
+* Drop _read_tree(), since wrapping two Git calls isn't worth the\r
+  trouble.\r
+* Use the default index (instead of nmbug.index) in commit().  Details\r
+  in the final list entry of the commit message.  This will make it\r
+  harder to commit non-HEAD branches (because we're clobbering the\r
+  default index), but I don't see a need to do that anyway (and the\r
+  nmbug UI has never supported it).\r
+\r
+Cheers,\r
+Trevor\r
+\r
+[1]: id:e630b6763e9d0771718afee41ea15b29bb4a1de8.1409935538.git.wking@tremily.us\r
+     http://article.gmane.org/gmane.mail.notmuch.general/19007\r
+\r
+ devel/nmbug/nmbug | 1515 ++++++++++++++++++++++++++++-------------------------\r
+ 1 file changed, 807 insertions(+), 708 deletions(-)\r
+ rewrite devel/nmbug/nmbug (98%)\r
+\r
+diff --git a/devel/nmbug/nmbug b/devel/nmbug/nmbug\r
+dissimilarity index 98%\r
+index 998ee6b..9402ead 100755\r
+--- a/devel/nmbug/nmbug\r
++++ b/devel/nmbug/nmbug\r
+@@ -1,708 +1,807 @@\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
++#\r
++# Copyright (c) 2011-2014 David Bremner <david@tethera.net>\r
++#                         W. Trevor King <wking@tremily.us>\r
++#\r
++# This program is free software: you can redistribute it and/or modify\r
++# it under the terms of the GNU General Public License as published by\r
++# the Free Software Foundation, either version 3 of the License, or\r
++# (at your option) any later version.\r
++#\r
++# This program is distributed in the hope that it will be useful,\r
++# but WITHOUT ANY WARRANTY; without even the implied warranty of\r
++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\r
++# GNU General Public License for more details.\r
++#\r
++# You should have received a copy of the GNU General Public License\r
++# along with this program.  If not, see http://www.gnu.org/licenses/ .\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
++from __future__ import print_function\r
++from __future__ import unicode_literals\r
++\r
++import codecs as _codecs\r
++import collections as _collections\r
++import inspect as _inspect\r
++import locale as _locale\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
++    """\r
++    PEP 343 context manager for subprocesses.\r
++\r
++    'expect' holds a tuple of acceptable exit codes, otherwise we'll\r
++    raise a SubprocessError in __exit__.\r
++    """\r
++    def __init__(self, process, args, expect=(0,)):\r
++        self._process = process\r
++        self._args = args\r
++        self._expect = expect\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 not in self._expect:\r
++            raise SubprocessError(args=self._args, status=status)\r
++\r
++    def wait(self):\r
++        return self._process.wait()\r
++\r
++\r
++def _spawn(args, input=None, additional_env=None, wait=False, stdin=None,\r
++           stdout=None, stderr=None, encoding=_locale.getpreferredencoding(),\r
++           expect=(0,), **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, expect=expect)\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
++    _git(args=['read-tree', '--empty'], wait=True)\r
++    _git(args=['read-tree', treeish], wait=True)\r
++    try:\r
++        _update_index(status=status)\r
++        (_, tree, _) = _git(\r
++            args=['write-tree'],\r
++            stdout=_subprocess.PIPE,\r
++            wait=True)\r
++        (_, parent, _) = _git(\r
++            args=['rev-parse', treeish],\r
++            stdout=_subprocess.PIPE,\r
++            wait=True)\r
++        (_, commit, _) = _git(\r
++            args=['commit-tree', tree.strip(), '-p', parent.strip()],\r
++            input=message,\r
++            stdout=_subprocess.PIPE,\r
++            wait=True)\r
++        _git(\r
++            args=['update-ref', treeish, commit.strip()],\r
++            stdout=_subprocess.PIPE,\r
++            wait=True)\r
++    except Exception as e:\r
++        _git(args=['read-tree', '--empty'], wait=True)\r
++        _git(args=['read-tree', treeish], wait=True)\r
++        raise\r
++\r
++def _update_index(status):\r
++    with _git(\r
++            args=['update-index', '--index-info'],\r
++            stdin=_subprocess.PIPE) 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-pull.') as workdir:\r
++        for command in [\r
++                ['reset', '--hard'],\r
++                args]:\r
++            _git(\r
++                args=command,\r
++                additional_env={'GIT_WORK_TREE': workdir},\r
++                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
++        for command in [\r
++                ['reset', '--hard'],\r
++                ['merge', reference]]:\r
++            _git(\r
++                args=command,\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 = ['log', '--name-status'] + list(args)\r
++    with _git(args=args, expect=(0, 1, -13)) as p:\r
++        p.wait()\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' is a nested defaultdict for message status:\r
++    # * The outer dict is keyed by message id.\r
++    # * The inner dict is keyed by tag name.\r
++    # * The inner dict values are status strings (' a', 'Dd', ...).\r
++    output = _collections.defaultdict(\r
++        lambda : _collections.defaultdict(lambda : ' '))\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
++    """\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(\r
++        title='commands',\r
++        description=(\r
++            'For help on a particular command, run: '\r
++            "'%(prog)s ... <command> --help'."))\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
++    try:\r
++        args.func(**kwargs)\r
++    except SubprocessError as e:\r
++        if _LOG.level == _logging.DEBUG:\r
++            raise  # don't mask the traceback\r
++        _LOG.error(str(e))\r
++        _sys.exit(1)\r
+-- \r
+2.1.0.60.g85f0837\r
+\r