#!/usr/bin/perl ############################################################################# # Copyright (c) 2004,2005 Novell, Inc. # All Rights Reserved. # # This program is free software; you can redistribute it and/or # modify it under the terms of version 2 of the GNU General Public License as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, contact Novell, Inc. # # To contact Novell about this file by physical or electronic mail, # you may find current contact information at www.novell.com ############################################################################# # # patch-tag is meant to maintain a set of metadata tags in a diff. Multiple # files can be specified on the command line and all options can be # given more than once. # # All options can be abbreviated. --print is the same as -p # # All tags are changed so the first letter is uppercase and the rest are # lowercase. # # Example usage: # # patch-tag file # print the entire header before the diff starts # # patch-tag -e filename # Runs $EDITOR on filename. If there are no tags in the file yet # a default set of tags is filled in. See $default_comment for the # list. # # patch-tag -p Author -p Subject file # print the author and subject tags only # # patch-tag -s [-p tag] file # prints in summary form, default tags are Subject and References # # --print forces everything into readonly mode. If you specify --tag # along with --print, the file won't be changed although the output on stdout # will. # # patch-tag -P file # Prints only the comments other then tags in the file. # # patch-tag -t author=Mason -t subject="a patch to fix an oops" # Add or modify the author and subject tags. If more than one # author tag is already present in the comment, only the first will # be changed. # # patch-tag -a author -a Subject=patch # Add an empty author tag and Subject: patch tag to the patch, # but don't overwrite any existing values if these tags were present # already. # # patch-tag -a filename # Read in a list of tags for -a from filename # # patch-tag -A works the same as -a, but always adds the new tag, even # if one is already present. # # patch-tag -c filename # Read a whole new comment block from stdin for filename. # patch-tag -C string filename # Replace the non-tag comment with string if it does not exist # patch-tag -m filename # Concatenate multiline tags into one line # # The template files for -a can have comments starting with #. Only lines # starting with string: will be used as tags. The tags may have default # values. You can also place the template file into ~/.patchtag, it will # be used automatically # use strict; use Getopt::Long qw(:config no_ignore_case); use File::Temp; use IO::File; my $VERSION = "0.11"; my $default_comment = "from:subject:patch-mainline:Git-commit:references:signed-off-by:acked-by:reviewed-by:"; my $post_comment_tags = "signed-off-by acked-by reviewed-by"; # when these are in the bk comment, and non-bk section, use the non-bk one my $non_dup_tags = "from subject"; # command line options my %tags; # hash of tags to be replaced my %print_tags; # hash of tags for printing to stdout my %add_tags; # hash of tags to be added if not already present my %always_add_tags; # hash of tags to be added no matter what my %delete_tags; # tags to be deleted entirely my $new_comment; # boolean, replace comment with data read from stdin my $edit; # invoke $EDITOR when done my $multiline; # concatenate multiline tags my $print_comment_only; # print only the comment block my $summary; # print output tags in summary form my $guard; # pattern to use for pulling guards from the filename my $replace_empty_comment; # new value for empty non-tag comment # globals my @output_array; # the finished comment as printed my @all_tags; # array used to do final tag output my @bk_footer_tags; # holds signed-off-by and acked-by from bk my %replaced_tags; # copy of %tags so we can detect which ones are found my $replace = 0; # should we overwrite the patch file? my $outfh; # current output file handle (could be a temp file) my $infh; # current input file handle my @files; # list of all the files to be read my $input; # the current patch file we're reading my $ret; my $tag_re = '(^[^:\s#]*):(\s+|$)(.*)'; my $git_re = '^From ([0-9a-f]{40}) .*'; # mbox From line by git format-patch sub print_usage() { print STDERR "patch-tag version $VERSION\n"; print STDERR "usage: patch-tag.pl [-cePms ] [-C val] [-aAtpd tag=val] patch ...\n"; print STDERR "\t--print a given tag\n"; print STDERR "\t--comment replace the comment block with text from stdin\n"; print STDERR "\t--Comment val replace non-tag comment with val if it does not exist\n"; print STDERR "\t--edit invoke \$EDITOR on each file after processing\n"; print STDERR "\t--delete tag delete tag from header\n"; print STDERR "\t--tag tag[=value] Replace or add a given tag\n"; print STDERR "\t--add tag[=value] Add a tag if not already present\n"; print STDERR "\t--Add tag[=value] Unconditionally add a tag\n"; print STDERR "\t--add filename containing template of tags to add\n"; print STDERR "\t\t~/.patchtag will be used as a default template file\n"; print STDERR "\t--multiline concatenate multiline tags into one line\n"; print STDERR "\t--Print-comment prints only the comment block without tags\n"; print STDERR "\t--summary print output tags in summary form\n"; print STDERR "\nAll options can be specified more than once, example\n"; print STDERR "usage and additional docs at the top of this script\n"; exit(1); } # we want the hashes of tags in lower case, normalize whatever # crud the user sent us # sub lc_hash(%) { my (%h) = (@_); my %lch; my $tag; my $value; foreach my $k (keys(%h)) { $tag = lc($k); $value = $h{$k}; # did they use --opt "tag: value"? If so, turn it into a tag value pair if (($tag =~ m/(.+[^:]):\s*(.+)/) && $value eq "") { $tag = $1; $value = $2; } # strip off any : in the tag $tag =~ s/://g; $lch{$tag} = $value; } return %lch; } # check for and collect a multiline tag from the input stream sub peek_multi_line($$$) { my ($infh, $buf, $line) = @_; my $next; $next = read_next_line($infh, $buf); while($next =~ m/^\s/ && !($next =~ m/^\n/)) { if ($multiline) { chomp $line; } $line .= $next; $next = read_next_line($infh, $buf); } push @$buf, $next; return $line; } # do tag replacement and other checks for a specific tag/value pair # pushing it into the output tag array sub process_tag($$) { my ($t, $value) = @_; # only do replacement on the first tag with a given key if (defined($tags{$t}) && defined($replaced_tags{$t})) { $value = $tags{$t}; push_output_tag($t, $value); } elsif (defined($print_tags{$t})) { push_output_tag($t, $value); } elsif (!%print_tags) { push_output_tag($t, $value); } delete $replaced_tags{$t}; } # tags that get pulled from bk comments get special treatment sub process_bk_tag($$) { my ($tag, $value) = @_; my $always_tags = "signed-off-by acked-by reviewed-by"; if ($always_tags =~ m/$tag/) { push @bk_footer_tags, [$tag, $value]; return; } # don't pull the bk tag out if it already exists foreach my $v (@all_tags) { $v =~ m/$tag_re/; my $t = $1; if (lc($t) eq $tag) { return; } } $replaced_tags{$tag} = $value; } # look for any tags that we were asked to print or replace from # the command line. Build the array of tags found in the comment sub check_tags($$$) { my ($infh, $buf, $line) = @_; my $filespec = ""; my $bk_comment = 0; my $orig_line; my $bkold_style = 0; again: $orig_line = $line; if ($bk_comment) { $line =~ s/^#\s*//; } # Preserve git From line if ($line =~ m/$git_re/) { push @output_array, $orig_line; $line = read_next_line($infh, $buf); goto again; } if ($line =~ m/$tag_re/) { $line = peek_multi_line($infh, $buf, $line); # evaluate again in case the multi-line string changed # check it as a multi line re. Clean up trailing newlines and # ws $line =~ s/[\s\n]*$//gs; $line =~ m/(^[^:\s#]*):\s*(.*)/s; my $lc_tag = lc($1); my $value = $2; if ($bk_comment) { # only pull out specific tags from the bk comment stream if ($post_comment_tags =~ m/$lc_tag/) { process_bk_tag($lc_tag, $value); } if (!%print_tags) { push @output_array, $orig_line; } } else { process_tag($lc_tag, $value); } } elsif (!%print_tags) { push @output_array, $orig_line; } # did we find a bitkeeper style patch header? # if so, just parse the whole thing here if ($line =~ m/^# The following is the BitKeeper ChangeSet Log/ || $line =~ m/^# This is a BitKeeper generated diff -Nru style patch/) { # there are two bk patch styles # old: # # ------------------- # # date author changset # # subject # new: # # # # Changeset # # date time author # # subject $bk_comment = 1; my $next = read_next_line($infh, $buf); push @output_array, $next if (!%print_tags); if ($next =~ m/^# ---------/) { $bkold_style = 1; } else { # read empty line $next = read_next_line($infh, $buf); push @output_array, $next if (!%print_tags); } $next = read_next_line($infh, $buf); push @output_array, $next if (!%print_tags); my @words = split /\s+/, $next; if ($bkold_style) { process_bk_tag('from', $words[2]); } else { process_bk_tag('from', $words[3]); } $next = read_next_line($infh, $buf); push @output_array, $next if (!%print_tags); chomp($next); $next =~ s/^#\s*//; # sometimes the bk comment is empty and there is just a filename # there's no good way to tell. if (!($next =~ m/(.*\/)+?.*?\.(c|h|s)$/)) { process_bk_tag('subject', $next); } # we've read the from tag and subject tag, the goto again # will loop through the ChangeSet Log section looking # for other tags } if ($bk_comment) { $line = read_next_line($infh, $buf); # old style bk comments end with a line full of dashes # new style bk comments end with a # filename or no # at all if (($bkold_style && $line =~ m/^# --------/) || (!$bkold_style && $line =~ m/^# \S|^[^#]/)) { push @$buf, $line; return; } goto again; } } # print the array of tags found in the comment sub print_output_array($$) { my ($input_file, $array) = @_; my $filespec = ""; # if there is more then one file, include some info about which file # we're printing tags from if (scalar(@files) > 1 && %print_tags && !$summary) { $filespec = "$input_file: "; } elsif ($summary) { $filespec = "# "; } foreach my $s (@$array) { if ($summary) { $s =~ s/\n\s/\n#\t/mg; } print $outfh "${filespec}$s"; } if ($summary) { print $outfh "$input_file\n\n"; } } # for -a and -A, look for a filename as an arg instead of a tag. If found # fill in the hash with the contents of the file # sub fill_hash_from_file($) { my ($h) = @_; # look for tags to add either from the command line or template files if (%$h && keys(%$h) <= 1) { my ($k, $v) = each %$h; my $source; if (defined($k) && (!defined($v) || $v eq "")) { if (-f $k) { delete($h->{$k}); $source = $k; } } if (defined($source)) { print STDERR "Using $source as tag template source\n"; # delete($$h{$k}); my $template = new IO::File; $template->open("<$source") || die "Unable to open $source for reading"; while(<$template>) { # eat comments s/#.*//; # eat ws at the start of the line s/^\s//; if (m/$tag_re/) { $h->{lc($1)} = $3; } } $template->close(); return 1; } } return 0; } # helper function to pick the proper output array for the tags # some go before the comment block and some go after # send $allow_dup = 0 if you want to prevent duplicate tag names. # Completely duplicate tag name,value pairs are always removed. sub push_output_tag($$) { my ($tag, $value) = @_; my $uc_tag = ucfirst($tag); my $string = $uc_tag . ": $value"; # check against the delete hash if (defined($delete_tags{$tag})) { return; } # check for dups; foreach my $v (@all_tags) { if ($v eq $string) { return; } } push @all_tags, $string; push @output_array, "$string\n"; } # helper function to cherry pick output tags from a hash. # This is used to print the From and Subject Tags first. sub add_output_tag ($$) { my ($tag, $h) = @_; my $value; my $tag_end = 0; my $line; if (!defined($$h{$tag})) { return; } $value = $$h{$tag}; delete($$h{$tag}); # for post comment tags, just tack it onto the very end. if ($post_comment_tags =~ m/$tag/) { process_tag($tag, $value); return; } # find the end of the top tag section in the comment. foreach $line (@output_array) { # Account git From line as tag header if ($line =~ m/$git_re/) { $tag_end++; } elsif ($line =~ m/$tag_re/) { my $t = lc($1); # did we find our way into the post comment tag section? if ($post_comment_tags =~ m/$t/) { last; } $tag_end++; } else { last; } } my @tmp_array = @output_array[$tag_end .. scalar(@output_array)-1]; $#output_array = $tag_end - 1; process_tag($tag, $value); push @output_array, @tmp_array; } # from the much harder then it should be category. Walk the # comment block and divide it into three parts. Header, comment, # footer. Make sure each part is separated by no more then one # blank line. # sub cleanup_blank_lines($) { my ($ar) = @_; my $line; my $tag_end; my $footer_start; my @header_ar = (); my @comment_ar = (); my @footer_ar = (); # find the header foreach $line (@$ar) { if ($line =~ m/$tag_re/) { my $t = lc($1); # did we find our way into the post comment tag section? if ($post_comment_tags =~ m/$t/) { last; } $tag_end++; } else { last; } } if ($tag_end) { @header_ar = @$ar[0 .. $tag_end-1]; } # eat all the blank lines while($tag_end < scalar(@$ar)) { $line = $$ar[$tag_end]; if ($line =~ m/^\s*\n$/) { $tag_end++; } else { last; } } # $tag_end is now the start of the comment block. # pop ws off the end of the output array; while($$ar[scalar(@$ar)-1] =~ m/^\s*\n$/) { my $t = pop @$ar; } # walk up the array to find the end of the footer. for($footer_start = scalar(@$ar) - 1 ; $footer_start >= $tag_end; $footer_start--) { $line = $ar->[$footer_start]; if (!($line =~ m/$tag_re/)) { last; } } @footer_ar = @$ar[$footer_start+1 .. scalar(@$ar)-1]; # eat ws between the comment and the footer while($footer_start > $tag_end && $ar->[$footer_start] =~ m/^\s*\n$/) { $footer_start--; } @comment_ar = @$ar[$tag_end .. $footer_start]; if ($print_comment_only) { @$ar = @comment_ar; return; } if (defined($replace_empty_comment) && scalar(@comment_ar) == 0) { push @comment_ar, "$replace_empty_comment\n"; } @$ar = @header_ar; if (scalar(@comment_ar)) { if (scalar(@header_ar)) { push @$ar, "\n"; } push @$ar, @comment_ar; } if (scalar(@footer_ar)) { push @$ar, "\n"; push @$ar, @footer_ar; } if ($replace) { push @$ar, "\n"; } } # read a line from $fh, using anything queued up in @$buf first # sub read_next_line($$) { my ($fh, $buf) = @_; my $line; $line = pop @$buf; if (!defined($line)) { $line = $fh->getline(); } return $line; } # diff reading state machine. When it returns a state of "done" # that means the line is the start of the diff. Any state other then # "comment" might be the start of the diff, the only way to know for # sure is to check the following line. # sub process_line($$) { my ($line, $state) = @_; my $return_state = ""; # bk uses this: ===== fs/reiserfs/inode.c 1.49 vs 1.50 ===== if ($line =~ m/(^Index:)|(^=====.*vs.*=====$)/) { $return_state = "index"; } elsif ($line =~ m/^=================/ && $state eq "index") { $return_state = "done"; } elsif ($line =~ m/^diff/) { $return_state = "diff"; } elsif ($line =~ m/(^---)|(^\+\+\+)/) { $return_state = "done"; } elsif ($state ne "comment") { $return_state = "comment"; } else { $return_state = "comment"; } return $return_state; } $ret = GetOptions("add:s%" => \%add_tags, "Add:s%" => \%always_add_tags, "delete:s%" => \%delete_tags, "edit" => \$edit, "guard=s" => \$guard, "tag:s%" => \%tags, "print:s" => \%print_tags, "Print-comment" => \$print_comment_only, "comment" => \$new_comment, "Comment=s" => \$replace_empty_comment, "multiline" => \$multiline, "summary" => \$summary, ) || print_usage(); @files = @ARGV; if (scalar(@ARGV) < 1) { print_usage(); } if ($new_comment && scalar(@ARGV) > 1) { print STDERR "error: --comment can only be used on one file at a time\n"; print_usage(); } fill_hash_from_file(\%add_tags); fill_hash_from_file(\%always_add_tags); if ($summary && !%print_tags) { $print_tags{'subject'} = 1; $print_tags{'references'} = 1; $print_tags{'suse-bugzilla'} = 1; } # if we're in edit mode and no tags are provided, check for a default # template file. if ($edit && keys(%tags) == 0 && keys(%add_tags) == 0 && keys(%always_add_tags) == 0 && -r "$ENV{'HOME'}/.patchtag") { $add_tags{"$ENV{'HOME'}/.patchtag"} = ""; fill_hash_from_file(\%add_tags); } # never overwrite the original when --print is used # if ((%add_tags || %always_add_tags || %tags || $new_comment || $edit || %delete_tags || $replace_empty_comment) && !(%print_tags || $print_comment_only)) { $replace = 1; } %tags = lc_hash(%tags); %print_tags = lc_hash(%print_tags); %add_tags = lc_hash(%add_tags); %always_add_tags = lc_hash(%always_add_tags); %delete_tags = lc_hash(%delete_tags); # if we're editing setup the default tags if ($edit && keys(%add_tags) == 0) { my @words = split /:/, $default_comment; foreach my $w (@words) { $add_tags{$w} = ""; } } foreach my $guarded_input (@files) { my $last = ""; my $scan_state = "comment"; my @input_buffer = (); my $input; if ($summary && $guard && $guarded_input =~ m/($guard)(.+)/) { $input = $2; } else { $input = $guarded_input; } $infh = new IO::File; $infh->open("<$input") || die "Unable to open $input for reading"; %replaced_tags = (%tags, %add_tags); @all_tags = (); @output_array = (); @bk_footer_tags = (); my %tmp_always_add = %always_add_tags; if ($replace) { $outfh = new File::Temp(TEMPLATE => "$input.XXXXXX", UNLINK => 0) || die "Unable to create temp file"; } else { $outfh = new IO::File; $outfh->open(">-") || die "Unable to open stdout"; } # loop through until the start of the diff. while($_ = read_next_line($infh, \@input_buffer)) { $scan_state = process_line($_, $scan_state); if ($scan_state eq "done") { push @input_buffer, $_; last; } if ($scan_state ne "comment") { my $next = read_next_line($infh, \@input_buffer); $scan_state = process_line($next, $scan_state); if ($scan_state ne "comment") { push @input_buffer, $next; push @input_buffer, $_; last; } push @input_buffer, $next; } check_tags($infh, \@input_buffer, $_); } # pop ws off the end of the output array; while($output_array[scalar(@output_array)-1] =~ m/^\s*\n$/) { pop @output_array; } foreach my $h (@bk_footer_tags) { process_tag($h->[0], $h->[1]); } # add any new tags left over, but do From and Subject first add_output_tag('from', \%replaced_tags); add_output_tag('subject', \%replaced_tags); foreach my $h (sort(keys(%replaced_tags))) { add_output_tag($h, \%replaced_tags); } # add any of the tags from -A foreach my $h (sort(keys(%tmp_always_add))) { add_output_tag($h, \%tmp_always_add); } # replace the comment entirely for -c if ($new_comment) { while() { print $outfh $_; } } else { cleanup_blank_lines(\@output_array); print_output_array($guarded_input, \@output_array); } # in replace mode, copy our temp file over the original. if ($replace) { while($_ = read_next_line($infh, \@input_buffer)) { print $outfh $_; } unlink "$input" || die "Unable to unlink $input"; rename $outfh->filename, $input || die "Unable to rename $outfh->filename to $input"; } if ($edit) { my $editor = "vi"; if (defined($ENV{'EDITOR'})) { $editor = $ENV{'EDITOR'}; } $ret = system("$editor $input"); if ($ret) { $ret = $ret >> 8; print STDERR "warning $editor exited with $ret\n"; } } $infh->close(); $outfh->close(); }