From 7ce6fba0c8f8fef6ca1f8ac02f0076d18798ea65 Mon Sep 17 00:00:00 2001 From: Ryan Jackson Date: Tue, 23 Mar 2010 12:05:14 -0600 Subject: [PATCH] gitmail: Improved tag, rewind, rebase support; minor cleanup - Improved support for lightweight tags - Supports create/update/delete - Show only current ref pointed to (or previously pointed to) - Show previous value of ref when deleted - Useful for recovering from accidental deletions - Distinguish between rewind and rebase - Show only new branch head on rewind - Show all commits on rebase if only commit message(s) changed (otherwise we wouldn't know about the commit at all) - Minor cleanup (remove duplicate code, etc.) --- tools/git/gitmail | 270 ++++++++++++++++++++++++++++------------------ 1 file changed, 164 insertions(+), 106 deletions(-) diff --git a/tools/git/gitmail b/tools/git/gitmail index 8ec55be9a..e33ea1702 100755 --- a/tools/git/gitmail +++ b/tools/git/gitmail @@ -19,12 +19,10 @@ # # TODO: # Users can add notifications for themselves -# Support branch deletion -# Support commits that remove revisions (ie. are not fast-forwards) -# Support tag creation/deletion/etc. # use strict; +use IPC::Open2; use POSIX 'setsid'; use Getopt::Long; sub get_config($$); @@ -178,7 +176,8 @@ my $EMPTYREV = "0"x40; my $CT_CREATE = "create"; my $CT_UPDATE = "update"; my $CT_DELETE = "delete"; -my $CT_FORCED_UPDATE = "force-update"; +my $CT_REWIND = "rewind"; +my $CT_REBASE = "rebase"; # # Tired of typing this and getting it wrong @@ -188,11 +187,11 @@ my $STDERRNULL = " 2> /dev/null"; ###################################################################### # Function Prototypes ###################################################################### -sub change_type($$); +sub change_type($$$); +sub ref_type($); sub rev_type($); sub revparse($); -sub changed_files($$); -sub changed_files_single_revision($); +sub changed_files(@); sub get_mail_addresses($@); sub get_merge_base($$); sub uniq(@); @@ -202,7 +201,6 @@ sub get_commits($$$); sub send_mail($$@); sub short_refname($); sub debug(@); -sub rev_string($$); sub object_exists($$); sub filter_out_objects_in_repo($@); @@ -252,10 +250,17 @@ if ($mailconfigfile) { # my @reflines; if ($testmode) { + my $fullref = `git rev-parse --symbolic-full-name $testbranch`; + if (!$fullref) { + exit(1); + } + my $newrev = `git rev-parse $fullref $STDERRNULL`; + chomp $newrev; + # # Provide a simple way to grab some commits - the three most recent ones # - @reflines = ("$testbranch~2 $testbranch $testbranch"); + @reflines = ("$newrev~2 $newrev $fullref"); } else { # # Get all of the references that are being pushed from stdin - we do this in @@ -284,6 +289,9 @@ if ($detach && !$debug) { # Loop over all of the references we got on stdin # foreach my $refline (@reflines) { + my @commits; + my @changed_files; + chomp $refline; debug("Read line '$refline'"); @@ -293,6 +301,7 @@ foreach my $refline (@reflines) { # happened in the middle # my ($oldrev, $newrev, $refname) = split(/\s+/, $refline); + my $ref_type = ref_type($refname); # # Use rev-parse so that fancy symbolic names, etc. can be used @@ -305,38 +314,57 @@ foreach my $refline (@reflines) { # Figure out what type of change occured (update, creation, deletion, etc.) # and what type of objects (commit, tree, etc.) we got # - my $ct = change_type($oldrev,$newrev); + my $ct = change_type($oldrev,$newrev,$ref_type); my $old_type = rev_type($oldrev); my $new_type = rev_type($newrev); debug("Change type: $ct ($old_type,$new_type)"); # - # For now, only handle commits that update existing branches or make - # new ones + # For now, only handle commit objects. Tag objects require extra work. # - if ($new_type ne "commit") { + if ($new_type && $new_type ne "commit") { debug("Skipping non-commit"); next; } # - # Get all commits between these two revisions - # and all files that changed + # Figure out which commits we're interested in based on reference type + # and change type. # - my @commits = get_commits($oldrev,$newrev,$refname); + if ($ref_type eq 'tag') { + if ($ct eq $CT_DELETE) { + # We want to know where the tag used to point before deletion + push @commits, $oldrev; + } else { + # Tags only have delete, create, and update. Rewind and rebase + # don't make sense in tag context. + # + # We only care about the new value of the tag here. + push @commits, $newrev; + } + } elsif ($ref_type eq 'branch') { + if ($ct eq $CT_DELETE) { + # We want to know where the branch used to point before deletion + push @commits, $oldrev; + } elsif ($ct eq $CT_REWIND) { + # There's no new history to show, but we still want to see where + # the branch now points. + push @commits, $newrev; + } else { + @commits = get_commits($oldrev,$newrev,$refname); + # We only want to see *new* commits, which means that commits already + # in the main repository need to be excluded too. + if (defined $exclude_repo) { + @commits = filter_out_objects_in_repo($exclude_repo, @commits); + } + } + } + + next unless (@commits); debug("commits are: ", join(" ",@commits)); - my @changed_files; - if (defined $exclude_repo || $ct eq $CT_FORCED_UPDATE) { - if (defined $exclude_repo) { - @commits = filter_out_objects_in_repo($exclude_repo, @commits); - next unless (@commits); - } - @changed_files = map { changed_files_single_revision($_) } @commits; - } else { - @changed_files = changed_files($oldrev,$newrev); - } + @changed_files = changed_files(@commits); debug("Changed files: ", join(",",@changed_files)); # @@ -364,8 +392,8 @@ debug("finishing"); # Does this change represent the creation, deletion, or update of an object? # Takes old and new revs # -sub change_type($$) { - my ($oldrev, $newrev) = @_; +sub change_type($$$) { + my ($oldrev, $newrev, $ref_type) = @_; # # We can detect creates and deletes by looking for a special 'null' @@ -375,13 +403,18 @@ sub change_type($$) { return $CT_CREATE; } elsif ($newrev eq $EMPTYREV) { return $CT_DELETE; + } elsif ($ref_type eq 'tag') { + return $CT_UPDATE; } else { - my $merge_base = get_merge_base($oldrev,$newrev); + my $merge_base = get_merge_base($oldrev,$newrev); my $oldrev = revparse($oldrev); + my $newrev = revparse($newrev); if ($merge_base eq $oldrev) { - return $CT_UPDATE; + return $CT_UPDATE; + } elsif ($merge_base eq $newrev) { + return $CT_REWIND; } else { - return $CT_FORCED_UPDATE; + return $CT_REBASE; } } } @@ -396,6 +429,20 @@ sub rev_type($) { return $rev_type; } +# +# Find out what type of reference this is +# +sub ref_type($) { + my ($ref) = @_; + my $type; + if ($ref =~ m#^refs/heads/#) { + $type = 'branch'; + } elsif ($ref =~ m#^refs/tags/#) { + $type = 'tag'; + } + return $type; +} + # # Parse (possibly) symbolic revision name into hash # Note: Dies if the revision name is bogus! @@ -413,30 +460,31 @@ sub revparse($) { } # -# Given two revisions, return a list of the files that were changed between -# them +# Given a list of commit object hashes, return the list of files changed by +# all commits. # -sub changed_files($$) { - my ($oldrev,$newrev) = @_; +sub changed_files(@) { + my %files; - my $revstring = rev_string($oldrev,$newrev); - debug("running '$GIT diff-tree -r --name-only '$revstring''"); - my @lines = `$GIT diff-tree -r --name-only '$revstring' $STDERRNULL`; - chomp @lines; - return @lines; -} + debug("running '$GIT diff-tree --stdin -r --name-only --no-commit-id' on '@_'"); + my $pid = open2(\*OUT, \*IN, "$GIT diff-tree --stdin -r --name-only --no-commit-id"); -# -# Given one revision, return a list of the files that were changed between -# it and its parents -# -sub changed_files_single_revision($) { - my ($rev) = @_; + print IN "$_\n" for (@_); + close(IN); + + while () { + chomp; + $files{$_} = 1; + } + close(OUT); - debug("running '$GIT diff-tree -r --name-only '$rev''"); - my @lines = `$GIT diff-tree -r --name-only '$rev' $STDERRNULL`; - chomp @lines; - return @lines; + waitpid($pid, 0); + my $rc = $? >> 8; + if ($rc) { + die "'git diff-tree' exited with return code $rc\n"; + } + + return keys(%files); } # @@ -581,7 +629,7 @@ sub commit_mail($\@$@) { # # Construct the subject line. For now, we just say what repo (if defined) - # and what branch it happened on + # and what branch/tag it happened on # my $subject = "git commit: "; my $ref_type; @@ -589,31 +637,37 @@ sub commit_mail($\@$@) { $subject .= "[$reponame] "; } - if ($refname =~ m#refs/tags/#) { - $ref_type = 'tag'; - } elsif ($refname =~ m#refs/heads/#) { - $ref_type = 'branch'; - } + $ref_type = ref_type($refname); $subject .= $ref_type . ' ' . short_refname($refname); + my $what_happened; if ($ct eq $CT_UPDATE) { - $subject .= " updated"; - } elsif ($ct eq $CT_FORCED_UPDATE) { - $subject .= " force-updated"; + $what_happened .= 'updated'; + } elsif ($ct eq $CT_REWIND) { + $what_happened .= 'rewound'; + } elsif ($ct eq $CT_REBASE) { + $what_happened .= 'rebased'; } elsif ($ct eq $CT_CREATE) { - $subject .= " created"; + $what_happened .= 'created'; } elsif ($ct eq $CT_DELETE) { - $subject .= " deleted"; + $what_happened .= 'deleted'; } + $subject .= ' ' . $what_happened; my $actionstring = ucfirst($ref_type) . ' ' . short_refname($refname) . - " has been ${ct}d\n\n"; + " has been $what_happened"; - if ($ct eq $CT_FORCED_UPDATE) { - $actionstring .= "New and/or modified commits shown below\n\n"; + if ($ct eq $CT_REBASE) { + $actionstring .= ". The following commits are new or have been modified:"; + } elsif ($ct eq $CT_REWIND) { + $actionstring .= " to point to the following commit:"; + } elsif ($ct eq $CT_DELETE) { + $actionstring .= ". It previously pointed to the following commit:"; } + $actionstring .= "\n\n"; + my @fullbody; foreach my $rev (@$commits) { # @@ -654,38 +708,47 @@ sub commit_mail($\@$@) { # sub get_commits($$$) { my ($oldrev,$newrev,$refname) = @_; - my $ct = change_type($oldrev,$newrev); + my $ct = change_type($oldrev,$newrev, ref_type($refname)); # # If this is an update, we can just ask git for the revisions between the - # two revisions we were given. We call git-cherry for this information - # so that we can identify if a particular commit already exists in the - # repository with a different commit hash (for the rebase case). + # two revisions we were given. # - if ($ct eq $CT_UPDATE || $ct eq $CT_FORCED_UPDATE) { + if ($ct eq $CT_UPDATE) { + my $revstring = "$oldrev..$newrev"; + + debug("running '$GIT rev-list --reverse --date-order '$revstring'"); + my @revs = `$GIT rev-list --reverse --date-order '$revstring'`; + chomp @revs; + return @revs; + } elsif ($ct eq $CT_REBASE) { debug("running '$GIT cherry '$oldrev' '$newrev'"); # Only return revs prefixed with a '+' since commits prefixed with a - # '-' are already in the repository with a different commit hash. We - # should have seen those already, so we don't want to see them here. - # Note that '+' commits may not be "new" per-se, but they are not the - # same as the existing commit with the same commit message due to - # rebasing. + # '-' are already in the repository with a different commit hash. + # + # The '-' commits are the same as their non-rebased counterparts, except + # their ancestry is different. For the email messages, we don't care + # about these since we should have seen the original commits already. + # + # The '+' commits are either new or are rebased commits whose *content* + # has changed. We definitely want to see these. Note that this only + # applies to the content of the commit, not the commit message. my @revs; + my @all_revs; for (`$GIT cherry '$oldrev' '$newrev'`) { + debug($_); chomp; - unshift @revs, $1 if (/^\+\s+(.*)$/); + @_ = split /\s+/, $_; + unshift @revs, $_[1] if ($_[0] eq '+'); + unshift @all_revs, $_[1]; } - return @revs; + # If cherry finds that all of the commits are already present, + # report 'em all anyway. We still need to know that the rebase + # happened, and reporting just the head doesn't make any sense. + @revs = @all_revs if (!@revs); + return @revs; } elsif ($ct eq $CT_CREATE) { - - # - # For tags, just return the new revision. This at least tells us - # where the tag points. - # - if ($refname =~ m#^refs/tags/#) { - return ($newrev); - } # # If it's a create, we have to be a bit more fancy: we look for all # commits reachable from the new branch, but *not* reachable from any @@ -710,6 +773,12 @@ sub get_commits($$$) { debug("running '$GIT rev-parse --not $other_branches | $GIT rev-list --pretty --stdin $newrev'"); my @commits = `$GIT rev-parse --not $other_branches | $GIT rev-list --reverse --date-order --stdin $newrev`; + # We always want to be notified when a branch is created, so if there are no commits reachable + # from only this branch just report on the head of the branch. + push @commits, $newrev if (!@commits); + + debug("commits are @commits"); + chomp @commits; return @commits; } @@ -779,9 +848,17 @@ sub short_refname($) { my $refname = `git rev-parse --abbrev-ref $ref $STDERRNULL`; chomp $refname; - # This shouldn't be necessary, but return the full ref if - # rev-parse doesn't return anything. + # Fall back to full name if rev-parse fails for some reason $refname = $ref if (!$refname); + debug("got short refname \"$refname\""); + + + # If the ref didn't get shortened, it may be because it was deleted. Just + # chop off 'refs/heads' or 'refs/tags' and return the rest. + if ($refname =~ m#^refs/(?:heads|tags)/(.*)#) { + $refname = $1; + } + return $refname; } @@ -825,25 +902,6 @@ sub get_config($$) { } } -# -# Return an appropriate string to get a revision: if the ref was created or -# deleted, this looks a little different -# -sub rev_string($$) { - my ($oldrev, $newrev) = @_; - my $ct = change_type($oldrev,$newrev); - if ($ct eq $CT_UPDATE) { - return "$oldrev..$newrev"; - } elsif ($ct eq $CT_CREATE) { - return $newrev; - } elsif ($ct eq $CT_DELETE) { - return $oldrev; - } else { - # Shouldn't be able to get here - return undef; - } -} - # # Returns the merge base (i.e., common ancestor) of # the two supplied revisions. -- GitLab