#! /usr/bin/perl # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # # This script is maintained at https://github.com/openSUSE/linuxrc-devtools # # If you're in another project, this is just a copy. # You may update it to the latest version from time to time... # # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - use strict; use Getopt::Long; use Data::Dumper; $Data::Dumper::Sortkeys = 1; $Data::Dumper::Terse = 1; $Data::Dumper::Indent = 1; sub usage; sub changelog_outdated; sub get_github_project; sub get_version; sub get_tags; sub get_log; sub is_formatted_tag; sub get_branch; sub choose_tags; sub add_head_tag; sub tags_to_str; sub format_log; sub format_all_logs; sub fix_dates; sub add_line_breaks; sub format_date_obs; sub format_date_iso; sub raw_date_to_s; usage 0 if !@ARGV; my @changelog_deps = qw ( .git/HEAD .git/refs/heads .git/refs/tags ); my $branch; my $current_version; my @tags; my @all_tags; my $config; my $opt_log; my $opt_version; my $opt_branch; my $opt_update; my $opt_file; my $opt_start; my $opt_max; my $opt_width = 66; my $opt_width_fuzz = 8; my $opt_sep_width = 68; my $opt_format = 'internal'; # obs, internal my $opt_merge_msg_before = 1; # log auto generated pr merge message before the commit messages (vs. after) my $opt_join_author = 1; # join consecutive commit messages as long as they are by the same author my $opt_keep_date = 1; # don't join consecutive commit messages if they have different time stamps my $opt_default_email = 'opensuse-packaging@opensuse.org'; # default email to use in changelog GetOptions( 'help' => sub { usage 0 }, 'version' => \$opt_version, 'branch' => \$opt_branch, 'update' => \$opt_update, 'start=s' => \$opt_start, 'format=s' => \$opt_format, 'max=i' => \$opt_max, 'width=i' => \$opt_width, 'fuzz=i' => \$opt_width_fuzz, 'merge-msg=s' => sub { $opt_merge_msg_before = ($_[1] eq 'after' ? 0 : 1) }, 'join-author!' => \$opt_join_author, 'keep-date!' => \$opt_keep_date, 'log|changelog' => \$opt_log, 'default-email=s' => \$opt_default_email, ) || usage 1; # ensure we are used correctly usage 1 if @ARGV > 1 || !($opt_log || $opt_version || $opt_branch); $opt_file = @ARGV ? shift : '-'; die "no git repo\n" unless -d ".git"; # if update option has been give write changelog only if git refs are newer exit 0 if $opt_update && $opt_file ne '-' && -f($opt_file) && !changelog_outdated($opt_file); $opt_max = 2 if $opt_version || $opt_branch; # gather some data get_github_project; get_branch; get_log; fix_dates; get_tags; choose_tags; add_head_tag; get_version; # just print current branch if($opt_branch) { open my $f, ">$opt_file"; print $f $config->{branch} ? $config->{branch} : "master", "\n"; close $f; exit 0; } # just print current version if($opt_version) { my $old_version; if($opt_file ne '-' && open(my $f, $opt_file)) { chomp($old_version = <$f>); close $f; } if($config->{version} ne $old_version) { open my $f, ">$opt_file"; print $f "$config->{version}\n"; close $f; } exit 0; } # set start tag if($opt_start) { my $x = is_formatted_tag $opt_start; die "$opt_start: not a valid start tag\n" if !$x; $x->{branch} = $config->{branch} if !$x->{branch}; $config->{start} = $x; } format_all_logs; open my $f, ">$opt_file"; print $f $_->{formatted} for @{$config->{log}}; close $f; exit 0; # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # usage(exit_code) # # Print help message and exit. # - exit_code: exit code # # Function does not return. # sub usage { my $err = shift; print <<" usage"; Usage: git2log [OPTIONS] [FILE] Create changelog and project version from git repo. --changelog Write changelog to FILE. --version Write version number to FILE. --branch Write current branch to FILE. --start START_TAG Start with tag START_TAG. --max N Write at most MAX long entries. --update Write changelog or version only if FILE is outdated. --format FORMAT Write log using FORMAT. Supported FORMATs are 'internal' (default) and 'obs'. --width WIDTH Reformat log entries to be max WIDTH chars wide. --fuzz FUZZ Allow log lines to be up to FUZZ chars longer as WIDTH to avoid line breaks leaving tiny bits on the last line. --merge-msg WHERE Log message about merges before or after the actual merge commit messages. Valid values for WHERE are 'after' and 'before' (default). --join-author Join consecutive commits as long as they are by the same author. (default) --no-join-author Keep consecutive commits by the same author separate. --keep-date Join consecutive commits only if they have the same date. (default) --no-keep-date Join consecutive commits even if dates differ. --default-email Use this email in changelog entries if no other suitable email could be determined (default: opensuse-packaging\@opensuse.org). --help Print this help text. usage exit $err; } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # res = changelog_outdated(file) # # Return status of changelog file. # - file: changelog file name # - res: status # 1: file is newer than the last git repo change and should be updated # 0: file is still recent enough # # Relies on global var @changelog_deps. # sub changelog_outdated { my $file = $_[0]; my $changelog_time = (stat $file)[9]; return 1 if !defined $changelog_time; for (@changelog_deps) { return 1 if (stat)[9] > $changelog_time; } return 0; } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # get_github_project() # # Set $config->{github_project} to the github project name. # sub get_github_project { if(`git config remote.origin.url` =~ m#github.com[:/]+(\S+/\S+)#) { $config->{github_project} = $1; $config->{github_project} =~ s/\.git$//; } } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # get_version() # # Set $config->{branch} and $config->{version} to the current branch and # version info. # # This might be taken directly from HEAD if HEAD is tagged or otherwise be # exprapolated from the most recent tag (cf. add_head_tag()). # sub get_version { $config->{version} = "0.0"; my $tag = $config->{log}[0]{tags}[0]; if($tag->{version}) { $config->{version} = $tag->{version}; $config->{branch} = $tag->{branch}; } } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # get_tags() # # Parse $config->{raw_log}, extract tag names, and split into per-tag # sections. # # Only tags recognized by is_formatted_tag() are considered. # # The parsed logs is stored in $config->{log}, an array of log sections. # Each section is a hash with these keys: # - 'tags': array of tags for this section # - 'commit': git commit id associated with these tags # - 'lines': git log lines # sub get_tags { my $log_entry; for (@{$config->{raw_log}}) { if(/^commit (\S+)( \((.*)\))?/) { my $commit = $1; my $tag_list = $3; my $xtag; for my $t (split /, /, $tag_list) { if($t =~ /tag: (\S+)/) { my $tag = $1; my $x = is_formatted_tag $tag; push @$xtag, $x if $x; } } if($xtag) { if($log_entry) { push @{$config->{log}}, $log_entry; last if $opt_max && @{$config->{log}} >= $opt_max; } $log_entry = { commit => $commit, tags => $xtag }; } else { $log_entry = { commit => $commit } if !$log_entry; } } push @{$log_entry->{lines}}, $_ if $log_entry; } } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # get_log() # # Read git log and store lines as array in $config->{raw_log} (trailing # newlines removed). # sub get_log { chomp(@{$config->{raw_log}} = `git log --pretty=medium --date=raw --topo-order --decorate`); } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # hash_ref = is_formatted_tag(tag_name) # # Parse tag and return hash ref with branch and version number parts or # undef if it doesn't match. # - tag_name: tag as string # - hash_ref: hash ref with internal tag representation (with keys 'branch' and 'version'). # # This expects tags of the form "VERSION" or "BRANCH-VERSION" where VERSION # consists of decimal numbers separated by dots '.' and BRANCH can be any # string. # (Note: it doesn't really have to be the name of an existing branch.) # # Tags not conforming to this convention are ignored. # sub is_formatted_tag { if($_[0] =~ /^((.+)-)?((\d+\.)*\d+)$/) { return { branch => $2, version => $3 } } return undef; } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # get_branch() # # Get currently active git branch and store in $config->{branch}. # # 'master' branch is represented by empty 'branch' key. # sub get_branch { chomp(my $branch = `git rev-parse --abbrev-ref HEAD`); $branch = "" if $branch eq 'master'; $config->{branch} = $branch; } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # res = tag_sort(a, b) # # Compare 2 tags. # - a, b: refs to tag hash # - res: -1, 0, 1 # # This is used when we have to decide between alternative tags. # (Prefer 'lesser' variant.) # sub tag_sort { my ($x, $y); $x = length $a->{version}; $y = length $b->{version}; # longer version number first return $y <=> $x if $y <=> $x; return $a->{branch} cmp $b->{branch}; } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # str = tag_to_str(tag_ref) # # Convert tag into string. # - tag_ref: ref to hash with 'branch' and 'version' keys # - str: string (e.g. "foo-1.44") # # 'master' branch is represented by missing/empty 'branch' key. # sub tag_to_str { my $tag = $_[0]; my $str; $str = "$tag->{branch}-" if $tag->{branch} ne ""; $str .= $tag->{version}; return $str; } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # str = tags_to_str(tag_array_ref) # # Convert array of tags into string. # - tag_array_ref: ref to array of tags # - str: string (e.g. "(tag1, tag2)" # # This function is used only internally for debugging. # sub tags_to_str { my $tags = $_[0]; my $str; for my $t (@$tags) { $str .= ", " if $str; $str .= tag_to_str $t; } return "($str)"; } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # choose_tags() # # Scan commit messages and extract tag & branch information. # # This stores the tag/branch info in $config->{log}[]{tags}. # sub choose_tags { my $branch = $config->{branch}; for my $x (@{$config->{log}}) { # printf "# %s\n", tags_to_str($x->{tags}); # no tag info? -> ignore next if !$x->{tags}; # single tag? -> remember branch info if(@{$x->{tags}} == 1) { $branch = $x->{tags}[0]{branch}; next; } # several tags? -> choose one # any with current branch name? my @t = grep { $_->{branch} eq $branch } @{$x->{tags}}; # no? -> choose among all @t = @{$x->{tags}} if @t == 0; # prefer longest version number, then alphanumerically smallest branch name @t = sort tag_sort @t; $branch = $t[0]{branch}; $x->{tags} = [ $t[0] ]; # printf "X %s\n", tags_to_str($x->{tags}); } } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # add_head_tag() # # Suggest tag for HEAD if there isn't one. # # Basically, use branch + version from most recent tag and increment version. # sub add_head_tag { return if @{$config->{log}} < 2; # HEAD tagged already? return if $config->{log}[0]{tags}; # the first tagged commit if HEAD isn't tagged my $tag = { %{$config->{log}[1]{tags}[0]} }; # increment version $tag->{version} =~ s/(\d+)$/$1 + 1/e; $config->{log}[0]{tags}[0] = $tag; # remember that the tag was generated $config->{log}[0]{was_untagged} = 1; } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # fix_dates() # # Adjust time stamps in entire git log. # # The time stamps of the git commits are not necessarily ordered by date. # But the generated changelog is required to have a strictly monotonic time. # # We do this by going through the log in reverse and rewriting any dates we # find whenever the date decreases. # # A minimum time difference of 1 second beween entries is maintained. # # Not very subtle but it works. # sub fix_dates { my $last_date; for (reverse @{$config->{raw_log}}) { # e.g. "Date: 1443184889 +0200" if(/^(Date:\s+)(\S+)(\s+\S+)/) { if(defined $last_date && $2 < $last_date) { $_ = "$1$last_date$3\n"; } else { $last_date = $2; } # ensure a minimal time gap of 1 second $last_date += 1; } } } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # format_all_logs() # # Format the entire git log. # # This is done for every code version individually (the log has already been # split accordingly). # # If $config->{start} is set, use this as starting point. Else format the # entire git log. # sub format_all_logs { # check if start tag actually exists - if not, print nothing if($config->{start}) { my $tag_found; for (@{$config->{log}}) { $tag_found = 1, last if grep { tag_to_str($config->{start}) eq tag_to_str($_) } @{$_->{tags}}; } return if !$tag_found; } for (@{$config->{log}}) { if($config->{start}) { # stop if we meet the start tag last if grep { tag_to_str($config->{start}) eq tag_to_str($_) } @{$_->{tags}}; } format_log $_; } } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # format_log(log) # # Format log messages. # - log: is an array ref with individual commits # # All commits belong to a specific code version (stored in $log->{tag}). # $log->{formatted} holds the result. # # The process is done in several individual steps, documented below in the code. # sub format_log { my $log = $_[0]; my $merge; my $commit; my $saved_commit; my $commits; for (@{$log->{lines}}) { if(/^commit (\S+)/) { $commit = { ref => $1 }; push @{$commits}, $commit; if( $merge && $merge->{merge_end} eq substr($commit->{ref}, 0, length($merge->{merge_end})) ) { undef $merge; } if($merge) { $commit->{merge_ref} = $merge->{ref}; $commit->{date} = $merge->{date}; # add to all commits so it's not lost when we re-arrange $commit->{merge_msg} = $merge->{msg} if $merge->{msg}; # saved entry no longer needed undef $saved_commit; } next; } if(/^Merge: (\S+)/) { if($commit) { $merge = { merge_end => $1, ref => $commit->{ref} } unless $merge; $saved_commit = pop @{$commits}; } undef $commit; next; } if(/^Date:\s+(\S.*)/) { if($commit) { $commit->{date} = $1 if !$commit->{date}; } elsif($merge) { $merge->{date} = $1 if !$merge->{date}; } next; } if(/^Author:\s+(\S.*)/) { $commit->{author} = $1 if $commit; $merge->{author} = $1 if $merge && !$merge->{author}; next; } if($commit) { push @{$commit->{lines}}, $_ if s/^ //; } elsif($merge && !$merge->{msg}) { if(/^ Merge pull request (#\d+) from (\S+)/) { if($config->{github_project}) { $merge->{msg} = "merge gh#$config->{github_project}$1"; } else { $merge->{msg} = "merge pr $2"; } } elsif(/^ Merge branch '([^']+)'/) { $merge->{msg} = "merge branch $1"; } } } # it can happen that there's a lonely merge commit left at the end if($merge && $saved_commit) { $saved_commit->{merge_ref} = $merge->{ref}; $saved_commit->{date} = $merge->{date}; $saved_commit->{author} = $merge->{author}; $saved_commit->{merge_msg} = $merge->{msg} if $merge->{msg}; $saved_commit->{formatted} = []; push @{$commits}, $saved_commit; } # Note: the individual steps below work on the array @$commits and modify # its content. # step 1 # - if there are paragraphs starting with '@log@' or '@+log@' # - delete first paragraph (short summary) # - else # - keep only first paragraph # - if there is a paragraph starting with '@-log', delete entire log # - tag commits that have a '@log@' tag so we can delete untagged commits # belonging to the same merge commit later my $tagged_merges = {}; for my $commit (@$commits) { my $para_cnt = 0; my $delete_all = 0; my $delete_first = 0; for (@{$commit->{lines}}) { $para_cnt++ if $_ eq ""; $para_cnt = 0, $delete_first = 1 if /^\@\+log\@/; $delete_all = 1 if /^\@\-log\@/; if(/^\@log\@/) { $para_cnt = 0; $commit->{clear} = 1; $tagged_merges->{$commit->{merge_ref}} = 1 if $commit->{merge_ref} || $log->{was_untagged}; } $_ = undef if $para_cnt; } shift @{$commit->{lines}} if $delete_first; $commit->{lines} = [] if $delete_all; } # step 2 # - clean up tagged commits or commits belonging to tagged merges for my $commit (@$commits) { next unless $commit->{clear} || $tagged_merges->{$commit->{merge_ref}}; for (@{$commit->{lines}}) { last if /^\@\+?log\@/; $_ = undef; } } # step 3 # - join lines for my $commit (@$commits) { my $lines; my $line; for (@{$commit->{lines}}) { next if $_ eq ""; if( s/^\s*[+\-][\-\s]*// || s/^\@\+?log\@// || $line eq "" ) { s/^\s*//; push @$lines, $line if $line ne ""; $line = $_; } else { s/^\s*//; $line .= " " if $line ne ""; $line .= $_; } } push @$lines, $line if $line ne ""; $commit->{formatted} = $lines if $lines; } # step 4 # - fix small glitches for my $commit (@$commits) { next unless $commit->{formatted}; for (@{$commit->{formatted}}) { s/(fate|bnc|bsc)\s*(#\d+)/\L$1\E$2/ig; } } # step 5 # - add merge info at the top or bottom (depending on $opt_merge_msg_before) my $merge_logged; for my $commit ($opt_merge_msg_before ? reverse(@$commits) : @$commits) { next unless $commit->{formatted}; if($commit->{merge_ref} && !$merge_logged->{$commit->{merge_ref}}) { $merge_logged->{$commit->{merge_ref}} = 1; if($commit->{merge_msg}) { if($opt_merge_msg_before) { unshift @{$commit->{formatted}}, $commit->{merge_msg}; } else { push @{$commit->{formatted}}, $commit->{merge_msg}; } } } } # step 6 # - join commit messages with same author (optionally even with different dates) my $commit0; for my $commit (@$commits) { next if !$commit->{formatted}; $commit0 = $commit, next if !$commit0; if( # $commit->{merge_ref} eq $commit0->{merge_ref} && ( $opt_join_author && ($commit->{author} eq $commit0->{author}) && (!$opt_keep_date || $commit->{date} eq $commit0->{date}) ) || $opt_format eq 'internal' ) { unshift @{$commit0->{formatted}}, @{$commit->{formatted}}; delete $commit->{formatted}; } else { $commit0 = $commit; } } # step 7 # - add version tag at the end of the first log entry for my $commit (@$commits) { next unless $commit->{formatted}; if($opt_format eq 'obs') { push @{$commit->{formatted}}, $log->{tags}[0]{version} if defined $log->{tags}[0]{version}; } else { # push @{$commit->{formatted}}, tag_to_str($log->{tags}[0]); } last; } # step 8 # - add line breaks for my $commit (@$commits) { next unless $commit->{formatted}; for (@{$commit->{formatted}}) { $_ = add_line_breaks $_; } } # step 9 # - generate final log message # # note: non-(open)suse email addresses are replaced by $opt_default_email my $formated_log; for my $commit (@$commits) { next unless $commit->{formatted} && @{$commit->{formatted}}; if($opt_format eq 'obs') { $formated_log .= "-" x $opt_sep_width . "\n"; $formated_log .= format_date_obs($commit->{date}); } else { $formated_log .= format_date_iso($commit->{date}); } if($opt_format eq 'obs') { my $auth = $commit->{author}; $auth =~ s/^.*.*$//; # replace non-suse e-mail addresses with a generic one if($auth !~ /\@(suse\.(com|cz|de)|opensuse\.org)$/) { $auth = $opt_default_email; } $formated_log .= " - $auth\n\n"; } else { $formated_log .= ":\t" . tag_to_str($log->{tags}[0]) . "\n"; } for (@{$commit->{formatted}}) { s/^/\t/mg if $opt_format eq 'internal'; $formated_log .= "$_\n"; } $formated_log .= "\n"; } $log->{formatted} = $formated_log; } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # new_text = add_line_breaks(text) # # Add line breaks to text. # - text: some text # - new_text: same text, reformatted # # Lines are formatted to have a maximal length of $opt_width. If this causes # the last line to be shorter than $opt_width_fuzz, it is appended to the # previous line. # sub add_line_breaks { my @words = split /\s+/, @_[0]; my $remaining_len = length(join '', @words); my $str = shift(@words); my $len = length $str; my $next_len; my $word_len; for (@words) { $word_len = length; $remaining_len -= $word_len; $next_len = $len + $word_len + 1; if( $next_len >= $opt_width && $next_len + $remaining_len + 1 >= $opt_width + $opt_width_fuzz ) { $str .= "\n $_"; $len = $word_len; } else { $str .= " $_"; $len += $word_len + 1; } } return "- " . $str; } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # seconds = raw_date_to_s(git_date) # # Convert git raw date to seconds. # - git_date: raw git format (e.g. "1443184889 +0200") # - seconds: the seconds part (e.g. "1443184889") # sub raw_date_to_s { return (split / /, $_[0])[0]; } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # date = format_date_obs(git_date) # # Convert git raw date to obs format. # - git_date: raw git format (e.g. "1443184889 +0200") # - date: obs format ("Fri Sep 25 12:41:29 UTC 2015") # sub format_date_obs { my @d = gmtime(raw_date_to_s($_[0])); return qw(Sun Mon Tue Wed Thu Fri Sat)[$d[6]] . " " . qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec)[$d[4]] . " " . $d[3] . " " . sprintf("%02d:%02d:%02d", $d[2], $d[1], $d[0]) . " UTC " . (1900 + $d[5]); } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # date = format_date_iso(git_date) # # Convert git raw date to iso format. # - git_date: raw git format (e.g. "1443184889 +0200") # - date: obs format ("2015-09-25") # sub format_date_iso { my @d = gmtime(raw_date_to_s($_[0])); return sprintf("%04d-%02d-%02d", 1900 + $d[5], $d[4] + 1, $d[3]); }