git-contacts (4513B)
1 #!/usr/bin/env perl 2 3 # List people who might be interested in a patch. Useful as the argument to 4 # git-send-email --cc-cmd option, and in other situations. 5 # 6 # Usage: git contacts <file | rev-list option> ... 7 8 use strict; 9 use warnings; 10 use IPC::Open2; 11 12 my $since = '5-years-ago'; 13 my $min_percent = 10; 14 my $labels_rx = qr/Signed-off-by|Reviewed-by|Acked-by|Cc|Reported-by/i; 15 my %seen; 16 17 sub format_contact { 18 my ($name, $email) = @_; 19 return "$name <$email>"; 20 } 21 22 sub parse_commit { 23 my ($commit, $data) = @_; 24 my $contacts = $commit->{contacts}; 25 my $inbody = 0; 26 for (split(/^/m, $data)) { 27 if (not $inbody) { 28 if (/^author ([^<>]+) <(\S+)> .+$/) { 29 $contacts->{format_contact($1, $2)} = 1; 30 } elsif (/^$/) { 31 $inbody = 1; 32 } 33 } elsif (/^$labels_rx:\s+([^<>]+)\s+<(\S+?)>$/o) { 34 $contacts->{format_contact($1, $2)} = 1; 35 } 36 } 37 } 38 39 sub import_commits { 40 my ($commits) = @_; 41 return unless %$commits; 42 my $pid = open2 my $reader, my $writer, qw(git cat-file --batch); 43 for my $id (keys(%$commits)) { 44 print $writer "$id\n"; 45 my $line = <$reader>; 46 if ($line =~ /^([0-9a-f]{40}) commit (\d+)/) { 47 my ($cid, $len) = ($1, $2); 48 die "expected $id but got $cid\n" unless $id eq $cid; 49 my $data; 50 # cat-file emits newline after data, so read len+1 51 read $reader, $data, $len + 1; 52 parse_commit($commits->{$id}, $data); 53 } 54 } 55 close $reader; 56 close $writer; 57 waitpid($pid, 0); 58 die "git-cat-file error: $?\n" if $?; 59 } 60 61 sub get_blame { 62 my ($commits, $source, $from, $ranges) = @_; 63 return unless @$ranges; 64 open my $f, '-|', 65 qw(git blame --porcelain -C), 66 map({"-L$_->[0],+$_->[1]"} @$ranges), 67 '--since', $since, "$from^", '--', $source or die; 68 while (<$f>) { 69 if (/^([0-9a-f]{40}) \d+ \d+ \d+$/) { 70 my $id = $1; 71 $commits->{$id} = { id => $id, contacts => {} } 72 unless $seen{$id}; 73 $seen{$id} = 1; 74 } 75 } 76 close $f; 77 } 78 79 sub blame_sources { 80 my ($sources, $commits) = @_; 81 for my $s (keys %$sources) { 82 for my $id (keys %{$sources->{$s}}) { 83 get_blame($commits, $s, $id, $sources->{$s}{$id}); 84 } 85 } 86 } 87 88 sub scan_patches { 89 my ($sources, $id, $f) = @_; 90 my $source; 91 while (<$f>) { 92 if (/^From ([0-9a-f]{40}) Mon Sep 17 00:00:00 2001$/) { 93 $id = $1; 94 $seen{$id} = 1; 95 } 96 next unless $id; 97 if (m{^--- (?:a/(.+)|/dev/null)$}) { 98 $source = $1; 99 } elsif (/^@@ -(\d+)(?:,(\d+))?/ && $source) { 100 my $len = defined($2) ? $2 : 1; 101 push @{$sources->{$source}{$id}}, [$1, $len] if $len; 102 } 103 } 104 } 105 106 sub scan_patch_file { 107 my ($commits, $file) = @_; 108 open my $f, '<', $file or die "read failure: $file: $!\n"; 109 scan_patches($commits, undef, $f); 110 close $f; 111 } 112 113 sub parse_rev_args { 114 my @args = @_; 115 open my $f, '-|', 116 qw(git rev-parse --revs-only --default HEAD --symbolic), @args 117 or die; 118 my @revs; 119 while (<$f>) { 120 chomp; 121 push @revs, $_; 122 } 123 close $f; 124 return @revs if scalar(@revs) != 1; 125 return "^$revs[0]", 'HEAD' unless $revs[0] =~ /^-/; 126 return $revs[0], 'HEAD'; 127 } 128 129 sub scan_rev_args { 130 my ($commits, $args) = @_; 131 my @revs = parse_rev_args(@$args); 132 open my $f, '-|', qw(git rev-list --reverse), @revs or die; 133 while (<$f>) { 134 chomp; 135 my $id = $_; 136 $seen{$id} = 1; 137 open my $g, '-|', qw(git show -C --oneline), $id or die; 138 scan_patches($commits, $id, $g); 139 close $g; 140 } 141 close $f; 142 } 143 144 sub mailmap_contacts { 145 my ($contacts) = @_; 146 my %mapped; 147 my $pid = open2 my $reader, my $writer, qw(git check-mailmap --stdin); 148 for my $contact (keys(%$contacts)) { 149 print $writer "$contact\n"; 150 my $canonical = <$reader>; 151 chomp $canonical; 152 $mapped{$canonical} += $contacts->{$contact}; 153 } 154 close $reader; 155 close $writer; 156 waitpid($pid, 0); 157 die "git-check-mailmap error: $?\n" if $?; 158 return \%mapped; 159 } 160 161 if (!@ARGV) { 162 die "No input revisions or patch files\n"; 163 } 164 165 my (@files, @rev_args); 166 for (@ARGV) { 167 if (-e) { 168 push @files, $_; 169 } else { 170 push @rev_args, $_; 171 } 172 } 173 174 my %sources; 175 for (@files) { 176 scan_patch_file(\%sources, $_); 177 } 178 if (@rev_args) { 179 scan_rev_args(\%sources, \@rev_args) 180 } 181 182 my $toplevel = `git rev-parse --show-toplevel`; 183 chomp $toplevel; 184 chdir($toplevel) or die "chdir failure: $toplevel: $!\n"; 185 186 my %commits; 187 blame_sources(\%sources, \%commits); 188 import_commits(\%commits); 189 190 my $contacts = {}; 191 for my $commit (values %commits) { 192 for my $contact (keys %{$commit->{contacts}}) { 193 $contacts->{$contact}++; 194 } 195 } 196 $contacts = mailmap_contacts($contacts); 197 198 my $ncommits = scalar(keys %commits); 199 for my $contact (keys %$contacts) { 200 my $percent = $contacts->{$contact} * 100 / $ncommits; 201 next if $percent < $min_percent; 202 print "$contact\n"; 203 }