2 # Copyright (c) 2011 David Bremner
3 # License: same as notmuch
7 use File::Temp qw(tempdir);
12 my $NMBGIT = $ENV{NMBGIT} || $ENV{HOME}.'/.nmbug';
14 $NMBGIT .= '/.git' if (-d $NMBGIT.'/.git');
16 my $TAGPREFIX = defined($ENV{NMBPREFIX}) ? $ENV{NMBPREFIX} : 'notmuch::';
20 my $ESCAPE_CHAR = '%';
21 my $NO_ESCAPE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.
23 my $MUST_ENCODE = qr{[^\Q$NO_ESCAPE\E]};
24 my $ESCAPED_RX = qr{$ESCAPE_CHAR([A-Fa-f0-9]{2})};
27 archive => \&do_archive,
28 checkout => \&do_checkout,
30 commit => \&do_commit,
37 status => \&do_status,
40 # Convert prefix into form suitable for literal matching against
41 # notmuch dump --format=batch-tag output.
42 my $ENCPREFIX = encode_for_fs ($TAGPREFIX);
43 $ENCPREFIX =~ s/:/%3a/g;
45 my $subcommand = shift || usage ();
47 if (!exists $command{$subcommand}) {
52 my $EMPTYBLOB = git (qw{hash-object -t blob /dev/null});
54 &{$command{$subcommand}}(@ARGV);
57 my $envref = (ref $_[0] eq 'HASH') ? shift : {};
58 my $ioref = (ref $_[0] eq 'ARRAY') ? shift : undef;
59 my $dir = ($_[0] eq '-|' or $_[0] eq '|-') ? shift : undef;
62 $envref->{GIT_DIR} ||= $NMBGIT;
63 spawn ($envref, defined $ioref ? $ioref : (), defined $dir ? $dir : (), @_);
67 my $fh = git_pipe (@_);
68 my $str = join ('', <$fh>);
70 die "'git @_' exited with nonzero value\n";
77 my $envref = (ref $_[0] eq 'HASH') ? shift : {};
78 my $ioref = (ref $_[0] eq 'ARRAY') ? shift : undef;
79 my $dir = ($_[0] eq '-|' or $_[0] eq '|-') ? shift : '-|';
83 if (open my $child, $dir) {
87 while (my ($key, $value) = each %{$envref}) {
91 if (defined $ioref && $dir eq '-|') {
92 open my $fh, '|-', @_ or die "open |- @_: $!";
93 foreach my $line (@{$ioref}) {
94 print $fh $line, "\n";
99 open STDIN, '<', '/dev/null' or die "reopening stdin: $!"
111 my $fh = spawn ('-|', qw/notmuch search --output=tags/, "*")
112 or die 'error dumping tags';
116 push @tags, $_ if (m/^$prefix/);
119 die "'notmuch search --output=tags *' exited with nonzero value\n";
126 system ('git', "--git-dir=$NMBGIT", 'archive', 'HEAD');
130 my $repository = shift;
132 my $tempwork = tempdir ('/tmp/nmbug-clone.XXXXXX', CLEANUP => 1);
133 system ('git', 'clone', '--no-checkout', '--separate-git-dir', $NMBGIT,
134 $repository, $tempwork) == 0
135 or die "'git clone' exited with nonzero value\n";
136 git ('config', '--unset', 'core.worktree');
137 git ('config', 'core.bare', 'true');
142 return scalar (@{$status->{added}} ) + scalar (@{$status->{deleted}} ) == 0;
149 my $status = compute_status ();
151 if ( is_committed ($status) ) {
152 print "Nothing to commit\n";
156 my $index = read_tree ('HEAD');
158 update_index ($index, $status);
160 my $tree = git ( { GIT_INDEX_FILE => $index }, 'write-tree')
161 or die 'no output from write-tree';
163 my $parent = git ( 'rev-parse', 'HEAD' )
164 or die 'no output from rev-parse';
166 my $commit = git ([ @args ], 'commit-tree', $tree, '-p', $parent)
167 or die 'commit-tree';
169 git ('update-ref', 'HEAD', $commit);
171 unlink $index || die "unlink: $!";
177 my $index = $NMBGIT.'/nmbug.index';
178 git ({ GIT_INDEX_FILE => $index }, 'read-tree', '--empty');
179 git ({ GIT_INDEX_FILE => $index }, 'read-tree', $treeish);
187 my $git = spawn ({ GIT_DIR => $NMBGIT, GIT_INDEX_FILE => $index },
188 '|-', qw/git update-index --index-info/)
189 or die 'git update-index';
191 foreach my $pair (@{$status->{deleted}}) {
192 index_tags_for_msg ($git, $pair->{id}, 'D', $pair->{tag});
195 foreach my $pair (@{$status->{added}}) {
196 index_tags_for_msg ($git, $pair->{id}, 'A', $pair->{tag});
198 unless (close $git) {
199 die "'git update-index --index-info' exited with nonzero value\n";
206 my $remote = shift || 'origin';
208 git ('fetch', $remote);
214 system ('notmuch', @args) == 0 or die "notmuch @args failed: $?";
220 my $index = $NMBGIT.'/nmbug.index';
222 my $query = join ' ', map ("tag:\"$_\"", get_tags ($TAGPREFIX));
224 my $fh = spawn ('-|', qw/notmuch dump --format=batch-tag --/, $query)
225 or die "notmuch dump: $!";
227 git ('read-tree', '--empty');
228 my $git = spawn ({ GIT_DIR => $NMBGIT, GIT_INDEX_FILE => $index },
229 '|-', qw/git update-index --index-info/)
230 or die 'git update-index';
235 my ($rest,$id) = split(/ -- id:/);
237 if ($id =~ s/^"(.*)"\s*$/$1/) {
238 # xapian quoted string, dequote.
242 #strip prefixes from tags before writing
243 my @tags = grep { s/^[+]$ENCPREFIX//; } split (' ', $rest);
244 index_tags_for_msg ($git,$id, 'A', @tags);
246 unless (close $git) {
247 die "'git update-index --index-info' exited with nonzero value\n";
250 die "'notmuch dump --format=batch-tag -- $query' exited with nonzero value\n";
255 # update the git index to either create or delete an empty file.
256 # Neither argument should be encoded/escaped.
257 sub index_tags_for_msg {
262 my $hash = $EMPTYBLOB;
263 my $blobmode = '100644';
267 $hash = '0000000000000000000000000000000000000000';
270 foreach my $tag (@_) {
271 my $tagpath = 'tags/' . encode_for_fs ($msgid) . '/' . encode_for_fs ($tag);
272 print $fh "$blobmode $hash\t$tagpath\n";
278 do_sync (action => 'checkout');
281 sub quote_for_xapian {
284 return '"' . $str . '"';
287 sub pair_to_batch_line {
288 my ($action, $pair) = @_;
290 # the tag should already be suitably encoded
292 return $action . $ENCPREFIX . $pair->{tag} .
293 ' -- id:' . quote_for_xapian ($pair->{id})."\n";
300 my $status = compute_status ();
301 my ($A_action, $D_action);
303 if ($args{action} eq 'checkout') {
311 my $notmuch = spawn ({}, '|-', qw/notmuch tag --batch/)
312 or die 'notmuch tag --batch';
314 foreach my $pair (@{$status->{added}}) {
315 print $notmuch pair_to_batch_line ($A_action, $pair);
318 foreach my $pair (@{$status->{deleted}}) {
319 print $notmuch pair_to_batch_line ($D_action, $pair);
322 unless (close $notmuch) {
323 die "'notmuch tag --batch' exited with nonzero value\n";
328 sub insist_committed {
330 my $status=compute_status();
331 if ( !is_committed ($status) ) {
332 print "Uncommitted changes to $TAGPREFIX* tags in notmuch
334 For a summary of changes, run 'nmbug status'
335 To save your changes, run 'nmbug commit' before merging/pull
336 To discard your changes, run 'nmbug checkout'
345 my $remote = shift || 'origin';
346 my $branch = shift || 'master';
348 git ( 'fetch', $remote);
350 do_merge ("$remote/$branch");
355 my $commit = shift || '@{upstream}';
359 my $tempwork = tempdir ('/tmp/nmbug-merge.XXXXXX', CLEANUP => 1);
361 git ( { GIT_WORK_TREE => $tempwork }, 'checkout', '-f', 'HEAD');
363 git ( { GIT_WORK_TREE => $tempwork }, 'merge', $commit);
370 # we don't want output trapping here, because we want the pager.
371 system ( 'git', "--git-dir=$NMBGIT", 'log', '--name-status', @_);
376 my $remote = shift || 'origin';
378 git ('push', $remote, 'master');
383 my $status = compute_status ();
386 foreach my $pair (@{$status->{added}}) {
387 $output{$pair->{id}} ||= {};
388 $output{$pair->{id}}{$pair->{tag}} = 'A'
391 foreach my $pair (@{$status->{deleted}}) {
392 $output{$pair->{id}} ||= {};
393 $output{$pair->{id}}{$pair->{tag}} = 'D'
396 foreach my $pair (@{$status->{missing}}) {
397 $output{$pair->{id}} ||= {};
398 $output{$pair->{id}}{$pair->{tag}} = 'U'
401 if (is_unmerged ()) {
402 foreach my $pair (diff_refs ('A')) {
403 $output{$pair->{id}} ||= {};
404 $output{$pair->{id}}{$pair->{tag}} ||= ' ';
405 $output{$pair->{id}}{$pair->{tag}} .= 'a';
408 foreach my $pair (diff_refs ('D')) {
409 $output{$pair->{id}} ||= {};
410 $output{$pair->{id}}{$pair->{tag}} ||= ' ';
411 $output{$pair->{id}}{$pair->{tag}} .= 'd';
415 foreach my $id (sort keys %output) {
416 foreach my $tag (sort keys %{$output{$id}}) {
417 printf "%s\t%s\t%s\n", $output{$id}{$tag}, $id, $tag;
424 my $commit = shift || '@{upstream}';
426 my $fetch_head = git ('rev-parse', $commit);
427 my $base = git ( 'merge-base', 'HEAD', $commit);
429 return ($base ne $fetch_head);
440 my $index = index_tags ();
442 my @maybe_deleted = diff_index ($index, 'D');
444 foreach my $pair (@maybe_deleted) {
446 my $id = $pair->{id};
448 my $fh = spawn ('-|', qw/notmuch search --output=files/,"id:$id")
449 or die "searching for $id";
451 push @missing, $pair;
453 push @deleted, $pair;
456 die "'notmuch search --output=files id:$id' exited with nonzero value\n";
461 @added = diff_index ($index, 'A');
463 unlink $index || die "unlink $index: $!";
465 return { added => [@added], deleted => [@deleted], missing => [@missing] };
473 my $fh = git_pipe ({ GIT_INDEX_FILE => $index },
474 qw/diff-index --cached/,
475 "--diff-filter=$filter", qw/--name-only HEAD/ );
477 my @lines = unpack_diff_lines ($fh);
479 die "'git diff-index --cached --diff-filter=$filter --name-only HEAD' ",
480 "exited with nonzero value\n";
488 my $ref1 = shift || 'HEAD';
489 my $ref2 = shift || '@{upstream}';
491 my $fh= git_pipe ( 'diff', "--diff-filter=$filter", '--name-only',
494 my @lines = unpack_diff_lines ($fh);
496 die "'git diff --diff-filter=$filter --name-only $ref1 $ref2' ",
497 "exited with nonzero value\n";
503 sub unpack_diff_lines {
509 my ($id,$tag) = m|tags/ ([^/]+) / ([^/]+) |x;
511 $id = decode_from_fs ($id);
512 $tag = decode_from_fs ($tag);
514 push @found, { id => $id, tag => $tag };
524 $str =~ s/($MUST_ENCODE)/"$ESCAPE_CHAR".sprintf ("%02x",ord ($1))/ge;
532 $str =~ s/$ESCAPED_RX/ chr (hex ($1))/eg;
546 pod2usage ( -verbose => 2 );
554 nmbug - manage notmuch tags about notmuch
558 nmbug subcommand [options]
560 B<nmbug help> for more help
564 =head2 Most common commands
568 =item B<commit> [message]
570 Commit appropriately prefixed tags from the notmuch database to
571 git. Any extra arguments are used (one per line) as a commit message.
573 =item B<push> [remote]
575 push local nmbug git state to remote repo
577 =item B<pull> [remote] [branch]
579 pull (merge) remote repo changes to notmuch. B<pull> is equivalent to
580 B<fetch> followed by B<merge>. The default remote is C<origin>, and
581 the default branch is C<master>.
585 =head2 Other Useful Commands
589 =item B<clone> repository
591 Create a local nmbug repository from a remote source. This wraps
592 C<git clone>, adding some options to avoid creating a working tree
593 while preserving remote-tracking branches and upstreams.
597 Update the notmuch database from git. This is mainly useful to discard
598 your changes in notmuch relative to git.
600 =item B<fetch> [remote]
602 Fetch changes from the remote repo (see merge to bring those changes
605 =item B<help> [subcommand]
607 print help [for subcommand]
609 =item B<log> [parameters]
611 A simple wrapper for git log. After running C<nmbug fetch>, you can
612 inspect the changes with C<nmbug log HEAD..@{upstream}>
614 =item B<merge> [commit]
616 Merge changes from C<commit> into HEAD, and load the result into
617 notmuch. The default commit is C<@{upstream}>.
621 Show pending updates in notmuch or git repo. See below for more
622 information about the output format.
626 =head2 Less common commands
632 Dump a tar archive (using git archive) of the current nmbug tag set.
638 B<nmbug status> prints lines of the form
642 where n is a single character representing notmuch database status
648 Tag is present in notmuch database, but not committed to nmbug
649 (equivalently, tag has been deleted in nmbug repo, e.g. by a pull, but
650 not restored to notmuch database).
654 Tag is present in nmbug repo, but not restored to notmuch database
655 (equivalently, tag has been deleted in notmuch)
659 Message is unknown (missing from local notmuch database)
663 The second character (if present) represents a difference between remote
664 git and local. Typically C<nmbug fetch> needs to be run to update this.
671 Tag is present in remote, but not in local git.
676 Tag is present in local git, but not in remote git.
683 Each tag $tag for message with Message-Id $id is written to
686 tags/encode($id)/encode($tag)
688 The encoding preserves alphanumerics, and the characters "+-_@=.:,"
689 (not the quotes). All other octets are replaced with '%' followed by
690 a two digit hex number.
694 B<NMBGIT> specifies the location of the git repository used by nmbug.
695 If not specified $HOME/.nmbug is used.
697 B<NMBPREFIX> specifies the prefix in the notmuch database for tags of
698 interest to nmbug. If not specified 'notmuch::' is used.