Blob Blame History Raw
#!/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(<STDIN>) {
	    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();
}