From: David Bremner Date: Sat, 16 Feb 2013 11:54:33 +0000 (-0400) Subject: nmbug: move from contrib to devel X-Git-Tag: 0.16_rc1~214 X-Git-Url: https://git.notmuchmail.org/git?p=notmuch;a=commitdiff_plain;h=41a29a84721235e33aff23bf6ef61b9ffdded2ef nmbug: move from contrib to devel There seems to be consensus to use presence in contrib as documentation of limited support by the notmuch developers; in fact nmbug is pretty integrated into our current development process, so devel seems more appropriate. --- diff --git a/contrib/nmbug/nmbug b/contrib/nmbug/nmbug deleted file mode 100755 index fe103b3b..00000000 --- a/contrib/nmbug/nmbug +++ /dev/null @@ -1,648 +0,0 @@ -#!/usr/bin/env perl -# Copyright (c) 2011 David Bremner -# License: same as notmuch - -use strict; -use warnings; -use File::Temp qw(tempdir); -use Pod::Usage; - -no encoding; - -my $NMBGIT = $ENV{NMBGIT} || $ENV{HOME}.'/.nmbug'; - -$NMBGIT .= '/.git' if (-d $NMBGIT.'/.git'); - -my $TAGPREFIX = $ENV{NMBPREFIX} || 'notmuch::'; - -# magic hash for git -my $EMPTYBLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'; - -# for encoding - -my $ESCAPE_CHAR = '%'; -my $NO_ESCAPE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'. - '0123456789+-_@=.:,'; -my $MUST_ENCODE = qr{[^\Q$NO_ESCAPE\E]}; -my $ESCAPED_RX = qr{$ESCAPE_CHAR([A-Fa-f0-9]{2})}; - -my %command = ( - archive => \&do_archive, - checkout => \&do_checkout, - commit => \&do_commit, - fetch => \&do_fetch, - help => \&do_help, - log => \&do_log, - merge => \&do_merge, - pull => \&do_pull, - push => \&do_push, - status => \&do_status, - ); - -my $subcommand = shift || usage (); - -if (!exists $command{$subcommand}) { - usage (); -} - -&{$command{$subcommand}}(@ARGV); - -sub git_pipe { - my $envref = (ref $_[0] eq 'HASH') ? shift : {}; - my $ioref = (ref $_[0] eq 'ARRAY') ? shift : undef; - my $dir = ($_[0] eq '-|' or $_[0] eq '|-') ? shift : undef; - - unshift @_, 'git'; - $envref->{GIT_DIR} ||= $NMBGIT; - spawn ($envref, defined $ioref ? $ioref : (), defined $dir ? $dir : (), @_); -} - -sub git { - my $fh = git_pipe (@_); - my $str = join ('', <$fh>); - unless (close $fh) { - die "'git @_' exited with nonzero value\n"; - } - chomp($str); - return $str; -} - -sub spawn { - my $envref = (ref $_[0] eq 'HASH') ? shift : {}; - my $ioref = (ref $_[0] eq 'ARRAY') ? shift : undef; - my $dir = ($_[0] eq '-|' or $_[0] eq '|-') ? shift : '-|'; - - die unless @_; - - if (open my $child, $dir) { - return $child; - } - # child - while (my ($key, $value) = each %{$envref}) { - $ENV{$key} = $value; - } - - if (defined $ioref && $dir eq '-|') { - open my $fh, '|-', @_ or die "open |- @_: $!"; - foreach my $line (@{$ioref}) { - print $fh $line, "\n"; - } - exit ! close $fh; - } else { - if ($dir ne '|-') { - open STDIN, '<', '/dev/null' or die "reopening stdin: $!" - } - exec @_; - die "exec @_: $!"; - } -} - - -sub get_tags { - my $prefix = shift; - my @tags; - - my $fh = spawn ('-|', qw/notmuch search --output=tags/, "*") - or die 'error dumping tags'; - - while (<$fh>) { - chomp (); - push @tags, $_ if (m/^$prefix/); - } - unless (close $fh) { - die "'notmuch search --output=tags *' exited with nonzero value\n"; - } - return @tags; -} - - -sub do_archive { - system ('git', "--git-dir=$NMBGIT", 'archive', 'HEAD'); -} - - -sub is_committed { - my $status = shift; - return scalar (@{$status->{added}} ) + scalar (@{$status->{deleted}} ) == 0; -} - - -sub do_commit { - my @args = @_; - - my $status = compute_status (); - - if ( is_committed ($status) ) { - print "Nothing to commit\n"; - return; - } - - my $index = read_tree ('HEAD'); - - update_index ($index, $status); - - my $tree = git ( { GIT_INDEX_FILE => $index }, 'write-tree') - or die 'no output from write-tree'; - - my $parent = git ( 'rev-parse', 'HEAD' ) - or die 'no output from rev-parse'; - - my $commit = git ([ @args ], 'commit-tree', $tree, '-p', $parent) - or die 'commit-tree'; - - git ('update-ref', 'HEAD', $commit); - - unlink $index || die "unlink: $!"; - -} - -sub read_tree { - my $treeish = shift; - my $index = $NMBGIT.'/nmbug.index'; - git ({ GIT_INDEX_FILE => $index }, 'read-tree', '--empty'); - git ({ GIT_INDEX_FILE => $index }, 'read-tree', $treeish); - return $index; -} - -sub update_index { - my $index = shift; - my $status = shift; - - my $git = spawn ({ GIT_DIR => $NMBGIT, GIT_INDEX_FILE => $index }, - '|-', qw/git update-index --index-info/) - or die 'git update-index'; - - foreach my $pair (@{$status->{deleted}}) { - index_tags_for_msg ($git, $pair->{id}, 'D', $pair->{tag}); - } - - foreach my $pair (@{$status->{added}}) { - index_tags_for_msg ($git, $pair->{id}, 'A', $pair->{tag}); - } - unless (close $git) { - die "'git update-index --index-info' exited with nonzero value\n"; - } - -} - - -sub do_fetch { - my $remote = shift || 'origin'; - - git ('fetch', $remote); -} - - -sub notmuch { - my @args = @_; - system ('notmuch', @args) == 0 or die "notmuch @args failed: $?"; -} - - -sub index_tags { - - my $index = $NMBGIT.'/nmbug.index'; - - my $query = join ' ', map ("tag:$_", get_tags ($TAGPREFIX)); - - my $fh = spawn ('-|', qw/notmuch dump --/, $query) - or die "notmuch dump: $!"; - - git ('read-tree', '--empty'); - my $git = spawn ({ GIT_DIR => $NMBGIT, GIT_INDEX_FILE => $index }, - '|-', qw/git update-index --index-info/) - or die 'git update-index'; - - while (<$fh>) { - m/ ( [^ ]* ) \s+ \( ([^\)]* ) \) /x || die 'syntax error in dump'; - my ($id,$rest) = ($1,$2); - - #strip prefixes before writing - my @tags = grep { s/^$TAGPREFIX//; } split (' ', $rest); - index_tags_for_msg ($git,$id, 'A', @tags); - } - unless (close $git) { - die "'git update-index --index-info' exited with nonzero value\n"; - } - unless (close $fh) { - die "'notmuch dump -- $query' exited with nonzero value\n"; - } - return $index; -} - -sub index_tags_for_msg { - my $fh = shift; - my $msgid = shift; - my $mode = shift; - - my $hash = $EMPTYBLOB; - my $blobmode = '100644'; - - if ($mode eq 'D') { - $blobmode = '0'; - $hash = '0000000000000000000000000000000000000000'; - } - - foreach my $tag (@_) { - my $tagpath = 'tags/' . encode_for_fs ($msgid) . '/' . encode_for_fs ($tag); - print $fh "$blobmode $hash\t$tagpath\n"; - } -} - - -sub do_checkout { - do_sync (action => 'checkout'); -} - - -sub do_sync { - - my %args = @_; - - my $status = compute_status (); - my ($A_action, $D_action); - - if ($args{action} eq 'checkout') { - $A_action = '-'; - $D_action = '+'; - } else { - $A_action = '+'; - $D_action = '-'; - } - - foreach my $pair (@{$status->{added}}) { - - notmuch ('tag', $A_action.$TAGPREFIX.$pair->{tag}, - 'id:'.$pair->{id}); - } - - foreach my $pair (@{$status->{deleted}}) { - notmuch ('tag', $D_action.$TAGPREFIX.$pair->{tag}, - 'id:'.$pair->{id}); - } - -} - - -sub insist_committed { - - my $status=compute_status(); - if ( !is_committed ($status) ) { - print "Uncommitted changes to $TAGPREFIX* tags in notmuch - -For a summary of changes, run 'nmbug status' -To save your changes, run 'nmbug commit' before merging/pull -To discard your changes, run 'nmbug checkout' -"; - exit (1); - } - -} - - -sub do_pull { - my $remote = shift || 'origin'; - - git ( 'fetch', $remote); - - do_merge (); -} - - -sub do_merge { - insist_committed (); - - my $tempwork = tempdir ('/tmp/nmbug-merge.XXXXXX', CLEANUP => 1); - - git ( { GIT_WORK_TREE => $tempwork }, 'checkout', '-f', 'HEAD'); - - git ( { GIT_WORK_TREE => $tempwork }, 'merge', 'FETCH_HEAD'); - - do_checkout (); -} - - -sub do_log { - # we don't want output trapping here, because we want the pager. - system ( 'git', "--git-dir=$NMBGIT", 'log', '--name-status', @_); -} - - -sub do_push { - my $remote = shift || 'origin'; - - git ('push', $remote, 'master'); -} - - -sub do_status { - my $status = compute_status (); - - my %output = (); - foreach my $pair (@{$status->{added}}) { - $output{$pair->{id}} ||= {}; - $output{$pair->{id}}{$pair->{tag}} = 'A' - } - - foreach my $pair (@{$status->{deleted}}) { - $output{$pair->{id}} ||= {}; - $output{$pair->{id}}{$pair->{tag}} = 'D' - } - - foreach my $pair (@{$status->{missing}}) { - $output{$pair->{id}} ||= {}; - $output{$pair->{id}}{$pair->{tag}} = 'U' - } - - if (is_unmerged ()) { - foreach my $pair (diff_refs ('A')) { - $output{$pair->{id}} ||= {}; - $output{$pair->{id}}{$pair->{tag}} ||= ' '; - $output{$pair->{id}}{$pair->{tag}} .= 'a'; - } - - foreach my $pair (diff_refs ('D')) { - $output{$pair->{id}} ||= {}; - $output{$pair->{id}}{$pair->{tag}} ||= ' '; - $output{$pair->{id}}{$pair->{tag}} .= 'd'; - } - } - - foreach my $id (sort keys %output) { - foreach my $tag (sort keys %{$output{$id}}) { - printf "%s\t%s\t%s\n", $output{$id}{$tag}, $id, $tag; - } - } -} - - -sub is_unmerged { - - return 0 if (! -f $NMBGIT.'/FETCH_HEAD'); - - my $fetch_head = git ('rev-parse', 'FETCH_HEAD'); - my $base = git ( 'merge-base', 'HEAD', 'FETCH_HEAD'); - - return ($base ne $fetch_head); - -} - -sub compute_status { - my %args = @_; - - my @added; - my @deleted; - my @missing; - - my $index = index_tags (); - - my @maybe_deleted = diff_index ($index, 'D'); - - foreach my $pair (@maybe_deleted) { - - my $id = $pair->{id}; - - my $fh = spawn ('-|', qw/notmuch search --output=files/,"id:$id") - or die "searching for $id"; - if (!<$fh>) { - push @missing, $pair; - } else { - push @deleted, $pair; - } - unless (close $fh) { - die "'notmuch search --output=files id:$id' exited with nonzero value\n"; - } - } - - - @added = diff_index ($index, 'A'); - - unlink $index || die "unlink $index: $!"; - - return { added => [@added], deleted => [@deleted], missing => [@missing] }; -} - - -sub diff_index { - my $index = shift; - my $filter = shift; - - my $fh = git_pipe ({ GIT_INDEX_FILE => $index }, - qw/diff-index --cached/, - "--diff-filter=$filter", qw/--name-only HEAD/ ); - - my @lines = unpack_diff_lines ($fh); - unless (close $fh) { - die "'git diff-index --cached --diff-filter=$filter --name-only HEAD' ", - "exited with nonzero value\n"; - } - return @lines; -} - - -sub diff_refs { - my $filter = shift; - my $ref1 = shift || 'HEAD'; - my $ref2 = shift || 'FETCH_HEAD'; - - my $fh= git_pipe ( 'diff', "--diff-filter=$filter", '--name-only', - $ref1, $ref2); - - my @lines = unpack_diff_lines ($fh); - unless (close $fh) { - die "'git diff --diff-filter=$filter --name-only $ref1 $ref2' ", - "exited with nonzero value\n"; - } - return @lines; -} - - -sub unpack_diff_lines { - my $fh = shift; - - my @found; - while(<$fh>) { - chomp (); - my ($id,$tag) = m|tags/ ([^/]+) / ([^/]+) |x; - - $id = decode_from_fs ($id); - $tag = decode_from_fs ($tag); - - push @found, { id => $id, tag => $tag }; - } - - return @found; -} - - -sub encode_for_fs { - my $str = shift; - - $str =~ s/($MUST_ENCODE)/"$ESCAPE_CHAR".sprintf ("%02x",ord ($1))/ge; - return $str; -} - - -sub decode_from_fs { - my $str = shift; - - $str =~ s/$ESCAPED_RX/ chr (hex ($1))/eg; - - return $str; - -} - - -sub usage { - pod2usage (); - exit (1); -} - - -sub do_help { - pod2usage ( -verbose => 2 ); - exit (0); -} - -__END__ - -=head1 NAME - -nmbug - manage notmuch tags about notmuch - -=head1 SYNOPSIS - -nmbug subcommand [options] - -B for more help - -=head1 OPTIONS - -=head2 Most common commands - -=over 8 - -=item B [message] - -Commit appropriately prefixed tags from the notmuch database to -git. Any extra arguments are used (one per line) as a commit message. - -=item B [remote] - -push local nmbug git state to remote repo - -=item B [remote] - -pull (merge) remote repo changes to notmuch. B is equivalent to -B followed by B. - -=back - -=head2 Other Useful Commands - -=over 8 - -=item B - -Update the notmuch database from git. This is mainly useful to discard -your changes in notmuch relative to git. - -=item B [remote] - -Fetch changes from the remote repo (see merge to bring those changes -into notmuch). - -=item B [subcommand] - -print help [for subcommand] - -=item B [parameters] - -A simple wrapper for git log. After running C, you can -inspect the changes with C - -=item B - -Merge changes from FETCH_HEAD into HEAD, and load the result into -notmuch. - -=item B - -Show pending updates in notmuch or git repo. See below for more -information about the output format. - -=back - -=head2 Less common commands - -=over 8 - -=item B - -Dump a tar archive (using git archive) of the current nmbug tag set. - -=back - -=head1 STATUS FORMAT - -B prints lines of the form - - ng Message-Id tag - -where n is a single character representing notmuch database status - -=over 8 - -=item B - -Tag is present in notmuch database, but not committed to nmbug -(equivalently, tag has been deleted in nmbug repo, e.g. by a pull, but -not restored to notmuch database). - -=item B - -Tag is present in nmbug repo, but not restored to notmuch database -(equivalently, tag has been deleted in notmuch) - -=item B - -Message is unknown (missing from local notmuch database) - -=back - -The second character (if present) represents a difference between remote -git and local. Typically C needs to be run to update this. - -=over 8 - - -=item B - -Tag is present in remote, but not in local git. - - -=item B - -Tag is present in local git, but not in remote git. - - -=back - -=head1 DUMP FORMAT - -Each tag $tag for message with Message-Id $id is written to -an empty file - - tags/encode($id)/encode($tag) - -The encoding preserves alphanumerics, and the characters "+-_@=.:," -(not the quotes). All other octets are replaced with '%' followed by -a two digit hex number. - -=head1 ENVIRONMENT - -B specifies the location of the git repository used by nmbug. -If not specified $HOME/.nmbug is used. - -B specifies the prefix in the notmuch database for tags of -interest to nmbug. If not specified 'notmuch::' is used. diff --git a/contrib/nmbug/nmbug-status b/contrib/nmbug/nmbug-status deleted file mode 100755 index d08ca08d..00000000 --- a/contrib/nmbug/nmbug-status +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/python -# -# Copyright (c) 2011-2012 David Bremner -# License: Same as notmuch -# dependencies -# - python 2.6 for json -# - argparse; either python 2.7, or install separately - -import datetime -import notmuch -import rfc822 -import urllib -import json -import argparse -import os -import subprocess - -# parse command line arguments - -parser = argparse.ArgumentParser() -parser.add_argument('--text', help='output plain text format', - action='store_true') - -parser.add_argument('--config', help='load config from given file') - - -args = parser.parse_args() - -# read config from json file - -if args.config != None: - fp = open(args.config) -else: - nmbhome = os.getenv('NMBGIT', os.path.expanduser('~/.nmbug')) - - # read only the first line from the pipe - sha1 = subprocess.Popen(['git', '--git-dir', nmbhome, - 'show-ref', '-s', 'config'], - stdout=subprocess.PIPE).stdout.readline() - - sha1 = sha1.rstrip() - - fp = subprocess.Popen(['git', '--git-dir', nmbhome, - 'cat-file', 'blob', sha1+':status-config.json'], - stdout=subprocess.PIPE).stdout - -config = json.load(fp) - -if args.text: - output_format = 'text' -else: - output_format = 'html' - -class Thread: - def __init__(self, last, lines): - self.last = last - self.lines = lines - - def join_utf8_with_newlines(self): - return '\n'.join( (line.encode('utf-8') for line in self.lines) ) - -def output_with_separator(threadlist, sep): - outputs = (thread.join_utf8_with_newlines() for thread in threadlist) - print sep.join(outputs) - -headers = ['date', 'from', 'subject'] - -def print_view(title, query, comment): - - query_string = ' and '.join(query) - q_new = notmuch.Query(db, query_string) - q_new.set_sort(notmuch.Query.SORT.OLDEST_FIRST) - - last_thread_id = '' - threads = {} - threadlist = [] - out = {} - last = None - lines = None - - if output_format == 'html': - print '

%s

' % (title, title) - print comment - print 'The view is generated from the following query:' - print '
' - print query_string - print '
' - print '\n' - - for m in q_new.search_messages(): - - thread_id = m.get_thread_id() - - if thread_id != last_thread_id: - if threads.has_key(thread_id): - last = threads[thread_id].last - lines = threads[thread_id].lines - else: - last = {} - lines = [] - thread = Thread(last, lines) - threads[thread_id] = thread - for h in headers: - last[h] = '' - threadlist.append(thread) - last_thread_id = thread_id - - for header in headers: - val = m.get_header(header) - - if header == 'date': - val = str.join(' ', val.split(None)[1:4]) - val = str(datetime.datetime.strptime(val, '%d %b %Y').date()) - elif header == 'from': - (val, addr) = rfc822.parseaddr(val) - if val == '': - val = addr.split('@')[0] - - if header != 'subject' and last[header] == val: - out[header] = '' - else: - out[header] = val - last[header] = val - - mid = m.get_message_id() - out['id'] = 'id:"%s"' % mid - - if output_format == 'html': - - out['subject'] = '%s' \ - % (urllib.quote(mid), out['subject']) - - lines.append(' ') - lines.append(' ') - else: - lines.append('%(date)-10.10s %(from)-20.20s %(subject)-40.40s\n%(id)72s' % out) - - if output_format == 'html': - output_with_separator(threadlist, - '\n\n') - print '
%s' % out['date']) - lines.append('%s' % out['id']) - lines.append('
%s' % out['from']) - lines.append('%s' % out['subject']) - lines.append('

' - else: - output_with_separator(threadlist, '\n\n') - -# main program - -db = notmuch.Database(mode=notmuch.Database.MODE.READ_WRITE) - -if output_format == 'html': - print ''' - - - - -Notmuch Patches - -''' - print '

Notmuch Patches

' - print 'Generated: %s
' % datetime.datetime.utcnow().date() - print 'For more infomation see nmbug' - - print '

Views

' - print '
    ' - for view in config['views']: - print '
  • %(title)s
  • ' % view - print '
' - -for view in config['views']: - print_view(**view) - -if output_format == 'html': - print '\n' diff --git a/contrib/nmbug/status-config.json b/contrib/nmbug/status-config.json deleted file mode 100644 index 6b4934fa..00000000 --- a/contrib/nmbug/status-config.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "views": [ - { - "comment": "Unresolved bugs (or just need tag updating).", - "query": [ - "tag:notmuch::bug", - "not tag:notmuch::fixed", - "not tag:notmuch::wontfix" - ], - "title": "Bugs" - }, - { - "comment": "These patches are under consideration for pushing.", - "query": [ - "tag:notmuch::patch and not tag:notmuch::pushed", - "not tag:notmuch::obsolete and not tag:notmuch::wip", - "not tag:notmuch::stale and not tag:notmuch::contrib", - "not tag:notmuch::moreinfo", - "not tag:notmuch::python", - "not tag:notmuch::vim", - "not tag:notmuch::wontfix", - "not tag:notmuch::needs-review" - ], - "title": "Maybe Ready (Core and Emacs)" - }, - { - "comment": "These python related patches might be ready to push, or they might just need updated tags.", - "query": [ - "tag:notmuch::patch and not tag:notmuch::pushed", - "not tag:notmuch::obsolete and not tag:notmuch::wip", - "not tag:notmuch::stale and not tag:notmuch::contrib", - "not tag:notmuch::moreinfo", - "not tag:notmuch::wontfix", - " tag:notmuch::python", - "not tag:notmuch::needs-review" - ], - "title": "Maybe Ready (Python)" - }, - { - "comment": "These vim related patches might be ready to push, or they might just need updated tags.", - "query": [ - "tag:notmuch::patch and not tag:notmuch::pushed", - "not tag:notmuch::obsolete and not tag:notmuch::wip", - "not tag:notmuch::stale and not tag:notmuch::contrib", - "not tag:notmuch::moreinfo", - "not tag:notmuch::wontfix", - "tag:notmuch::vim", - "not tag:notmuch::needs-review" - ], - "title": "Maybe Ready (vim)" - }, - { - "comment": "These patches are under review, or waiting for feedback.", - "query": [ - "tag:notmuch::patch", - "not tag:notmuch::pushed", - "not tag:notmuch::obsolete", - "not tag:notmuch::stale", - "not tag:notmuch::wontfix", - "(tag:notmuch::moreinfo or tag:notmuch::needs-review)" - ], - "title": "Review" - } - ] -} diff --git a/devel/nmbug/nmbug b/devel/nmbug/nmbug new file mode 100755 index 00000000..fe103b3b --- /dev/null +++ b/devel/nmbug/nmbug @@ -0,0 +1,648 @@ +#!/usr/bin/env perl +# Copyright (c) 2011 David Bremner +# License: same as notmuch + +use strict; +use warnings; +use File::Temp qw(tempdir); +use Pod::Usage; + +no encoding; + +my $NMBGIT = $ENV{NMBGIT} || $ENV{HOME}.'/.nmbug'; + +$NMBGIT .= '/.git' if (-d $NMBGIT.'/.git'); + +my $TAGPREFIX = $ENV{NMBPREFIX} || 'notmuch::'; + +# magic hash for git +my $EMPTYBLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'; + +# for encoding + +my $ESCAPE_CHAR = '%'; +my $NO_ESCAPE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'. + '0123456789+-_@=.:,'; +my $MUST_ENCODE = qr{[^\Q$NO_ESCAPE\E]}; +my $ESCAPED_RX = qr{$ESCAPE_CHAR([A-Fa-f0-9]{2})}; + +my %command = ( + archive => \&do_archive, + checkout => \&do_checkout, + commit => \&do_commit, + fetch => \&do_fetch, + help => \&do_help, + log => \&do_log, + merge => \&do_merge, + pull => \&do_pull, + push => \&do_push, + status => \&do_status, + ); + +my $subcommand = shift || usage (); + +if (!exists $command{$subcommand}) { + usage (); +} + +&{$command{$subcommand}}(@ARGV); + +sub git_pipe { + my $envref = (ref $_[0] eq 'HASH') ? shift : {}; + my $ioref = (ref $_[0] eq 'ARRAY') ? shift : undef; + my $dir = ($_[0] eq '-|' or $_[0] eq '|-') ? shift : undef; + + unshift @_, 'git'; + $envref->{GIT_DIR} ||= $NMBGIT; + spawn ($envref, defined $ioref ? $ioref : (), defined $dir ? $dir : (), @_); +} + +sub git { + my $fh = git_pipe (@_); + my $str = join ('', <$fh>); + unless (close $fh) { + die "'git @_' exited with nonzero value\n"; + } + chomp($str); + return $str; +} + +sub spawn { + my $envref = (ref $_[0] eq 'HASH') ? shift : {}; + my $ioref = (ref $_[0] eq 'ARRAY') ? shift : undef; + my $dir = ($_[0] eq '-|' or $_[0] eq '|-') ? shift : '-|'; + + die unless @_; + + if (open my $child, $dir) { + return $child; + } + # child + while (my ($key, $value) = each %{$envref}) { + $ENV{$key} = $value; + } + + if (defined $ioref && $dir eq '-|') { + open my $fh, '|-', @_ or die "open |- @_: $!"; + foreach my $line (@{$ioref}) { + print $fh $line, "\n"; + } + exit ! close $fh; + } else { + if ($dir ne '|-') { + open STDIN, '<', '/dev/null' or die "reopening stdin: $!" + } + exec @_; + die "exec @_: $!"; + } +} + + +sub get_tags { + my $prefix = shift; + my @tags; + + my $fh = spawn ('-|', qw/notmuch search --output=tags/, "*") + or die 'error dumping tags'; + + while (<$fh>) { + chomp (); + push @tags, $_ if (m/^$prefix/); + } + unless (close $fh) { + die "'notmuch search --output=tags *' exited with nonzero value\n"; + } + return @tags; +} + + +sub do_archive { + system ('git', "--git-dir=$NMBGIT", 'archive', 'HEAD'); +} + + +sub is_committed { + my $status = shift; + return scalar (@{$status->{added}} ) + scalar (@{$status->{deleted}} ) == 0; +} + + +sub do_commit { + my @args = @_; + + my $status = compute_status (); + + if ( is_committed ($status) ) { + print "Nothing to commit\n"; + return; + } + + my $index = read_tree ('HEAD'); + + update_index ($index, $status); + + my $tree = git ( { GIT_INDEX_FILE => $index }, 'write-tree') + or die 'no output from write-tree'; + + my $parent = git ( 'rev-parse', 'HEAD' ) + or die 'no output from rev-parse'; + + my $commit = git ([ @args ], 'commit-tree', $tree, '-p', $parent) + or die 'commit-tree'; + + git ('update-ref', 'HEAD', $commit); + + unlink $index || die "unlink: $!"; + +} + +sub read_tree { + my $treeish = shift; + my $index = $NMBGIT.'/nmbug.index'; + git ({ GIT_INDEX_FILE => $index }, 'read-tree', '--empty'); + git ({ GIT_INDEX_FILE => $index }, 'read-tree', $treeish); + return $index; +} + +sub update_index { + my $index = shift; + my $status = shift; + + my $git = spawn ({ GIT_DIR => $NMBGIT, GIT_INDEX_FILE => $index }, + '|-', qw/git update-index --index-info/) + or die 'git update-index'; + + foreach my $pair (@{$status->{deleted}}) { + index_tags_for_msg ($git, $pair->{id}, 'D', $pair->{tag}); + } + + foreach my $pair (@{$status->{added}}) { + index_tags_for_msg ($git, $pair->{id}, 'A', $pair->{tag}); + } + unless (close $git) { + die "'git update-index --index-info' exited with nonzero value\n"; + } + +} + + +sub do_fetch { + my $remote = shift || 'origin'; + + git ('fetch', $remote); +} + + +sub notmuch { + my @args = @_; + system ('notmuch', @args) == 0 or die "notmuch @args failed: $?"; +} + + +sub index_tags { + + my $index = $NMBGIT.'/nmbug.index'; + + my $query = join ' ', map ("tag:$_", get_tags ($TAGPREFIX)); + + my $fh = spawn ('-|', qw/notmuch dump --/, $query) + or die "notmuch dump: $!"; + + git ('read-tree', '--empty'); + my $git = spawn ({ GIT_DIR => $NMBGIT, GIT_INDEX_FILE => $index }, + '|-', qw/git update-index --index-info/) + or die 'git update-index'; + + while (<$fh>) { + m/ ( [^ ]* ) \s+ \( ([^\)]* ) \) /x || die 'syntax error in dump'; + my ($id,$rest) = ($1,$2); + + #strip prefixes before writing + my @tags = grep { s/^$TAGPREFIX//; } split (' ', $rest); + index_tags_for_msg ($git,$id, 'A', @tags); + } + unless (close $git) { + die "'git update-index --index-info' exited with nonzero value\n"; + } + unless (close $fh) { + die "'notmuch dump -- $query' exited with nonzero value\n"; + } + return $index; +} + +sub index_tags_for_msg { + my $fh = shift; + my $msgid = shift; + my $mode = shift; + + my $hash = $EMPTYBLOB; + my $blobmode = '100644'; + + if ($mode eq 'D') { + $blobmode = '0'; + $hash = '0000000000000000000000000000000000000000'; + } + + foreach my $tag (@_) { + my $tagpath = 'tags/' . encode_for_fs ($msgid) . '/' . encode_for_fs ($tag); + print $fh "$blobmode $hash\t$tagpath\n"; + } +} + + +sub do_checkout { + do_sync (action => 'checkout'); +} + + +sub do_sync { + + my %args = @_; + + my $status = compute_status (); + my ($A_action, $D_action); + + if ($args{action} eq 'checkout') { + $A_action = '-'; + $D_action = '+'; + } else { + $A_action = '+'; + $D_action = '-'; + } + + foreach my $pair (@{$status->{added}}) { + + notmuch ('tag', $A_action.$TAGPREFIX.$pair->{tag}, + 'id:'.$pair->{id}); + } + + foreach my $pair (@{$status->{deleted}}) { + notmuch ('tag', $D_action.$TAGPREFIX.$pair->{tag}, + 'id:'.$pair->{id}); + } + +} + + +sub insist_committed { + + my $status=compute_status(); + if ( !is_committed ($status) ) { + print "Uncommitted changes to $TAGPREFIX* tags in notmuch + +For a summary of changes, run 'nmbug status' +To save your changes, run 'nmbug commit' before merging/pull +To discard your changes, run 'nmbug checkout' +"; + exit (1); + } + +} + + +sub do_pull { + my $remote = shift || 'origin'; + + git ( 'fetch', $remote); + + do_merge (); +} + + +sub do_merge { + insist_committed (); + + my $tempwork = tempdir ('/tmp/nmbug-merge.XXXXXX', CLEANUP => 1); + + git ( { GIT_WORK_TREE => $tempwork }, 'checkout', '-f', 'HEAD'); + + git ( { GIT_WORK_TREE => $tempwork }, 'merge', 'FETCH_HEAD'); + + do_checkout (); +} + + +sub do_log { + # we don't want output trapping here, because we want the pager. + system ( 'git', "--git-dir=$NMBGIT", 'log', '--name-status', @_); +} + + +sub do_push { + my $remote = shift || 'origin'; + + git ('push', $remote, 'master'); +} + + +sub do_status { + my $status = compute_status (); + + my %output = (); + foreach my $pair (@{$status->{added}}) { + $output{$pair->{id}} ||= {}; + $output{$pair->{id}}{$pair->{tag}} = 'A' + } + + foreach my $pair (@{$status->{deleted}}) { + $output{$pair->{id}} ||= {}; + $output{$pair->{id}}{$pair->{tag}} = 'D' + } + + foreach my $pair (@{$status->{missing}}) { + $output{$pair->{id}} ||= {}; + $output{$pair->{id}}{$pair->{tag}} = 'U' + } + + if (is_unmerged ()) { + foreach my $pair (diff_refs ('A')) { + $output{$pair->{id}} ||= {}; + $output{$pair->{id}}{$pair->{tag}} ||= ' '; + $output{$pair->{id}}{$pair->{tag}} .= 'a'; + } + + foreach my $pair (diff_refs ('D')) { + $output{$pair->{id}} ||= {}; + $output{$pair->{id}}{$pair->{tag}} ||= ' '; + $output{$pair->{id}}{$pair->{tag}} .= 'd'; + } + } + + foreach my $id (sort keys %output) { + foreach my $tag (sort keys %{$output{$id}}) { + printf "%s\t%s\t%s\n", $output{$id}{$tag}, $id, $tag; + } + } +} + + +sub is_unmerged { + + return 0 if (! -f $NMBGIT.'/FETCH_HEAD'); + + my $fetch_head = git ('rev-parse', 'FETCH_HEAD'); + my $base = git ( 'merge-base', 'HEAD', 'FETCH_HEAD'); + + return ($base ne $fetch_head); + +} + +sub compute_status { + my %args = @_; + + my @added; + my @deleted; + my @missing; + + my $index = index_tags (); + + my @maybe_deleted = diff_index ($index, 'D'); + + foreach my $pair (@maybe_deleted) { + + my $id = $pair->{id}; + + my $fh = spawn ('-|', qw/notmuch search --output=files/,"id:$id") + or die "searching for $id"; + if (!<$fh>) { + push @missing, $pair; + } else { + push @deleted, $pair; + } + unless (close $fh) { + die "'notmuch search --output=files id:$id' exited with nonzero value\n"; + } + } + + + @added = diff_index ($index, 'A'); + + unlink $index || die "unlink $index: $!"; + + return { added => [@added], deleted => [@deleted], missing => [@missing] }; +} + + +sub diff_index { + my $index = shift; + my $filter = shift; + + my $fh = git_pipe ({ GIT_INDEX_FILE => $index }, + qw/diff-index --cached/, + "--diff-filter=$filter", qw/--name-only HEAD/ ); + + my @lines = unpack_diff_lines ($fh); + unless (close $fh) { + die "'git diff-index --cached --diff-filter=$filter --name-only HEAD' ", + "exited with nonzero value\n"; + } + return @lines; +} + + +sub diff_refs { + my $filter = shift; + my $ref1 = shift || 'HEAD'; + my $ref2 = shift || 'FETCH_HEAD'; + + my $fh= git_pipe ( 'diff', "--diff-filter=$filter", '--name-only', + $ref1, $ref2); + + my @lines = unpack_diff_lines ($fh); + unless (close $fh) { + die "'git diff --diff-filter=$filter --name-only $ref1 $ref2' ", + "exited with nonzero value\n"; + } + return @lines; +} + + +sub unpack_diff_lines { + my $fh = shift; + + my @found; + while(<$fh>) { + chomp (); + my ($id,$tag) = m|tags/ ([^/]+) / ([^/]+) |x; + + $id = decode_from_fs ($id); + $tag = decode_from_fs ($tag); + + push @found, { id => $id, tag => $tag }; + } + + return @found; +} + + +sub encode_for_fs { + my $str = shift; + + $str =~ s/($MUST_ENCODE)/"$ESCAPE_CHAR".sprintf ("%02x",ord ($1))/ge; + return $str; +} + + +sub decode_from_fs { + my $str = shift; + + $str =~ s/$ESCAPED_RX/ chr (hex ($1))/eg; + + return $str; + +} + + +sub usage { + pod2usage (); + exit (1); +} + + +sub do_help { + pod2usage ( -verbose => 2 ); + exit (0); +} + +__END__ + +=head1 NAME + +nmbug - manage notmuch tags about notmuch + +=head1 SYNOPSIS + +nmbug subcommand [options] + +B for more help + +=head1 OPTIONS + +=head2 Most common commands + +=over 8 + +=item B [message] + +Commit appropriately prefixed tags from the notmuch database to +git. Any extra arguments are used (one per line) as a commit message. + +=item B [remote] + +push local nmbug git state to remote repo + +=item B [remote] + +pull (merge) remote repo changes to notmuch. B is equivalent to +B followed by B. + +=back + +=head2 Other Useful Commands + +=over 8 + +=item B + +Update the notmuch database from git. This is mainly useful to discard +your changes in notmuch relative to git. + +=item B [remote] + +Fetch changes from the remote repo (see merge to bring those changes +into notmuch). + +=item B [subcommand] + +print help [for subcommand] + +=item B [parameters] + +A simple wrapper for git log. After running C, you can +inspect the changes with C + +=item B + +Merge changes from FETCH_HEAD into HEAD, and load the result into +notmuch. + +=item B + +Show pending updates in notmuch or git repo. See below for more +information about the output format. + +=back + +=head2 Less common commands + +=over 8 + +=item B + +Dump a tar archive (using git archive) of the current nmbug tag set. + +=back + +=head1 STATUS FORMAT + +B prints lines of the form + + ng Message-Id tag + +where n is a single character representing notmuch database status + +=over 8 + +=item B + +Tag is present in notmuch database, but not committed to nmbug +(equivalently, tag has been deleted in nmbug repo, e.g. by a pull, but +not restored to notmuch database). + +=item B + +Tag is present in nmbug repo, but not restored to notmuch database +(equivalently, tag has been deleted in notmuch) + +=item B + +Message is unknown (missing from local notmuch database) + +=back + +The second character (if present) represents a difference between remote +git and local. Typically C needs to be run to update this. + +=over 8 + + +=item B + +Tag is present in remote, but not in local git. + + +=item B + +Tag is present in local git, but not in remote git. + + +=back + +=head1 DUMP FORMAT + +Each tag $tag for message with Message-Id $id is written to +an empty file + + tags/encode($id)/encode($tag) + +The encoding preserves alphanumerics, and the characters "+-_@=.:," +(not the quotes). All other octets are replaced with '%' followed by +a two digit hex number. + +=head1 ENVIRONMENT + +B specifies the location of the git repository used by nmbug. +If not specified $HOME/.nmbug is used. + +B specifies the prefix in the notmuch database for tags of +interest to nmbug. If not specified 'notmuch::' is used. diff --git a/devel/nmbug/nmbug-status b/devel/nmbug/nmbug-status new file mode 100755 index 00000000..d08ca08d --- /dev/null +++ b/devel/nmbug/nmbug-status @@ -0,0 +1,176 @@ +#!/usr/bin/python +# +# Copyright (c) 2011-2012 David Bremner +# License: Same as notmuch +# dependencies +# - python 2.6 for json +# - argparse; either python 2.7, or install separately + +import datetime +import notmuch +import rfc822 +import urllib +import json +import argparse +import os +import subprocess + +# parse command line arguments + +parser = argparse.ArgumentParser() +parser.add_argument('--text', help='output plain text format', + action='store_true') + +parser.add_argument('--config', help='load config from given file') + + +args = parser.parse_args() + +# read config from json file + +if args.config != None: + fp = open(args.config) +else: + nmbhome = os.getenv('NMBGIT', os.path.expanduser('~/.nmbug')) + + # read only the first line from the pipe + sha1 = subprocess.Popen(['git', '--git-dir', nmbhome, + 'show-ref', '-s', 'config'], + stdout=subprocess.PIPE).stdout.readline() + + sha1 = sha1.rstrip() + + fp = subprocess.Popen(['git', '--git-dir', nmbhome, + 'cat-file', 'blob', sha1+':status-config.json'], + stdout=subprocess.PIPE).stdout + +config = json.load(fp) + +if args.text: + output_format = 'text' +else: + output_format = 'html' + +class Thread: + def __init__(self, last, lines): + self.last = last + self.lines = lines + + def join_utf8_with_newlines(self): + return '\n'.join( (line.encode('utf-8') for line in self.lines) ) + +def output_with_separator(threadlist, sep): + outputs = (thread.join_utf8_with_newlines() for thread in threadlist) + print sep.join(outputs) + +headers = ['date', 'from', 'subject'] + +def print_view(title, query, comment): + + query_string = ' and '.join(query) + q_new = notmuch.Query(db, query_string) + q_new.set_sort(notmuch.Query.SORT.OLDEST_FIRST) + + last_thread_id = '' + threads = {} + threadlist = [] + out = {} + last = None + lines = None + + if output_format == 'html': + print '

%s

' % (title, title) + print comment + print 'The view is generated from the following query:' + print '
' + print query_string + print '
' + print '\n' + + for m in q_new.search_messages(): + + thread_id = m.get_thread_id() + + if thread_id != last_thread_id: + if threads.has_key(thread_id): + last = threads[thread_id].last + lines = threads[thread_id].lines + else: + last = {} + lines = [] + thread = Thread(last, lines) + threads[thread_id] = thread + for h in headers: + last[h] = '' + threadlist.append(thread) + last_thread_id = thread_id + + for header in headers: + val = m.get_header(header) + + if header == 'date': + val = str.join(' ', val.split(None)[1:4]) + val = str(datetime.datetime.strptime(val, '%d %b %Y').date()) + elif header == 'from': + (val, addr) = rfc822.parseaddr(val) + if val == '': + val = addr.split('@')[0] + + if header != 'subject' and last[header] == val: + out[header] = '' + else: + out[header] = val + last[header] = val + + mid = m.get_message_id() + out['id'] = 'id:"%s"' % mid + + if output_format == 'html': + + out['subject'] = '%s' \ + % (urllib.quote(mid), out['subject']) + + lines.append(' ') + lines.append(' ') + else: + lines.append('%(date)-10.10s %(from)-20.20s %(subject)-40.40s\n%(id)72s' % out) + + if output_format == 'html': + output_with_separator(threadlist, + '\n\n') + print '
%s' % out['date']) + lines.append('%s' % out['id']) + lines.append('
%s' % out['from']) + lines.append('%s' % out['subject']) + lines.append('

' + else: + output_with_separator(threadlist, '\n\n') + +# main program + +db = notmuch.Database(mode=notmuch.Database.MODE.READ_WRITE) + +if output_format == 'html': + print ''' + + + + +Notmuch Patches + +''' + print '

Notmuch Patches

' + print 'Generated: %s
' % datetime.datetime.utcnow().date() + print 'For more infomation see nmbug' + + print '

Views

' + print '
    ' + for view in config['views']: + print '
  • %(title)s
  • ' % view + print '
' + +for view in config['views']: + print_view(**view) + +if output_format == 'html': + print '\n' diff --git a/devel/nmbug/status-config.json b/devel/nmbug/status-config.json new file mode 100644 index 00000000..6b4934fa --- /dev/null +++ b/devel/nmbug/status-config.json @@ -0,0 +1,65 @@ +{ + "views": [ + { + "comment": "Unresolved bugs (or just need tag updating).", + "query": [ + "tag:notmuch::bug", + "not tag:notmuch::fixed", + "not tag:notmuch::wontfix" + ], + "title": "Bugs" + }, + { + "comment": "These patches are under consideration for pushing.", + "query": [ + "tag:notmuch::patch and not tag:notmuch::pushed", + "not tag:notmuch::obsolete and not tag:notmuch::wip", + "not tag:notmuch::stale and not tag:notmuch::contrib", + "not tag:notmuch::moreinfo", + "not tag:notmuch::python", + "not tag:notmuch::vim", + "not tag:notmuch::wontfix", + "not tag:notmuch::needs-review" + ], + "title": "Maybe Ready (Core and Emacs)" + }, + { + "comment": "These python related patches might be ready to push, or they might just need updated tags.", + "query": [ + "tag:notmuch::patch and not tag:notmuch::pushed", + "not tag:notmuch::obsolete and not tag:notmuch::wip", + "not tag:notmuch::stale and not tag:notmuch::contrib", + "not tag:notmuch::moreinfo", + "not tag:notmuch::wontfix", + " tag:notmuch::python", + "not tag:notmuch::needs-review" + ], + "title": "Maybe Ready (Python)" + }, + { + "comment": "These vim related patches might be ready to push, or they might just need updated tags.", + "query": [ + "tag:notmuch::patch and not tag:notmuch::pushed", + "not tag:notmuch::obsolete and not tag:notmuch::wip", + "not tag:notmuch::stale and not tag:notmuch::contrib", + "not tag:notmuch::moreinfo", + "not tag:notmuch::wontfix", + "tag:notmuch::vim", + "not tag:notmuch::needs-review" + ], + "title": "Maybe Ready (vim)" + }, + { + "comment": "These patches are under review, or waiting for feedback.", + "query": [ + "tag:notmuch::patch", + "not tag:notmuch::pushed", + "not tag:notmuch::obsolete", + "not tag:notmuch::stale", + "not tag:notmuch::wontfix", + "(tag:notmuch::moreinfo or tag:notmuch::needs-review)" + ], + "title": "Review" + } + ] +}