]> git.notmuchmail.org Git - notmuch/blob - devel/nmbug/nmbug
b18ded7b7c1e48cf7b54a1e6901cb92c7cbf1961
[notmuch] / devel / nmbug / nmbug
1 #!/usr/bin/env perl
2 # Copyright (c) 2011 David Bremner
3 # License: same as notmuch
4
5 use strict;
6 use warnings;
7 use File::Temp qw(tempdir);
8 use Pod::Usage;
9
10 no encoding;
11
12 my $NMBGIT = $ENV{NMBGIT} || $ENV{HOME}.'/.nmbug';
13
14 $NMBGIT .= '/.git' if (-d $NMBGIT.'/.git');
15
16 my $TAGPREFIX = defined($ENV{NMBPREFIX}) ? $ENV{NMBPREFIX} : 'notmuch::';
17
18 # for encoding
19
20 my $ESCAPE_CHAR =       '%';
21 my $NO_ESCAPE =         'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.
22                         '0123456789+-_@=.:,';
23 my $MUST_ENCODE =       qr{[^\Q$NO_ESCAPE\E]};
24 my $ESCAPED_RX =        qr{$ESCAPE_CHAR([A-Fa-f0-9]{2})};
25
26 my %command = (
27              archive    => \&do_archive,
28              checkout   => \&do_checkout,
29              clone      => \&do_clone,
30              commit     => \&do_commit,
31              fetch      => \&do_fetch,
32              help       => \&do_help,
33              log        => \&do_log,
34              merge      => \&do_merge,
35              pull       => \&do_pull,
36              push       => \&do_push,
37              status     => \&do_status,
38              );
39
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;
44
45 my $subcommand = shift || usage ();
46
47 if (!exists $command{$subcommand}) {
48   usage ();
49 }
50
51 # magic hash for git
52 my $EMPTYBLOB = git (qw{hash-object -t blob /dev/null});
53
54 &{$command{$subcommand}}(@ARGV);
55
56 sub git_pipe {
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;
60
61   unshift @_, 'git';
62   $envref->{GIT_DIR} ||= $NMBGIT;
63   spawn ($envref, defined $ioref ? $ioref : (), defined $dir ? $dir : (), @_);
64 }
65
66 sub git {
67   my $fh = git_pipe (@_);
68   my $str = join ('', <$fh>);
69   unless (close $fh) {
70     die "'git @_' exited with nonzero value\n";
71   }
72   chomp($str);
73   return $str;
74 }
75
76 sub spawn {
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 : '-|';
80
81   die unless @_;
82
83   if (open my $child, $dir) {
84     return $child;
85   }
86   # child
87   while (my ($key, $value) = each %{$envref}) {
88     $ENV{$key} = $value;
89   }
90
91   if (defined $ioref && $dir eq '-|') {
92       open my $fh, '|-', @_ or die "open |- @_: $!";
93       foreach my $line (@{$ioref}) {
94         print $fh $line, "\n";
95       }
96       exit ! close $fh;
97     } else {
98       if ($dir ne '|-') {
99         open STDIN, '<', '/dev/null' or die "reopening stdin: $!"
100       }
101       exec @_;
102       die "exec @_: $!";
103     }
104 }
105
106
107 sub get_tags {
108   my $prefix = shift;
109   my @tags;
110
111   my $fh = spawn ('-|', qw/notmuch search --output=tags/, "*")
112     or die 'error dumping tags';
113
114   while (<$fh>) {
115     chomp ();
116     push @tags, $_ if (m/^$prefix/);
117   }
118   unless (close $fh) {
119     die "'notmuch search --output=tags *' exited with nonzero value\n";
120   }
121   return @tags;
122 }
123
124
125 sub do_archive {
126   system ('git', "--git-dir=$NMBGIT", 'archive', 'HEAD');
127 }
128
129 sub do_clone {
130   my $repository = shift;
131
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');
138 }
139
140 sub is_committed {
141   my $status = shift;
142   return scalar (@{$status->{added}} ) + scalar (@{$status->{deleted}} ) == 0;
143 }
144
145
146 sub do_commit {
147   my @args = @_;
148
149   my $status = compute_status ();
150
151   if ( is_committed ($status) ) {
152     print "Nothing to commit\n";
153     return;
154   }
155
156   my $index = read_tree ('HEAD');
157
158   update_index ($index, $status);
159
160   my $tree = git ( { GIT_INDEX_FILE => $index }, 'write-tree')
161     or die 'no output from write-tree';
162
163   my $parent = git ( 'rev-parse', 'HEAD'  )
164     or die 'no output from rev-parse';
165
166   my $commit = git ([ @args ], 'commit-tree', $tree, '-p', $parent)
167     or die 'commit-tree';
168
169   git ('update-ref', 'HEAD', $commit);
170
171   unlink $index || die "unlink: $!";
172
173 }
174
175 sub read_tree {
176   my $treeish = shift;
177   my $index = $NMBGIT.'/nmbug.index';
178   git ({ GIT_INDEX_FILE => $index }, 'read-tree', '--empty');
179   git ({ GIT_INDEX_FILE => $index }, 'read-tree', $treeish);
180   return $index;
181 }
182
183 sub update_index {
184   my $index = shift;
185   my $status = shift;
186
187   my $git = spawn ({ GIT_DIR => $NMBGIT, GIT_INDEX_FILE => $index },
188                      '|-', qw/git update-index --index-info/)
189     or die 'git update-index';
190
191   foreach my $pair (@{$status->{deleted}}) {
192     index_tags_for_msg ($git, $pair->{id}, 'D', $pair->{tag});
193   }
194
195   foreach my $pair (@{$status->{added}}) {
196     index_tags_for_msg ($git, $pair->{id}, 'A', $pair->{tag});
197   }
198   unless (close $git) {
199     die "'git update-index --index-info' exited with nonzero value\n";
200   }
201
202 }
203
204
205 sub do_fetch {
206   my $remote = shift || 'origin';
207
208   git ('fetch', $remote);
209 }
210
211
212 sub notmuch {
213   my @args = @_;
214   system ('notmuch', @args) == 0 or die  "notmuch @args failed: $?";
215 }
216
217
218 sub index_tags {
219
220   my $index = $NMBGIT.'/nmbug.index';
221
222   my $query = join ' ', map ("tag:\"$_\"", get_tags ($TAGPREFIX));
223
224   my $fh = spawn ('-|', qw/notmuch dump --format=batch-tag --/, $query)
225     or die "notmuch dump: $!";
226
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';
231
232   while (<$fh>) {
233
234     chomp();
235     my ($rest,$id) = split(/ -- id:/);
236
237     if ($id =~ s/^"(.*)"\s*$/$1/) {
238       # xapian quoted string, dequote.
239       $id =~ s/""/"/g;
240     }
241
242     #strip prefixes from tags before writing
243     my @tags = grep { s/^[+]$ENCPREFIX//; } split (' ', $rest);
244     index_tags_for_msg ($git,$id, 'A', @tags);
245   }
246   unless (close $git) {
247     die "'git update-index --index-info' exited with nonzero value\n";
248   }
249   unless (close $fh) {
250     die "'notmuch dump --format=batch-tag -- $query' exited with nonzero value\n";
251   }
252   return $index;
253 }
254
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 {
258   my $fh = shift;
259   my $msgid = shift;
260   my $mode = shift;
261
262   my $hash = $EMPTYBLOB;
263   my $blobmode = '100644';
264
265   if ($mode eq 'D') {
266     $blobmode = '0';
267     $hash = '0000000000000000000000000000000000000000';
268   }
269
270   foreach my $tag (@_) {
271     my $tagpath = 'tags/' . encode_for_fs ($msgid) . '/' . encode_for_fs ($tag);
272     print $fh "$blobmode $hash\t$tagpath\n";
273   }
274 }
275
276
277 sub do_checkout {
278   do_sync (action => 'checkout');
279 }
280
281 sub quote_for_xapian {
282   my $str = shift;
283   $str =~ s/"/""/g;
284   return '"' . $str . '"';
285 }
286
287 sub pair_to_batch_line {
288   my ($action, $pair) = @_;
289
290   # the tag should already be suitably encoded
291
292   return $action . $ENCPREFIX . $pair->{tag} .
293     ' -- id:' . quote_for_xapian ($pair->{id})."\n";
294 }
295
296 sub do_sync {
297
298   my %args = @_;
299
300   my $status = compute_status ();
301   my ($A_action, $D_action);
302
303   if ($args{action} eq 'checkout') {
304     $A_action = '-';
305     $D_action = '+';
306   } else {
307     $A_action = '+';
308     $D_action = '-';
309   }
310
311   my $notmuch = spawn ({}, '|-', qw/notmuch tag --batch/)
312     or die 'notmuch tag --batch';
313
314   foreach my $pair (@{$status->{added}}) {
315     print $notmuch pair_to_batch_line ($A_action, $pair);
316   }
317
318   foreach my $pair (@{$status->{deleted}}) {
319     print $notmuch pair_to_batch_line ($D_action, $pair);
320   }
321
322   unless (close $notmuch) {
323     die "'notmuch tag --batch' exited with nonzero value\n";
324   }
325 }
326
327
328 sub insist_committed {
329
330   my $status=compute_status();
331   if ( !is_committed ($status) ) {
332     print "Uncommitted changes to $TAGPREFIX* tags in notmuch
333
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'
337 ";
338     exit (1);
339   }
340
341 }
342
343
344 sub do_pull {
345   my $remote = shift || 'origin';
346   my $branch = shift || 'master';
347
348   git ( 'fetch', $remote);
349
350   do_merge ("$remote/$branch");
351 }
352
353
354 sub do_merge {
355   my $commit = shift || '@{upstream}';
356
357   insist_committed ();
358
359   my $tempwork = tempdir ('/tmp/nmbug-merge.XXXXXX', CLEANUP => 1);
360
361   git ( { GIT_WORK_TREE => $tempwork }, 'checkout', '-f', 'HEAD');
362
363   git ( { GIT_WORK_TREE => $tempwork }, 'merge', $commit);
364
365   do_checkout ();
366 }
367
368
369 sub do_log {
370   # we don't want output trapping here, because we want the pager.
371   system ( 'git', "--git-dir=$NMBGIT", 'log', '--name-status', @_);
372 }
373
374
375 sub do_push {
376   my $remote = shift || 'origin';
377
378   git ('push', $remote, 'master');
379 }
380
381
382 sub do_status {
383   my $status = compute_status ();
384
385   my %output = ();
386   foreach my $pair (@{$status->{added}}) {
387     $output{$pair->{id}} ||= {};
388     $output{$pair->{id}}{$pair->{tag}} = 'A'
389   }
390
391   foreach my $pair (@{$status->{deleted}}) {
392     $output{$pair->{id}} ||= {};
393     $output{$pair->{id}}{$pair->{tag}} = 'D'
394   }
395
396   foreach my $pair (@{$status->{missing}}) {
397     $output{$pair->{id}} ||= {};
398     $output{$pair->{id}}{$pair->{tag}} = 'U'
399   }
400
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';
406     }
407
408     foreach my $pair (diff_refs ('D')) {
409       $output{$pair->{id}} ||= {};
410       $output{$pair->{id}}{$pair->{tag}} ||= ' ';
411       $output{$pair->{id}}{$pair->{tag}} .= 'd';
412     }
413   }
414
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;
418     }
419   }
420 }
421
422
423 sub is_unmerged {
424   my $commit = shift || '@{upstream}';
425
426   my $fetch_head = git ('rev-parse', $commit);
427   my $base = git ( 'merge-base', 'HEAD', $commit);
428
429   return ($base ne $fetch_head);
430
431 }
432
433 sub compute_status {
434   my %args = @_;
435
436   my @added;
437   my @deleted;
438   my @missing;
439
440   my $index = index_tags ();
441
442   my @maybe_deleted = diff_index ($index, 'D');
443
444   foreach my $pair (@maybe_deleted) {
445
446     my $id = $pair->{id};
447
448     my $fh = spawn ('-|', qw/notmuch search --output=files/,"id:$id")
449       or die "searching for $id";
450     if (!<$fh>) {
451       push @missing, $pair;
452     } else {
453       push @deleted, $pair;
454     }
455     unless (close $fh) {
456       die "'notmuch search --output=files id:$id' exited with nonzero value\n";
457     }
458   }
459
460
461   @added = diff_index ($index, 'A');
462
463   unlink $index || die "unlink $index: $!";
464
465   return { added => [@added], deleted => [@deleted], missing => [@missing] };
466 }
467
468
469 sub diff_index {
470   my $index = shift;
471   my $filter = shift;
472
473   my $fh = git_pipe ({ GIT_INDEX_FILE => $index },
474                   qw/diff-index --cached/,
475                  "--diff-filter=$filter", qw/--name-only HEAD/ );
476
477   my @lines = unpack_diff_lines ($fh);
478   unless (close $fh) {
479     die "'git diff-index --cached --diff-filter=$filter --name-only HEAD' ",
480         "exited with nonzero value\n";
481   }
482   return @lines;
483 }
484
485
486 sub diff_refs {
487   my $filter = shift;
488   my $ref1 = shift || 'HEAD';
489   my $ref2 = shift || '@{upstream}';
490
491   my $fh= git_pipe ( 'diff', "--diff-filter=$filter", '--name-only',
492                  $ref1, $ref2);
493
494   my @lines = unpack_diff_lines ($fh);
495   unless (close $fh) {
496     die "'git diff --diff-filter=$filter --name-only $ref1 $ref2' ",
497         "exited with nonzero value\n";
498   }
499   return @lines;
500 }
501
502
503 sub unpack_diff_lines {
504   my $fh = shift;
505
506   my @found;
507   while(<$fh>) {
508     chomp ();
509     my ($id,$tag) = m|tags/ ([^/]+) / ([^/]+) |x;
510
511     $id = decode_from_fs ($id);
512     $tag = decode_from_fs ($tag);
513
514     push @found, { id => $id, tag => $tag };
515   }
516
517   return @found;
518 }
519
520
521 sub encode_for_fs {
522   my $str = shift;
523
524   $str =~ s/($MUST_ENCODE)/"$ESCAPE_CHAR".sprintf ("%02x",ord ($1))/ge;
525   return $str;
526 }
527
528
529 sub decode_from_fs {
530   my $str = shift;
531
532   $str =~ s/$ESCAPED_RX/ chr (hex ($1))/eg;
533
534   return $str;
535
536 }
537
538
539 sub usage {
540   pod2usage ();
541   exit (1);
542 }
543
544
545 sub do_help {
546   pod2usage ( -verbose => 2 );
547   exit (0);
548 }
549
550 __END__
551
552 =head1 NAME
553
554 nmbug - manage notmuch tags about notmuch
555
556 =head1 SYNOPSIS
557
558 nmbug subcommand [options]
559
560 B<nmbug help> for more help
561
562 =head1 OPTIONS
563
564 =head2 Most common commands
565
566 =over 8
567
568 =item B<commit> [message]
569
570 Commit appropriately prefixed tags from the notmuch database to
571 git. Any extra arguments are used (one per line) as a commit message.
572
573 =item  B<push> [remote]
574
575 push local nmbug git state to remote repo
576
577 =item  B<pull> [remote] [branch]
578
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>.
582
583 =back
584
585 =head2 Other Useful Commands
586
587 =over 8
588
589 =item B<clone> repository
590
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.
594
595 =item B<checkout>
596
597 Update the notmuch database from git. This is mainly useful to discard
598 your changes in notmuch relative to git.
599
600 =item B<fetch> [remote]
601
602 Fetch changes from the remote repo (see merge to bring those changes
603 into notmuch).
604
605 =item B<help> [subcommand]
606
607 print help [for subcommand]
608
609 =item B<log> [parameters]
610
611 A simple wrapper for git log. After running C<nmbug fetch>, you can
612 inspect the changes with C<nmbug log HEAD..@{upstream}>
613
614 =item B<merge> [commit]
615
616 Merge changes from C<commit> into HEAD, and load the result into
617 notmuch.  The default commit is C<@{upstream}>.
618
619 =item  B<status>
620
621 Show pending updates in notmuch or git repo. See below for more
622 information about the output format.
623
624 =back
625
626 =head2 Less common commands
627
628 =over 8
629
630 =item B<archive>
631
632 Dump a tar archive (using git archive) of the current nmbug tag set.
633
634 =back
635
636 =head1 STATUS FORMAT
637
638 B<nmbug status> prints lines of the form
639
640    ng Message-Id tag
641
642 where n is a single character representing notmuch database status
643
644 =over 8
645
646 =item B<A>
647
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).
651
652 =item B<D>
653
654 Tag is present in nmbug repo, but not restored to notmuch database
655 (equivalently, tag has been deleted in notmuch)
656
657 =item B<U>
658
659 Message is unknown (missing from local notmuch database)
660
661 =back
662
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.
665
666 =over 8
667
668
669 =item B<a>
670
671 Tag is present in remote, but not in local git.
672
673
674 =item B<d>
675
676 Tag is present in local git, but not in remote git.
677
678
679 =back
680
681 =head1 DUMP FORMAT
682
683 Each tag $tag for message with Message-Id $id is written to
684 an empty file
685
686         tags/encode($id)/encode($tag)
687
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.
691
692 =head1 ENVIRONMENT
693
694 B<NMBGIT> specifies the location of the git repository used by nmbug.
695 If not specified $HOME/.nmbug is used.
696
697 B<NMBPREFIX> specifies the prefix in the notmuch database for tags of
698 interest to nmbug. If not specified 'notmuch::' is used.