| #!/usr/bin/perl | |
| # List people who might be interested in a patch. Useful as the argument to | |
| # git-send-email --cc-cmd option, and in other situations. | |
| # | |
| # Usage: git contacts <file | '-' | rev-list option> ... | |
| use strict; | |
| use warnings; | |
| use IPC::Open2; | |
| my $since = '5-years-ago'; | |
| my $min_percent = 10; | |
| my $labels_rx = qr/Signed-off-by|Reviewed-by|Acked-by|Cc|Reported-by/i; | |
| my %seen; | |
| sub format_contact { | |
| my ($name, $email) = @_; | |
| return "$name <$email>"; | |
| } | |
| sub parse_commit { | |
| my ($commit, $data) = @_; | |
| my $contacts = $commit->{contacts}; | |
| my $inbody = 0; | |
| for (split(/^/m, $data)) { | |
| if (not $inbody) { | |
| if (/^author ([^<>]+) <(\S+)> .+$/) { | |
| $contacts->{format_contact($1, $2)} = 1; | |
| } elsif (/^$/) { | |
| $inbody = 1; | |
| } | |
| } elsif (/^$labels_rx:\s+([^<>]+)\s+<(\S+?)>$/o) { | |
| $contacts->{format_contact($1, $2)} = 1; | |
| } | |
| } | |
| } | |
| sub import_commits { | |
| my ($commits) = @_; | |
| return unless %$commits; | |
| my $pid = open2 my $reader, my $writer, qw(git cat-file --batch); | |
| for my $id (keys(%$commits)) { | |
| print $writer "$id\n"; | |
| my $line = <$reader>; | |
| if ($line =~ /^([0-9a-f]{40}) commit (\d+)/) { | |
| my ($cid, $len) = ($1, $2); | |
| die "expected $id but got $cid\n" unless $id eq $cid; | |
| my $data; | |
| # cat-file emits newline after data, so read len+1 | |
| read $reader, $data, $len + 1; | |
| parse_commit($commits->{$id}, $data); | |
| } | |
| } | |
| close $reader; | |
| close $writer; | |
| waitpid($pid, 0); | |
| die "git-cat-file error: $?\n" if $?; | |
| } | |
| sub get_blame { | |
| my ($commits, $source, $from, $ranges) = @_; | |
| return unless @$ranges; | |
| open my $f, '-|', | |
| qw(git blame --porcelain -C), | |
| map({"-L$_->[0],+$_->[1]"} @$ranges), | |
| '--since', $since, "$from^", '--', $source or die; | |
| while (<$f>) { | |
| if (/^([0-9a-f]{40}) \d+ \d+ \d+$/) { | |
| my $id = $1; | |
| $commits->{$id} = { id => $id, contacts => {} } | |
| unless $seen{$id}; | |
| $seen{$id} = 1; | |
| } | |
| } | |
| close $f; | |
| } | |
| sub blame_sources { | |
| my ($sources, $commits) = @_; | |
| for my $s (keys %$sources) { | |
| for my $id (keys %{$sources->{$s}}) { | |
| get_blame($commits, $s, $id, $sources->{$s}{$id}); | |
| } | |
| } | |
| } | |
| sub scan_patches { | |
| my ($sources, $id, $f) = @_; | |
| my $source; | |
| while (<$f>) { | |
| if (/^From ([0-9a-f]{40}) Mon Sep 17 00:00:00 2001$/) { | |
| $id = $1; | |
| $seen{$id} = 1; | |
| } | |
| next unless $id; | |
| if (m{^--- (?:a/(.+)|/dev/null)$}) { | |
| $source = $1; | |
| } elsif (/^@@ -(\d+)(?:,(\d+))?/ && $source) { | |
| my $len = defined($2) ? $2 : 1; | |
| push @{$sources->{$source}{$id}}, [$1, $len] if $len; | |
| } | |
| } | |
| } | |
| sub scan_patch_file { | |
| my ($commits, $file) = @_; | |
| open my $f, '<', $file or die "read failure: $file: $!\n"; | |
| scan_patches($commits, undef, $f); | |
| close $f; | |
| } | |
| sub parse_rev_args { | |
| my @args = @_; | |
| open my $f, '-|', | |
| qw(git rev-parse --revs-only --default HEAD --symbolic), @args | |
| or die; | |
| my @revs; | |
| while (<$f>) { | |
| chomp; | |
| push @revs, $_; | |
| } | |
| close $f; | |
| return @revs if scalar(@revs) != 1; | |
| return "^$revs[0]", 'HEAD' unless $revs[0] =~ /^-/; | |
| return $revs[0], 'HEAD'; | |
| } | |
| sub scan_rev_args { | |
| my ($commits, $args) = @_; | |
| my @revs = parse_rev_args(@$args); | |
| open my $f, '-|', qw(git rev-list --reverse), @revs or die; | |
| while (<$f>) { | |
| chomp; | |
| my $id = $_; | |
| $seen{$id} = 1; | |
| open my $g, '-|', qw(git show -C --oneline), $id or die; | |
| scan_patches($commits, $id, $g); | |
| close $g; | |
| } | |
| close $f; | |
| } | |
| sub mailmap_contacts { | |
| my ($contacts) = @_; | |
| my %mapped; | |
| my $pid = open2 my $reader, my $writer, qw(git check-mailmap --stdin); | |
| for my $contact (keys(%$contacts)) { | |
| print $writer "$contact\n"; | |
| my $canonical = <$reader>; | |
| chomp $canonical; | |
| $mapped{$canonical} += $contacts->{$contact}; | |
| } | |
| close $reader; | |
| close $writer; | |
| waitpid($pid, 0); | |
| die "git-check-mailmap error: $?\n" if $?; | |
| return \%mapped; | |
| } | |
| if (!@ARGV) { | |
| die "No input revisions or patch files\n"; | |
| } | |
| my ($read_from_stdin, @files, @rev_args); | |
| for (@ARGV) { | |
| if ($_ eq '-') { | |
| $read_from_stdin = 1; | |
| } elsif (-e) { | |
| push @files, $_; | |
| } else { | |
| push @rev_args, $_; | |
| } | |
| } | |
| my %sources; | |
| if ($read_from_stdin) { | |
| scan_patches(\%sources, undef, \*STDIN); | |
| } | |
| for (@files) { | |
| scan_patch_file(\%sources, $_); | |
| } | |
| if (@rev_args) { | |
| scan_rev_args(\%sources, \@rev_args) | |
| } | |
| my $toplevel = `git rev-parse --show-toplevel`; | |
| chomp $toplevel; | |
| chdir($toplevel) or die "chdir failure: $toplevel: $!\n"; | |
| my %commits; | |
| blame_sources(\%sources, \%commits); | |
| import_commits(\%commits); | |
| my $contacts = {}; | |
| for my $commit (values %commits) { | |
| for my $contact (keys %{$commit->{contacts}}) { | |
| $contacts->{$contact}++; | |
| } | |
| } | |
| $contacts = mailmap_contacts($contacts); | |
| my $ncommits = scalar(keys %commits); | |
| for my $contact (keys %$contacts) { | |
| my $percent = $contacts->{$contact} * 100 / $ncommits; | |
| next if $percent < $min_percent; | |
| print "$contact\n"; | |
| } |