--- /dev/null
+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 C8D41431FBC\r
+ for <notmuch@notmuchmail.org>; Fri, 5 Sep 2014 09:50:10 -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 zd+KNIXyppii for <notmuch@notmuchmail.org>;\r
+ Fri, 5 Sep 2014 09:49:38 -0700 (PDT)\r
+Received: from qmta13.emeryville.ca.mail.comcast.net\r
+ (qmta13.emeryville.ca.mail.comcast.net [76.96.27.243])\r
+ by olra.theworths.org (Postfix) with ESMTP id 54044431FAF\r
+ for <notmuch@notmuchmail.org>; Fri, 5 Sep 2014 09:49:38 -0700 (PDT)\r
+Received: from omta14.emeryville.ca.mail.comcast.net ([76.96.30.60])\r
+ by qmta13.emeryville.ca.mail.comcast.net with comcast\r
+ id nSib1o0041HpZEsADUpdbB; Fri, 05 Sep 2014 16:49:37 +0000\r
+Received: from odin.tremily.us ([24.18.63.50])\r
+ by omta14.emeryville.ca.mail.comcast.net with comcast\r
+ id nUpa1o00i152l3L8aUpbxV; Fri, 05 Sep 2014 16:49:36 +0000\r
+Received: from mjolnir.tremily.us (unknown [192.168.0.150])\r
+ by odin.tremily.us (Postfix) with ESMTPS id A091D136474E;\r
+ Fri, 5 Sep 2014 09:49:34 -0700 (PDT)\r
+Received: (nullmailer pid 27470 invoked by uid 1000);\r
+ Fri, 05 Sep 2014 16:47:01 -0000\r
+From: "W. Trevor King" <wking@tremily.us>\r
+To: notmuch@notmuchmail.org\r
+Subject: [PATCH v4] nmbug: Translate to Python\r
+Date: Fri, 5 Sep 2014 09:46:58 -0700\r
+Message-Id:\r
+ <e630b6763e9d0771718afee41ea15b29bb4a1de8.1409935538.git.wking@tremily.us>\r
+X-Mailer: git-send-email 1.8.5.5\r
+MIME-Version: 1.0\r
+Content-Type: text/plain; charset=UTF-8\r
+Content-Transfer-Encoding: 8bit\r
+DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=comcast.net;\r
+ s=q20140121; t=1409935777;\r
+ bh=k8DHKqhQpZrOYSbY/JB6pSJesMwp/PcSjROP8LIi+sc=;\r
+ h=Received:Received:Received:Received:From:To:Subject:Date:\r
+ Message-Id:MIME-Version:Content-Type;\r
+ b=My1T8hTgnfAmGFLGBZw5po8ZbGNZkvo1cwy65d0hu8+xLmWm+DbCOkmLkaZrTz/2K\r
+ tuya0XHvZojkIpvse5tDVh9ii84F/5cTcnR0V/eURD7STU94pW0+9EMK3pzUFu/zz1\r
+ tfcPVZlELXS+Xx3Q2z5mnOASXQ+MIyJkI922KTgRioWM1EHs+dEIaUNOBEC5q1CaCo\r
+ Ull7Rdy81MZzJv1+oqg1YwRHilhJFNHa9YX2omsQYdKv8Ps4D7P6eiqcbb6rDysMt6\r
+ DZIKpdlAHXi1BSy/wzXqw+x9GspYtpVEQas6lAxAR1eFwdOqvlxRoBV0aXOU04DIGs\r
+ nj7ijyXvi0IJQ==\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: Fri, 05 Sep 2014 16:50:11 -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 I can't find a workflow that needs this\r
+ checkout.\r
+---\r
+Changes since v3 [1]:\r
+\r
+* Document ‘nmbug commit’ single-argument message in the commit\r
+ message [2].\r
+* ‘nmbug-pull.’ prefix for the pull temporary directory [3].\r
+* ‘nmbug log’ now uses _git() instead of execvp for consistency\r
+ with other commands [3].\r
+* Add periods to docstring summaries where they were missing [3].\r
+* Import print_function and unicode_literals from __future__ [3].\r
+* Mention ‘nmbug ... <command> --help’ [4].\r
+* Bump the copyright years, add myself, and give the GPLv3+\r
+ boilerplate [4].\r
+* Use (optional) parenthesis around multi-variable assignments, even\r
+ though I'm not a lisp programmer ;) [4,5].\r
+* Replace some unused assignments with underscores.\r
+* Only print SubprocessError tracebacks with debug logging [6].\r
+* Restore the Perl version's pre-merge/pull checkout [7].\r
+* Expanded comment for status' ‘output’ variable [8].\r
+\r
+I've left a local version (distinct from the notmuch version), because\r
+the suggested doc/conf.py hackery [5] only works if you have the full\r
+notmuch source available (with it's associated ‘version’ file). I'm\r
+grudgingly willing to hard-code the notmuch version into nmbug, but\r
+I'd rather just keep the distinct local version.\r
+\r
+I've also left off the merge/pull checkouts, since I haven't been able\r
+to reproduce David's error locally [9]. I want to figure out what's\r
+actually going wrong before updating the code, so we can figure out if\r
+implicit checkouts are the right fix. Merging with a dirty index\r
+(i.e. uncommitted tag changes in Xapian) looks like:\r
+\r
+ $ ./nmbug merge\r
+ Uncommitted changes to notmuch::* 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
+which seems reasonable (and doesn't show David's crash).\r
+\r
+Cheers,\r
+Trevor\r
+\r
+[1]: id:84447a0ed48412e1587761d560d18cb5affd4f66.1405897133.git.wking@tremily.us\r
+ http://thread.gmane.org/gmane.mail.notmuch.general/18759\r
+[2]: id:20140720231157.GT8744@odin\r
+ http://article.gmane.org/gmane.mail.notmuch.general/18760\r
+[3]: id:20140720233402.GU8744@odin\r
+ http://article.gmane.org/gmane.mail.notmuch.general/18761\r
+[4]: id:878un36bd5.fsf@maritornes.cs.unb.ca\r
+ http://article.gmane.org/gmane.mail.notmuch.general/18861\r
+[5]: id:871ts5ei6c.fsf@maritornes.cs.unb.ca\r
+ http://article.gmane.org/gmane.mail.notmuch.general/18942\r
+[6]: id:20140806232805.GI31313@odin.tremily.us\r
+ http://article.gmane.org/gmane.mail.notmuch.general/18882\r
+[7]: id:20140806233803.GJ31313@odin.tremily.us\r
+ http://article.gmane.org/gmane.mail.notmuch.general/18883\r
+[8]: id:20140806232131.GH31313@odin.tremily.us\r
+ http://article.gmane.org/gmane.mail.notmuch.general/18881\r
+[9]: id:871tsue7gl.fsf@maritornes.cs.unb.ca\r
+ http://article.gmane.org/gmane.mail.notmuch.general/18876\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..77bec64 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 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=_sys.stdout.encoding,\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
++ index = _read_tree(treeish=treeish)\r
++ _update_index(index=index, status=status)\r
++ (_, tree, _) = _git(\r
++ args=['write-tree'],\r
++ stdout=_subprocess.PIPE,\r
++ additional_env={'GIT_INDEX_FILE': index},\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
++ _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-pull.') 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 = ['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
+1.8.5.5\r
+\r